Files
bambuddy/backend/app/services/filament_requirements.py
T
Seb 14919a80a5 Merge pull request #1440 from Person2099/fix/filament-override-ams-mapping-dispatch
fix: compute AMS mapping from force-colour overrides when 3MF reqs unavailable
2026-05-20 11:25:45 +02:00

132 lines
5.5 KiB
Python

"""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),
}
)