filament_requirements.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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 (
  19. extract_nozzle_mapping_from_3mf,
  20. extract_rack_plan_from_3mf,
  21. )
  22. logger = logging.getLogger(__name__)
  23. def extract_filament_requirements(file_path: Path, plate_id: int | None = None) -> list[dict]:
  24. """Parse `[{slot_id, type, color, tray_info_idx, used_grams, nozzle_id?}]` from a 3MF.
  25. Args:
  26. file_path: Path to the 3MF.
  27. plate_id: When set, only return filaments used on that plate. When
  28. None, return every filament with `used_g > 0` across the file.
  29. Returns:
  30. Sorted list (by `slot_id`) of filament dicts. Empty list when the
  31. 3MF is unreadable, missing `Metadata/slice_info.config`, or has no
  32. filaments matching the plate filter — callers treat that as "no
  33. requirements" rather than an error so a malformed 3MF doesn't break
  34. the upload path.
  35. """
  36. if not file_path.exists():
  37. return []
  38. filaments: list[dict] = []
  39. try:
  40. with zipfile.ZipFile(file_path, "r") as zf:
  41. if "Metadata/slice_info.config" not in zf.namelist():
  42. return []
  43. content = zf.read("Metadata/slice_info.config").decode()
  44. root = ET.fromstring(content) # noqa: S314 # nosec B314
  45. if plate_id is not None:
  46. for plate_elem in root.findall("./plate"):
  47. plate_index = None
  48. for meta in plate_elem.findall("metadata"):
  49. if meta.get("key") == "index":
  50. try:
  51. plate_index = int(meta.get("value", "0"))
  52. except ValueError:
  53. pass
  54. break
  55. if plate_index == plate_id:
  56. _collect_filaments(plate_elem, filaments)
  57. break
  58. else:
  59. # Modern BambuStudio format wraps filaments inside <plate> elements.
  60. # When no plate filter is requested, collect from every plate and
  61. # deduplicate by slot_id (first occurrence wins after sort).
  62. plate_elems = root.findall("./plate")
  63. if plate_elems:
  64. for plate_elem in plate_elems:
  65. _collect_filaments(plate_elem, filaments)
  66. # Deduplicate: same slot_id can appear on multiple plates.
  67. # Keep the entry with the highest used_grams; ties go to the
  68. # first plate (stable after sort + dict insertion order).
  69. seen: dict[int, dict] = {}
  70. for f in filaments:
  71. sid = f["slot_id"]
  72. if sid not in seen or f["used_grams"] > seen[sid]["used_grams"]:
  73. seen[sid] = f
  74. filaments = list(seen.values())
  75. else:
  76. # Older / non-plate-wrapped format: filaments are direct children of root.
  77. _collect_filaments(root, filaments)
  78. filaments.sort(key=lambda x: x["slot_id"])
  79. # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
  80. # slot is fed into. Empty mapping for single-nozzle printers, in
  81. # which case we just don't add the key.
  82. # Same plate the filaments above were collected from: a multi-plate
  83. # file can assign one slot to different extruders per plate, and
  84. # annotating slot 2 with plate 3's nozzle is worse than not
  85. # annotating it.
  86. nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
  87. if nozzle_mapping:
  88. for filament in filaments:
  89. filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
  90. annotate_rack_groups(filaments, file_path, plate_id)
  91. except Exception as e:
  92. logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
  93. return []
  94. return filaments
  95. def annotate_rack_groups(filaments: list[dict], file_path: Path, plate_id: int | None) -> None:
  96. """Tag each filament with its group and that group's hotend needs (#1784).
  97. `nozzle_id` says which *carriage*, which is all a two-hotend printer needs.
  98. An H2C's rack carriage hosts six, so the print dialog also needs the
  99. filament *group* — the slicer's logical nozzle — to offer a rack position
  100. for it. Groups are the unit of choice, not slots: two slots in one group
  101. share a hotend and cannot be pointed at different positions.
  102. Annotated whenever the file describes a rack, independently of the nozzle
  103. mapping, which is deliberately withheld for exactly the multi-rack plates
  104. this is most needed for.
  105. Mutates ``filaments`` in place and returns nothing, so every caller lands
  106. on one implementation: the three filament-requirements paths (archive,
  107. library and this module's own parser) each build their filament list
  108. differently and would otherwise drift.
  109. """
  110. rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=plate_id)
  111. if rack_plan is None:
  112. return
  113. group_dicts = rack_plan.group_dicts()
  114. for filament in filaments:
  115. index = filament.get("slot_id", 0) - 1
  116. if not 0 <= index < len(rack_plan.slot_groups):
  117. continue
  118. group_id = rack_plan.slot_groups[index]
  119. if group_id < 0:
  120. continue
  121. filament["group_id"] = group_id
  122. filament["group"] = group_dicts.get(group_id)
  123. def overrides_for_plate(
  124. overrides: list[dict],
  125. file_path: Path | None,
  126. plate_id: int | None,
  127. ) -> list[dict]:
  128. """Drop the filament overrides whose slots this plate never prints.
  129. Queueing several plates of one 3MF builds a single override list out of every
  130. selected plate's filaments and hands that same list to each plate's item. A
  131. ``force_color_match`` entry blocks dispatch until the printer has that exact
  132. colour loaded, so a single-colour plate ended up waiting on every colour in
  133. the batch (#2551). Each item may only demand what its own plate consumes.
  134. Overrides are kept as-is when the plate's slots cannot be established (whole
  135. file selected, source gone, unreadable 3MF, malformed entry): an item that
  136. waits on a colour it does not need is visible and fixable, whereas one that
  137. silently loses a forced colour can dispatch the print in the wrong filament.
  138. """
  139. if not overrides or plate_id is None or file_path is None or not file_path.exists():
  140. return overrides
  141. plate_slots = {f["slot_id"] for f in extract_filament_requirements(file_path, plate_id)}
  142. if not plate_slots:
  143. logger.warning(
  144. "Cannot read the filaments of plate %s in %s; keeping all %d filament override(s)",
  145. plate_id,
  146. file_path.name,
  147. len(overrides),
  148. )
  149. return overrides
  150. narrowed = []
  151. for override in overrides:
  152. try:
  153. slot_id = int(override["slot_id"])
  154. except (KeyError, TypeError, ValueError):
  155. narrowed.append(override)
  156. continue
  157. if slot_id in plate_slots:
  158. narrowed.append(override)
  159. if len(narrowed) != len(overrides):
  160. logger.info(
  161. "Plate %s: kept %d of %d filament override(s) — the rest belong to other plates",
  162. plate_id,
  163. len(narrowed),
  164. len(overrides),
  165. )
  166. return narrowed
  167. def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
  168. """Walk every `./filament` child under `parent` and append normalised
  169. entries to `into`. Skips filaments with `used_g <= 0` (slot present in
  170. the slicer config but not consumed by this plate)."""
  171. for filament_elem in parent.findall("./filament"):
  172. filament_id = filament_elem.get("id")
  173. if not filament_id:
  174. continue
  175. try:
  176. used_grams = float(filament_elem.get("used_g", "0"))
  177. except (ValueError, TypeError):
  178. continue
  179. if used_grams <= 0:
  180. continue
  181. try:
  182. slot_id = int(filament_id)
  183. except (ValueError, TypeError):
  184. continue
  185. into.append(
  186. {
  187. "slot_id": slot_id,
  188. "type": filament_elem.get("type", ""),
  189. "color": filament_elem.get("color", ""),
  190. "tray_info_idx": filament_elem.get("tray_info_idx", ""),
  191. "used_grams": round(used_grams, 1),
  192. }
  193. )