test_slicer_3mf_convert.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. """Tests for the per-slice 3MF input normalisation helpers."""
  2. from __future__ import annotations
  3. import json
  4. import zipfile
  5. from io import BytesIO
  6. from backend.app.services.slicer_3mf_convert import (
  7. count_plates_in_3mf,
  8. extract_source_printer_model,
  9. merge_plate_3mfs,
  10. substitute_unused_plate_filaments,
  11. )
  12. def _make_3mf(entries: dict[str, bytes]) -> bytes:
  13. buf = BytesIO()
  14. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  15. for name, payload in entries.items():
  16. zf.writestr(name, payload)
  17. return buf.getvalue()
  18. class TestExtractSourcePrinterModel:
  19. def test_returns_canonical_short_code_for_x1c(self):
  20. # Raw field is the long display name; we need the short code so
  21. # is_dual_nozzle_model() matches against the model registry.
  22. cfg = json.dumps({"printer_model": "Bambu Lab X1 Carbon", "other": "field"}).encode()
  23. zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
  24. assert extract_source_printer_model(zip_bytes) == "X1C"
  25. def test_returns_canonical_short_code_for_h2d(self):
  26. cfg = json.dumps({"printer_model": "Bambu Lab H2D"}).encode()
  27. zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
  28. assert extract_source_printer_model(zip_bytes) == "H2D"
  29. def test_dual_nozzle_check_works_on_extracted_code(self):
  30. """The whole point of canonicalising in this helper: the result
  31. must feed straight into is_dual_nozzle_model() without further
  32. normalisation."""
  33. from backend.app.utils.printer_models import is_dual_nozzle_model
  34. h2d = _make_3mf({"Metadata/project_settings.config": json.dumps({"printer_model": "Bambu Lab H2D"}).encode()})
  35. x1c = _make_3mf(
  36. {"Metadata/project_settings.config": json.dumps({"printer_model": "Bambu Lab X1 Carbon"}).encode()}
  37. )
  38. assert is_dual_nozzle_model(extract_source_printer_model(h2d)) is True
  39. assert is_dual_nozzle_model(extract_source_printer_model(x1c)) is False
  40. def test_returns_none_when_field_missing(self):
  41. cfg = json.dumps({"other": "field"}).encode()
  42. zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
  43. assert extract_source_printer_model(zip_bytes) is None
  44. def test_returns_none_when_field_empty(self):
  45. cfg = json.dumps({"printer_model": ""}).encode()
  46. zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
  47. assert extract_source_printer_model(zip_bytes) is None
  48. def test_returns_none_when_no_embedded_config(self):
  49. zip_bytes = _make_3mf({"Metadata/other.txt": b"hello"})
  50. assert extract_source_printer_model(zip_bytes) is None
  51. def test_returns_none_for_non_zip_bytes(self):
  52. assert extract_source_printer_model(b"not a zip") is None
  53. def test_returns_none_for_malformed_json(self):
  54. zip_bytes = _make_3mf({"Metadata/project_settings.config": b"{not json"})
  55. assert extract_source_printer_model(zip_bytes) is None
  56. def test_returns_none_when_config_is_list_not_dict(self):
  57. cfg = json.dumps(["not", "a", "dict"]).encode()
  58. zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
  59. assert extract_source_printer_model(zip_bytes) is None
  60. class TestCountPlatesIn3mf:
  61. def test_counts_plater_id_entries(self):
  62. xml = (
  63. b'<?xml version="1.0"?>\n<config>\n'
  64. b'<plate><metadata key="plater_id" value="1"/></plate>\n'
  65. b'<plate><metadata key="plater_id" value="2"/></plate>\n'
  66. b'<plate><metadata key="plater_id" value="3"/></plate>\n'
  67. b"</config>\n"
  68. )
  69. zip_bytes = _make_3mf({"Metadata/model_settings.config": xml})
  70. assert count_plates_in_3mf(zip_bytes) == 3
  71. def test_returns_zero_for_no_model_settings(self):
  72. zip_bytes = _make_3mf({"3D/3dmodel.model": b"<model/>"})
  73. assert count_plates_in_3mf(zip_bytes) == 0
  74. def test_returns_zero_for_non_zip(self):
  75. assert count_plates_in_3mf(b"not a zip") == 0
  76. def test_returns_zero_when_no_plate_ids(self):
  77. zip_bytes = _make_3mf({"Metadata/model_settings.config": b"<config/>"})
  78. assert count_plates_in_3mf(zip_bytes) == 0
  79. class TestMergePlate3mfs:
  80. """Per-plate cross-class loop output → merged multi-plate 3MF. The
  81. merge needs to: (1) carry forward the first plate's base metadata
  82. (project_settings, model_settings, 3dmodel), (2) overlay each
  83. plate's gcode + thumbnails, (3) re-assemble slice_info.config to
  84. list every plate."""
  85. @staticmethod
  86. def _single_plate_3mf(plate_num: int, gcode_bytes: bytes, slice_info_block: str | None = None) -> bytes:
  87. slice_info = (
  88. '<?xml version="1.0" encoding="UTF-8"?>\n<config>\n'
  89. '<header><header_item key="X-BBL-Client-Type" value="slicer"/></header>\n'
  90. + (slice_info_block or f'<plate><metadata key="index" value="{plate_num}"/></plate>')
  91. + "\n</config>\n"
  92. ).encode("utf-8")
  93. return _make_3mf(
  94. {
  95. "3D/3dmodel.model": f"<model plate={plate_num}/>".encode(),
  96. "Metadata/project_settings.config": b'{"printer_model": "Bambu Lab H2D"}',
  97. "Metadata/model_settings.config": b"<config/>",
  98. "Metadata/slice_info.config": slice_info,
  99. f"Metadata/plate_{plate_num}.gcode": gcode_bytes,
  100. f"Metadata/plate_{plate_num}.gcode.md5": b"d41d8cd98f00b204e9800998ecf8427e",
  101. f"Metadata/plate_{plate_num}.json": b"{}",
  102. f"Metadata/plate_{plate_num}.png": b"PLATE_PNG",
  103. f"Metadata/plate_{plate_num}_small.png": b"SMALL",
  104. f"Metadata/top_{plate_num}.png": b"TOP",
  105. f"Metadata/pick_{plate_num}.png": b"PICK",
  106. }
  107. )
  108. def test_empty_input_raises(self):
  109. import pytest as _pytest
  110. with _pytest.raises(ValueError):
  111. merge_plate_3mfs([])
  112. def test_single_plate_is_passthrough(self):
  113. only = self._single_plate_3mf(1, b"GCODE-1")
  114. assert merge_plate_3mfs([(1, only)]) == only
  115. def test_overlays_per_plate_artifacts(self):
  116. p1 = self._single_plate_3mf(1, b"GCODE-PLATE-1")
  117. p2 = self._single_plate_3mf(2, b"GCODE-PLATE-2")
  118. p3 = self._single_plate_3mf(3, b"GCODE-PLATE-3")
  119. merged = merge_plate_3mfs([(1, p1), (2, p2), (3, p3)])
  120. with zipfile.ZipFile(BytesIO(merged), "r") as zf:
  121. assert zf.read("Metadata/plate_1.gcode") == b"GCODE-PLATE-1"
  122. assert zf.read("Metadata/plate_2.gcode") == b"GCODE-PLATE-2"
  123. assert zf.read("Metadata/plate_3.gcode") == b"GCODE-PLATE-3"
  124. # Per-plate thumbnails and json overlaid too.
  125. assert zf.read("Metadata/plate_2.png") == b"PLATE_PNG"
  126. assert zf.read("Metadata/plate_3_small.png") == b"SMALL"
  127. # Base 3MF's project_settings.config carried forward unchanged.
  128. assert zf.read("Metadata/project_settings.config") == p1_project(p1)
  129. def test_combined_slice_info_lists_every_plate(self):
  130. p1 = self._single_plate_3mf(1, b"G1", slice_info_block='<plate><metadata key="index" value="1"/></plate>')
  131. p2 = self._single_plate_3mf(2, b"G2", slice_info_block='<plate><metadata key="index" value="2"/></plate>')
  132. merged = merge_plate_3mfs([(1, p1), (2, p2)])
  133. with zipfile.ZipFile(BytesIO(merged), "r") as zf:
  134. info = zf.read("Metadata/slice_info.config").decode("utf-8")
  135. # Both plate blocks present.
  136. assert 'value="1"' in info
  137. assert 'value="2"' in info
  138. # Two <plate> blocks total (we don't include the source's stale
  139. # one from before slicing).
  140. assert info.count("<plate>") == 2
  141. def test_falls_back_to_source_thumbnails_when_sliced_outputs_lack_them(self):
  142. """BS CLI with --arrange generates fresh per-plate gcode but
  143. doesn't always write a fresh ``plate_N.png``. The merger's
  144. ``source_3mf_bytes`` fallback should fill the gap from the
  145. source 3MF's original per-plate render so the archive's per-
  146. plate previews aren't blank."""
  147. # Sliced outputs that lack plate_N.png entries entirely (only
  148. # gcode + json + md5 — the thumbnail slot is empty).
  149. def _no_thumb_3mf(plate_num: int) -> bytes:
  150. return _make_3mf(
  151. {
  152. "3D/3dmodel.model": b"<model/>",
  153. "Metadata/project_settings.config": b"{}",
  154. "Metadata/model_settings.config": b"<config/>",
  155. "Metadata/slice_info.config": (
  156. '<?xml version="1.0"?>\n<config>'
  157. f'<plate><metadata key="index" value="{plate_num}"/></plate>'
  158. "</config>"
  159. ).encode(),
  160. f"Metadata/plate_{plate_num}.gcode": f"G{plate_num}".encode(),
  161. }
  162. )
  163. source = _make_3mf(
  164. {
  165. "3D/3dmodel.model": b"<model/>",
  166. "Metadata/plate_1.png": b"SRC_PNG_1",
  167. "Metadata/plate_1_small.png": b"SRC_SMALL_1",
  168. "Metadata/plate_2.png": b"SRC_PNG_2",
  169. "Metadata/plate_2_small.png": b"SRC_SMALL_2",
  170. }
  171. )
  172. merged = merge_plate_3mfs(
  173. [(1, _no_thumb_3mf(1)), (2, _no_thumb_3mf(2))],
  174. source_3mf_bytes=source,
  175. )
  176. with zipfile.ZipFile(BytesIO(merged), "r") as zf:
  177. assert zf.read("Metadata/plate_1.png") == b"SRC_PNG_1"
  178. assert zf.read("Metadata/plate_1_small.png") == b"SRC_SMALL_1"
  179. assert zf.read("Metadata/plate_2.png") == b"SRC_PNG_2"
  180. assert zf.read("Metadata/plate_2_small.png") == b"SRC_SMALL_2"
  181. def test_source_fallback_does_not_overwrite_fresh_sliced_thumbnails(self):
  182. """If a sliced output DID write its own ``plate_N.png`` (same-class
  183. slice / older BS that renders even with arrange), keep it — the
  184. sliced render reflects the actual H2D layout; the source fallback
  185. only fills gaps."""
  186. p1 = self._single_plate_3mf(1, b"G1") # has plate_1.png = PLATE_PNG
  187. p2 = self._single_plate_3mf(2, b"G2") # has plate_2.png = PLATE_PNG
  188. source = _make_3mf(
  189. {
  190. "Metadata/plate_1.png": b"SRC_PNG_1",
  191. "Metadata/plate_2.png": b"SRC_PNG_2",
  192. }
  193. )
  194. merged = merge_plate_3mfs([(1, p1), (2, p2)], source_3mf_bytes=source)
  195. with zipfile.ZipFile(BytesIO(merged), "r") as zf:
  196. # Sliced output's thumbnails win.
  197. assert zf.read("Metadata/plate_1.png") == b"PLATE_PNG"
  198. assert zf.read("Metadata/plate_2.png") == b"PLATE_PNG"
  199. def test_unsorted_input_is_sorted_by_plate_number(self):
  200. p1 = self._single_plate_3mf(1, b"G1")
  201. p2 = self._single_plate_3mf(2, b"G2")
  202. # Pass them out of order; the merger should still place plate 2's
  203. # artifacts at plate_2.* and plate 1's at plate_1.*.
  204. merged = merge_plate_3mfs([(2, p2), (1, p1)])
  205. with zipfile.ZipFile(BytesIO(merged), "r") as zf:
  206. assert zf.read("Metadata/plate_1.gcode") == b"G1"
  207. assert zf.read("Metadata/plate_2.gcode") == b"G2"
  208. def p1_project(zip_bytes: bytes) -> bytes:
  209. """Helper for the merge test — pulls plate-1's project_settings.config out
  210. of a fixture so the test's assertion shows the actual reference value
  211. rather than hard-coding the literal."""
  212. with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
  213. return zf.read("Metadata/project_settings.config")
  214. class TestSubstituteUnusedPlateFilaments:
  215. """Slot 1 carries the used filament; unused-slot entries are
  216. overwritten with slot 1 so BambuStudio's filament-temp validator
  217. doesn't trip on heterogeneous loaded filaments that the plate's
  218. G-code never actually touches."""
  219. @staticmethod
  220. def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes:
  221. """Build a minimal model_settings.config mapping each plate to a set
  222. of extruder/slot numbers via per-object extruder metadata. Mirrors
  223. the schema ``extract_plate_extruder_set_from_3mf`` parses:
  224. - top-level ``<object id=N>`` with ``<metadata key="extruder" .../>``
  225. - per-plate ``<plate>`` listing the object ids it contains.
  226. ``per_plate_extruders`` is a list of (plate_id, [extruder_ids]).
  227. Object ids are auto-numbered globally so plates can reference them.
  228. """
  229. objects = []
  230. plates = []
  231. oid = 1
  232. for plate_id, exts in per_plate_extruders:
  233. plate_obj_refs = []
  234. for ext in exts:
  235. objects.append(f'<object id="{oid}"><metadata key="extruder" value="{ext}"/></object>')
  236. plate_obj_refs.append(
  237. f'<model_instance><metadata key="object_id" value="{oid}"/>'
  238. f'<metadata key="instance_id" value="0"/>'
  239. f'<metadata key="identify_id" value="{oid}"/></model_instance>'
  240. )
  241. oid += 1
  242. plates.append(
  243. f'<plate><metadata key="plater_id" value="{plate_id}"/>' + "".join(plate_obj_refs) + "</plate>"
  244. )
  245. xml = (
  246. '<?xml version="1.0" encoding="UTF-8"?>\n'
  247. "<config>\n" + "\n".join(objects) + "\n" + "\n".join(plates) + "\n" + "</config>"
  248. )
  249. return xml.encode("utf-8")
  250. def test_substitutes_unused_slot_with_slot_1(self):
  251. # Plate 1 uses slot 1 only; slots 2 and 3 are loaded but unused.
  252. zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])})
  253. items = ["pla_basic.json", "abs_loaded_but_unused.json", "abs_again_unused.json"]
  254. result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
  255. assert result == ["pla_basic.json", "pla_basic.json", "pla_basic.json"]
  256. def test_no_substitution_when_all_used(self):
  257. # Multi-colour plate where every slot is used: leave the user's selections alone.
  258. zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1, 2, 3])])})
  259. items = ["pla_white.json", "pla_red.json", "pla_blue.json"]
  260. result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
  261. assert result == ["pla_white.json", "pla_red.json", "pla_blue.json"]
  262. def test_no_op_when_plate_id_is_none(self):
  263. items = ["a.json", "b.json", "c.json"]
  264. result = substitute_unused_plate_filaments(b"any bytes", plate_id=None, items=items)
  265. assert result == items
  266. def test_no_op_when_single_filament(self):
  267. result = substitute_unused_plate_filaments(b"any bytes", plate_id=1, items=["only.json"])
  268. assert result == ["only.json"]
  269. result = substitute_unused_plate_filaments(b"any bytes", plate_id=1, items=[])
  270. assert result == []
  271. def test_no_op_when_source_not_zip(self):
  272. items = ["a.json", "b.json"]
  273. result = substitute_unused_plate_filaments(b"not a zip", plate_id=1, items=items)
  274. assert result == items
  275. def test_no_op_when_no_model_settings(self):
  276. # Empty parse result is treated as "every slot used" (fail-open default).
  277. zip_bytes = _make_3mf({"3D/3dmodel.model": b"<model/>"})
  278. items = ["a.json", "b.json", "c.json"]
  279. result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
  280. assert result == items