filament_requirements.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  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. _collect_filaments(root, filaments)
  57. filaments.sort(key=lambda x: x["slot_id"])
  58. # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
  59. # slot is fed into. Empty mapping for single-nozzle printers, in
  60. # which case we just don't add the key.
  61. nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
  62. if nozzle_mapping:
  63. for filament in filaments:
  64. filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
  65. except Exception as e:
  66. logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
  67. return []
  68. return filaments
  69. def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
  70. """Walk every `./filament` child under `parent` and append normalised
  71. entries to `into`. Skips filaments with `used_g <= 0` (slot present in
  72. the slicer config but not consumed by this plate)."""
  73. for filament_elem in parent.findall("./filament"):
  74. filament_id = filament_elem.get("id")
  75. if not filament_id:
  76. continue
  77. try:
  78. used_grams = float(filament_elem.get("used_g", "0"))
  79. except (ValueError, TypeError):
  80. continue
  81. if used_grams <= 0:
  82. continue
  83. try:
  84. slot_id = int(filament_id)
  85. except (ValueError, TypeError):
  86. continue
  87. into.append(
  88. {
  89. "slot_id": slot_id,
  90. "type": filament_elem.get("type", ""),
  91. "color": filament_elem.get("color", ""),
  92. "tray_info_idx": filament_elem.get("tray_info_idx", ""),
  93. "used_grams": round(used_grams, 1),
  94. }
  95. )