filament_requirements.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  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 overrides_for_plate(
  88. overrides: list[dict],
  89. file_path: Path | None,
  90. plate_id: int | None,
  91. ) -> list[dict]:
  92. """Drop the filament overrides whose slots this plate never prints.
  93. Queueing several plates of one 3MF builds a single override list out of every
  94. selected plate's filaments and hands that same list to each plate's item. A
  95. ``force_color_match`` entry blocks dispatch until the printer has that exact
  96. colour loaded, so a single-colour plate ended up waiting on every colour in
  97. the batch (#2551). Each item may only demand what its own plate consumes.
  98. Overrides are kept as-is when the plate's slots cannot be established (whole
  99. file selected, source gone, unreadable 3MF, malformed entry): an item that
  100. waits on a colour it does not need is visible and fixable, whereas one that
  101. silently loses a forced colour can dispatch the print in the wrong filament.
  102. """
  103. if not overrides or plate_id is None or file_path is None or not file_path.exists():
  104. return overrides
  105. plate_slots = {f["slot_id"] for f in extract_filament_requirements(file_path, plate_id)}
  106. if not plate_slots:
  107. logger.warning(
  108. "Cannot read the filaments of plate %s in %s; keeping all %d filament override(s)",
  109. plate_id,
  110. file_path.name,
  111. len(overrides),
  112. )
  113. return overrides
  114. narrowed = []
  115. for override in overrides:
  116. try:
  117. slot_id = int(override["slot_id"])
  118. except (KeyError, TypeError, ValueError):
  119. narrowed.append(override)
  120. continue
  121. if slot_id in plate_slots:
  122. narrowed.append(override)
  123. if len(narrowed) != len(overrides):
  124. logger.info(
  125. "Plate %s: kept %d of %d filament override(s) — the rest belong to other plates",
  126. plate_id,
  127. len(narrowed),
  128. len(overrides),
  129. )
  130. return narrowed
  131. def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
  132. """Walk every `./filament` child under `parent` and append normalised
  133. entries to `into`. Skips filaments with `used_g <= 0` (slot present in
  134. the slicer config but not consumed by this plate)."""
  135. for filament_elem in parent.findall("./filament"):
  136. filament_id = filament_elem.get("id")
  137. if not filament_id:
  138. continue
  139. try:
  140. used_grams = float(filament_elem.get("used_g", "0"))
  141. except (ValueError, TypeError):
  142. continue
  143. if used_grams <= 0:
  144. continue
  145. try:
  146. slot_id = int(filament_id)
  147. except (ValueError, TypeError):
  148. continue
  149. into.append(
  150. {
  151. "slot_id": slot_id,
  152. "type": filament_elem.get("type", ""),
  153. "color": filament_elem.get("color", ""),
  154. "tray_info_idx": filament_elem.get("tray_info_idx", ""),
  155. "used_grams": round(used_grams, 1),
  156. }
  157. )