filament_requirements.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. """Parse per-slot filament requirements out of a 3MF file.
  2. The scheduler used to own this logic (`PrintScheduler._get_filament_requirements`)
  3. because it ran during dispatch decisions. Extracted here so the VP queue-mode
  4. write path can use the same parser to populate `filament_overrides` /
  5. `required_filament_types` at upload time (#1188 — Bambuddy was creating queue
  6. items with no filament fields, which made the scheduler fall through to
  7. model-only matching and dispatch onto whatever printer happened to be free
  8. regardless of loaded colour).
  9. The shape returned here matches the `filament_overrides` JSON shape the
  10. scheduler validates against, minus the `force_color_match` flag — callers
  11. add that themselves based on their own setting.
  12. """
  13. from __future__ import annotations
  14. import logging
  15. import xml.etree.ElementTree as ET
  16. import zipfile
  17. from pathlib import Path
  18. from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
  19. logger = logging.getLogger(__name__)
  20. def extract_filament_requirements(file_path: Path, plate_id: int | None = None) -> list[dict]:
  21. """Parse `[{slot_id, type, color, tray_info_idx, used_grams, nozzle_id?}]` from a 3MF.
  22. Args:
  23. file_path: Path to the 3MF.
  24. plate_id: When set, only return filaments used on that plate. When
  25. None, return every filament with `used_g > 0` across the file.
  26. Returns:
  27. Sorted list (by `slot_id`) of filament dicts. Empty list when the
  28. 3MF is unreadable, missing `Metadata/slice_info.config`, or has no
  29. filaments matching the plate filter — callers treat that as "no
  30. requirements" rather than an error so a malformed 3MF doesn't break
  31. the upload path.
  32. """
  33. if not file_path.exists():
  34. return []
  35. filaments: list[dict] = []
  36. try:
  37. with zipfile.ZipFile(file_path, "r") as zf:
  38. if "Metadata/slice_info.config" not in zf.namelist():
  39. return []
  40. content = zf.read("Metadata/slice_info.config").decode()
  41. root = ET.fromstring(content) # noqa: S314 # nosec B314
  42. if plate_id is not None:
  43. for plate_elem in root.findall("./plate"):
  44. plate_index = None
  45. for meta in plate_elem.findall("metadata"):
  46. if meta.get("key") == "index":
  47. try:
  48. plate_index = int(meta.get("value", "0"))
  49. except ValueError:
  50. pass
  51. break
  52. if plate_index == plate_id:
  53. _collect_filaments(plate_elem, filaments)
  54. break
  55. else:
  56. # Modern BambuStudio format wraps filaments inside <plate> elements.
  57. # When no plate filter is requested, collect from every plate and
  58. # deduplicate by slot_id (first occurrence wins after sort).
  59. plate_elems = root.findall("./plate")
  60. if plate_elems:
  61. for plate_elem in plate_elems:
  62. _collect_filaments(plate_elem, filaments)
  63. # Deduplicate: same slot_id can appear on multiple plates.
  64. # Keep the entry with the highest used_grams; ties go to the
  65. # first plate (stable after sort + dict insertion order).
  66. seen: dict[int, dict] = {}
  67. for f in filaments:
  68. sid = f["slot_id"]
  69. if sid not in seen or f["used_grams"] > seen[sid]["used_grams"]:
  70. seen[sid] = f
  71. filaments = list(seen.values())
  72. else:
  73. # Older / non-plate-wrapped format: filaments are direct children of root.
  74. _collect_filaments(root, filaments)
  75. filaments.sort(key=lambda x: x["slot_id"])
  76. # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
  77. # slot is fed into. Empty mapping for single-nozzle printers, in
  78. # which case we just don't add the key.
  79. nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
  80. if nozzle_mapping:
  81. for filament in filaments:
  82. filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
  83. except Exception as e:
  84. logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
  85. return []
  86. return filaments
  87. def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
  88. """Walk every `./filament` child under `parent` and append normalised
  89. entries to `into`. Skips filaments with `used_g <= 0` (slot present in
  90. the slicer config but not consumed by this plate)."""
  91. for filament_elem in parent.findall("./filament"):
  92. filament_id = filament_elem.get("id")
  93. if not filament_id:
  94. continue
  95. try:
  96. used_grams = float(filament_elem.get("used_g", "0"))
  97. except (ValueError, TypeError):
  98. continue
  99. if used_grams <= 0:
  100. continue
  101. try:
  102. slot_id = int(filament_id)
  103. except (ValueError, TypeError):
  104. continue
  105. into.append(
  106. {
  107. "slot_id": slot_id,
  108. "type": filament_elem.get("type", ""),
  109. "color": filament_elem.get("color", ""),
  110. "tray_info_idx": filament_elem.get("tray_info_idx", ""),
  111. "used_grams": round(used_grams, 1),
  112. }
  113. )