test_slicer_3mf_convert.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  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. """Unused-slot entries are overwritten with the plate's lowest *used*
  216. slot so BambuStudio's validators don't trip on loaded filaments the
  217. plate's G-code never actually touches — neither the filament-temp
  218. spread nor the preset-vs-printer compatibility check."""
  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
  281. def test_support_material_slot_preserved(self):
  282. # #1881 regression: object geometry references only slot 1 (PLA),
  283. # but slot 2 (PVA) is configured as the support material in
  284. # project_settings.config. Without the support-slot union, slot 2's
  285. # user-picked PVA profile would be overwritten with slot 1's PLA
  286. # and the print would come out single-material with PLA supports.
  287. model_settings = self._model_settings_xml([(1, [1])])
  288. project_settings = json.dumps(
  289. {
  290. "enable_support": "1",
  291. "support_filament": "2",
  292. "support_interface_filament": "2",
  293. "filament_type": ["PLA", "PVA"],
  294. }
  295. ).encode()
  296. zip_bytes = _make_3mf(
  297. {
  298. "Metadata/model_settings.config": model_settings,
  299. "Metadata/project_settings.config": project_settings,
  300. }
  301. )
  302. items = ["pla.json", "pva_support.json"]
  303. result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
  304. assert result == ["pla.json", "pva_support.json"]
  305. def test_support_disabled_still_substitutes_unused(self):
  306. # When supports are off, slot 2 is genuinely unused — the temp-spread
  307. # validator still needs the substitution to succeed.
  308. model_settings = self._model_settings_xml([(1, [1])])
  309. project_settings = json.dumps(
  310. {
  311. "enable_support": "0",
  312. "support_filament": "2",
  313. }
  314. ).encode()
  315. zip_bytes = _make_3mf(
  316. {
  317. "Metadata/model_settings.config": model_settings,
  318. "Metadata/project_settings.config": project_settings,
  319. }
  320. )
  321. items = ["pla.json", "abs_never_used.json"]
  322. result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
  323. assert result == ["pla.json", "pla.json"]
  324. # ---- #2628: the anchor is the lowest USED slot, not slot 1 ----------
  325. def test_substitutes_from_first_used_slot_when_slot_1_is_unused(self):
  326. """michaelklos's report: plate 2 of a multi-plate project uses only
  327. slot 2, while slot 1 carries an ``@Bambu Lab H2D`` preset baked into
  328. the source 3MF. Anchoring on slot 1 made the substitution a no-op for
  329. the one slot that needed it, and the CLI rejected the A1 slice with
  330. "filament preset (slot 1) is not compatible with printer
  331. Bambu Lab A1 0.4 nozzle"."""
  332. zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])})
  333. items = ["tpu_at_h2d.json", "pla_at_a1.json"]
  334. result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
  335. assert result == ["pla_at_a1.json", "pla_at_a1.json"]
  336. def test_unused_slot_1_does_not_poison_the_other_unused_slots(self):
  337. """With more than one unused slot, the old anchor propagated slot 1's
  338. own (foreign-printer) preset into every other unused slot — the exact
  339. poisoning #1851 removed from the picker."""
  340. zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [3])])})
  341. items = ["tpu_at_h2d.json", "abs.json", "pla_at_a1.json"]
  342. result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
  343. assert result == ["pla_at_a1.json", "pla_at_a1.json", "pla_at_a1.json"]
  344. def test_anchor_is_the_lowest_used_slot_not_merely_a_used_one(self):
  345. """Deterministic pick so the same project always slices identically."""
  346. zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2, 4])])})
  347. items = ["slot1.json", "slot2.json", "slot3.json", "slot4.json"]
  348. result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
  349. assert result == ["slot2.json", "slot2.json", "slot2.json", "slot4.json"]
  350. def test_no_op_when_every_used_slot_is_outside_the_submitted_list(self):
  351. """A plate referencing only slots beyond the picked list leaves nothing
  352. trustworthy to copy from — keep the user's picks rather than invent a
  353. substitution from a slot the plate doesn't use."""
  354. zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [5])])})
  355. items = ["a.json", "b.json"]
  356. result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
  357. assert result == ["a.json", "b.json"]
  358. def test_support_slot_can_be_the_anchor(self):
  359. """The support-filament union (#1881) feeds the same used-slot set, so
  360. a plate whose only geometry slot is 2 with PVA supports in 3 anchors on
  361. 2 — never on the unused slot 1."""
  362. model_settings = self._model_settings_xml([(1, [1]), (2, [2])])
  363. project_settings = json.dumps(
  364. {
  365. "enable_support": "1",
  366. "support_filament": "3",
  367. "support_interface_filament": "3",
  368. "filament_type": ["PLA", "PLA", "PVA"],
  369. }
  370. ).encode()
  371. zip_bytes = _make_3mf(
  372. {
  373. "Metadata/model_settings.config": model_settings,
  374. "Metadata/project_settings.config": project_settings,
  375. }
  376. )
  377. items = ["tpu_at_h2d.json", "pla.json", "pva.json"]
  378. result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
  379. assert result == ["pla.json", "pla.json", "pva.json"]