| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 |
- """Parse per-slot filament requirements out of a 3MF file.
- The scheduler used to own this logic (`PrintScheduler._get_filament_requirements`)
- because it ran during dispatch decisions. Extracted here so the VP queue-mode
- write path can use the same parser to populate `filament_overrides` /
- `required_filament_types` at upload time (#1188 — Bambuddy was creating queue
- items with no filament fields, which made the scheduler fall through to
- model-only matching and dispatch onto whatever printer happened to be free
- regardless of loaded colour).
- The shape returned here matches the `filament_overrides` JSON shape the
- scheduler validates against, minus the `force_color_match` flag — callers
- add that themselves based on their own setting.
- """
- from __future__ import annotations
- import logging
- import xml.etree.ElementTree as ET
- import zipfile
- from pathlib import Path
- from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
- logger = logging.getLogger(__name__)
- def extract_filament_requirements(file_path: Path, plate_id: int | None = None) -> list[dict]:
- """Parse `[{slot_id, type, color, tray_info_idx, used_grams, nozzle_id?}]` from a 3MF.
- Args:
- file_path: Path to the 3MF.
- plate_id: When set, only return filaments used on that plate. When
- None, return every filament with `used_g > 0` across the file.
- Returns:
- Sorted list (by `slot_id`) of filament dicts. Empty list when the
- 3MF is unreadable, missing `Metadata/slice_info.config`, or has no
- filaments matching the plate filter — callers treat that as "no
- requirements" rather than an error so a malformed 3MF doesn't break
- the upload path.
- """
- if not file_path.exists():
- return []
- filaments: list[dict] = []
- try:
- with zipfile.ZipFile(file_path, "r") as zf:
- if "Metadata/slice_info.config" not in zf.namelist():
- return []
- content = zf.read("Metadata/slice_info.config").decode()
- root = ET.fromstring(content) # noqa: S314 # nosec B314
- if plate_id is not None:
- for plate_elem in root.findall("./plate"):
- plate_index = None
- for meta in plate_elem.findall("metadata"):
- if meta.get("key") == "index":
- try:
- plate_index = int(meta.get("value", "0"))
- except ValueError:
- pass
- break
- if plate_index == plate_id:
- _collect_filaments(plate_elem, filaments)
- break
- else:
- # Modern BambuStudio format wraps filaments inside <plate> elements.
- # When no plate filter is requested, collect from every plate and
- # deduplicate by slot_id (first occurrence wins after sort).
- plate_elems = root.findall("./plate")
- if plate_elems:
- for plate_elem in plate_elems:
- _collect_filaments(plate_elem, filaments)
- # Deduplicate: same slot_id can appear on multiple plates.
- # Keep the entry with the highest used_grams; ties go to the
- # first plate (stable after sort + dict insertion order).
- seen: dict[int, dict] = {}
- for f in filaments:
- sid = f["slot_id"]
- if sid not in seen or f["used_grams"] > seen[sid]["used_grams"]:
- seen[sid] = f
- filaments = list(seen.values())
- else:
- # Older / non-plate-wrapped format: filaments are direct children of root.
- _collect_filaments(root, filaments)
- filaments.sort(key=lambda x: x["slot_id"])
- # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
- # slot is fed into. Empty mapping for single-nozzle printers, in
- # which case we just don't add the key.
- nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
- if nozzle_mapping:
- for filament in filaments:
- filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
- except Exception as e:
- logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
- return []
- return filaments
- def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
- """Walk every `./filament` child under `parent` and append normalised
- entries to `into`. Skips filaments with `used_g <= 0` (slot present in
- the slicer config but not consumed by this plate)."""
- for filament_elem in parent.findall("./filament"):
- filament_id = filament_elem.get("id")
- if not filament_id:
- continue
- try:
- used_grams = float(filament_elem.get("used_g", "0"))
- except (ValueError, TypeError):
- continue
- if used_grams <= 0:
- continue
- try:
- slot_id = int(filament_id)
- except (ValueError, TypeError):
- continue
- into.append(
- {
- "slot_id": slot_id,
- "type": filament_elem.get("type", ""),
- "color": filament_elem.get("color", ""),
- "tray_info_idx": filament_elem.get("tray_info_idx", ""),
- "used_grams": round(used_grams, 1),
- }
- )
|