test_printable_objects_plate.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Skip-object extraction must follow the plate that is actually printing (#2522).
  2. An "all plates" sliced export lists every plate in ``slice_info.config`` and
  3. ships a ``plate_N.json`` per plate. Bambuddy read the *first* plate whatever
  4. the printer was running, so a reporter printing plate 2 (one object) was
  5. offered plate 1's four objects, with plate 1's marker positions drawn over
  6. plate 2's thumbnail.
  7. Two defects fed that: no call site passed ``plate_number``, and the lookup it
  8. would have used (``.//plate[@plate_idx='N']``) tested an attribute Bambu and
  9. Orca never write — the index lives in a ``<metadata key="index">`` child, so
  10. the selector silently fell back to plate 1 regardless.
  11. The fixture below mirrors the reporter's real file: plate 1 holds four copies
  12. of ``stand_pillow_01.stl`` (identify_ids 2040/2062/2084/2106), plate 2 holds
  13. one (2168).
  14. """
  15. import json
  16. import logging
  17. import zipfile
  18. from io import BytesIO
  19. from types import SimpleNamespace
  20. import pytest
  21. from backend.app import main as main_module
  22. from backend.app.services.archive import (
  23. extract_printable_objects_from_3mf,
  24. peek_plate_index_in_3mf,
  25. )
  26. PLATE_1_IDS = [2040, 2062, 2084, 2106]
  27. PLATE_2_ID = 2168
  28. def _plate_xml(index: int, ids: list[int]) -> str:
  29. objects = "".join(f'<object identify_id="{i}" name="stand_pillow_01.stl" skipped="false" />' for i in ids)
  30. return f'<plate><metadata key="index" value="{index}"/><metadata key="weight" value="10"/>{objects}</plate>'
  31. def _plate_json(boxes: list[list[float]]) -> str:
  32. """A plate_N.json with one bbox_objects entry per box, plus their union."""
  33. return json.dumps(
  34. {
  35. "bbox_all": [
  36. min(b[0] for b in boxes),
  37. min(b[1] for b in boxes),
  38. max(b[2] for b in boxes),
  39. max(b[3] for b in boxes),
  40. ],
  41. "bbox_objects": [
  42. # The ids here are the slicer's own bbox ids, which do NOT equal
  43. # identify_id in real files — matching is by name, as before.
  44. {"id": 9000 + n, "name": "stand_pillow_01.stl", "bbox": box}
  45. for n, box in enumerate(boxes)
  46. ],
  47. }
  48. )
  49. # Four copies in a square (plate 1) vs. a single copy elsewhere (plate 2).
  50. PLATE_1_BOXES = [[0, 0, 10, 10], [90, 0, 100, 10], [0, 90, 10, 100], [90, 90, 100, 100]]
  51. PLATE_2_BOXES = [[40, 40, 60, 60]]
  52. def _multi_plate_3mf() -> bytes:
  53. buf = BytesIO()
  54. with zipfile.ZipFile(buf, "w") as zf:
  55. zf.writestr(
  56. "Metadata/slice_info.config",
  57. f"<config><header/>{_plate_xml(1, PLATE_1_IDS)}{_plate_xml(2, [PLATE_2_ID])}</config>",
  58. )
  59. zf.writestr("Metadata/plate_1.json", _plate_json(PLATE_1_BOXES))
  60. zf.writestr("Metadata/plate_2.json", _plate_json(PLATE_2_BOXES))
  61. return buf.getvalue()
  62. def _single_plate_3mf(index: int) -> bytes:
  63. """A per-plate export: one <plate>, but its index is the original plate number."""
  64. buf = BytesIO()
  65. with zipfile.ZipFile(buf, "w") as zf:
  66. zf.writestr(
  67. "Metadata/slice_info.config",
  68. f"<config><header/>{_plate_xml(index, [PLATE_2_ID])}</config>",
  69. )
  70. zf.writestr(f"Metadata/plate_{index}.json", _plate_json(PLATE_2_BOXES))
  71. return buf.getvalue()
  72. class TestExtractPrintableObjectsPlateScope:
  73. def test_returns_only_the_requested_plates_objects(self):
  74. objects, bbox_all = extract_printable_objects_from_3mf(
  75. _multi_plate_3mf(), plate_number=2, include_positions=True
  76. )
  77. assert list(objects) == [PLATE_2_ID]
  78. # Positions come from plate_2.json, not plate_1.json.
  79. assert objects[PLATE_2_ID]["x"] == 50
  80. assert objects[PLATE_2_ID]["y"] == 50
  81. assert bbox_all == [40, 40, 60, 60]
  82. def test_other_plate_of_the_same_file_resolves_independently(self):
  83. objects, bbox_all = extract_printable_objects_from_3mf(
  84. _multi_plate_3mf(), plate_number=1, include_positions=True
  85. )
  86. assert sorted(objects) == PLATE_1_IDS
  87. assert bbox_all == [0, 0, 100, 100]
  88. def test_no_plate_given_falls_back_to_the_first(self):
  89. objects = extract_printable_objects_from_3mf(_multi_plate_3mf())
  90. assert sorted(objects) == PLATE_1_IDS
  91. def test_unknown_plate_falls_back_without_mixing_plates(self):
  92. # Plate 9 doesn't exist. We fall back to the first plate — and must read
  93. # ITS plate_1.json, not a plate_9.json that isn't there. Getting this
  94. # wrong would return plate 1's objects with no positions at all.
  95. objects, bbox_all = extract_printable_objects_from_3mf(
  96. _multi_plate_3mf(), plate_number=9, include_positions=True
  97. )
  98. assert sorted(objects) == PLATE_1_IDS
  99. assert bbox_all == [0, 0, 100, 100]
  100. assert all(o["x"] is not None for o in objects.values())
  101. def test_single_plate_export_keeps_its_own_index(self):
  102. # Bambu Studio's "current plate" export carries one <plate> whose index
  103. # is still the original plate number, and its positions live in
  104. # plate_3.json. Asking for plate 3 must match it.
  105. objects, bbox_all = extract_printable_objects_from_3mf(
  106. _single_plate_3mf(3), plate_number=3, include_positions=True
  107. )
  108. assert list(objects) == [PLATE_2_ID]
  109. assert bbox_all == [40, 40, 60, 60]
  110. class TestPeekPlateIndexMultiPlate:
  111. def test_multi_plate_file_has_no_single_plate_index(self, tmp_path):
  112. # The #1204 guard compares this against the plate parsed from gcode_file
  113. # and throws the 3MF away on mismatch. An all-plates upload has no one
  114. # answer, and reporting plate 1 made the guard discard a correct file.
  115. path = tmp_path / "all-plates.3mf"
  116. path.write_bytes(_multi_plate_3mf())
  117. assert peek_plate_index_in_3mf(path) is None
  118. def test_single_plate_file_still_reports_its_index(self, tmp_path):
  119. path = tmp_path / "one-plate.3mf"
  120. path.write_bytes(_single_plate_3mf(2))
  121. assert peek_plate_index_in_3mf(path) == 2
  122. class TestLoadObjectsFromArchiveWiring:
  123. """The extractor took a plate_number all along — no caller ever passed one."""
  124. @pytest.fixture
  125. def archive_3mf(self, tmp_path, monkeypatch):
  126. path = tmp_path / "job.3mf"
  127. path.write_bytes(_multi_plate_3mf())
  128. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  129. return SimpleNamespace(file_path="job.3mf")
  130. def _client(self, **state):
  131. fields = {
  132. "printable_objects": {},
  133. "printable_objects_bbox_all": None,
  134. "skipped_objects": [1],
  135. "gcode_file": None,
  136. "subtask_name": None,
  137. "dispatched_plate_id": None,
  138. "dispatched_subtask": None,
  139. }
  140. fields.update(state)
  141. return SimpleNamespace(state=SimpleNamespace(**fields))
  142. def _load(self, monkeypatch, client, archive):
  143. monkeypatch.setattr(main_module.printer_manager, "get_client", lambda pid: client)
  144. main_module._load_objects_from_archive(archive, 1, logging.getLogger(__name__))
  145. def test_uses_the_plate_bambuddy_dispatched(self, monkeypatch, archive_3mf):
  146. # Bambuddy-dispatched print: the plate is known from the dispatch itself,
  147. # which is what the reporter's P1S does (its gcode_file echo carries no
  148. # plate path — #1166).
  149. client = self._client(dispatched_plate_id=2, dispatched_subtask="job", subtask_name="job")
  150. self._load(monkeypatch, client, archive_3mf)
  151. assert list(client.state.printable_objects) == [PLATE_2_ID]
  152. assert client.state.printable_objects_bbox_all == [40, 40, 60, 60]
  153. assert client.state.skipped_objects == []
  154. def test_uses_the_plate_parsed_from_gcode_file(self, monkeypatch, archive_3mf):
  155. # Print started outside Bambuddy: the plate comes from the gcode path.
  156. client = self._client(gcode_file="/data/Metadata/plate_2.gcode")
  157. self._load(monkeypatch, client, archive_3mf)
  158. assert list(client.state.printable_objects) == [PLATE_2_ID]
  159. def test_unknown_plate_still_loads_the_first(self, monkeypatch, archive_3mf):
  160. client = self._client()
  161. self._load(monkeypatch, client, archive_3mf)
  162. assert sorted(client.state.printable_objects) == PLATE_1_IDS