test_plate_thumbnail.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. """Unit tests for the plate-thumbnail injection service.
  2. The service backfills ``Metadata/plate_N.png`` when the sidecar CLI
  3. (BS or Orca) skipped it in --slice --export-3mf. Each test builds a
  4. synthetic sliced-3MF fixture: a trimesh-exported cube as
  5. ``3D/3dmodel.model`` plus dummy ``Metadata/plate_1.gcode`` so the
  6. inject function sees it as "plate 1, no thumbnail."
  7. """
  8. from __future__ import annotations
  9. import io
  10. import zipfile
  11. import pytest
  12. def _trimesh_available() -> bool:
  13. try:
  14. import trimesh # noqa: F401
  15. return True
  16. except ImportError:
  17. return False
  18. def _build_sliced_3mf(
  19. *,
  20. plate_ids: list[int],
  21. with_thumbnails: set[int] | None = None,
  22. with_model: bool = True,
  23. ) -> bytes:
  24. """Build a synthetic sliced .gcode.3mf for injection tests.
  25. - ``plate_ids``: which Metadata/plate_N.gcode entries to write
  26. - ``with_thumbnails``: subset of plate_ids that ALSO get plate_N.png +
  27. plate_N_small.png (simulates a desktop-Studio-style slice where the
  28. slicer did embed thumbnails)
  29. - ``with_model``: when True, embeds a trimesh-rendered cube as
  30. ``3D/3dmodel.model`` so the injector can reload + render it
  31. """
  32. import trimesh
  33. have_thumbs = with_thumbnails or set()
  34. buf = io.BytesIO()
  35. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  36. if with_model:
  37. # trimesh's primitives.Box exports cleanly to 3MF.
  38. mesh = trimesh.creation.box(extents=(10.0, 10.0, 10.0))
  39. model_bytes = mesh.export(file_type="3mf")
  40. # trimesh.export(file_type='3mf') returns a full 3MF zip; we
  41. # want just the embedded 3D/3dmodel.model XML so we can place
  42. # it under the sliced-3MF layout.
  43. with zipfile.ZipFile(io.BytesIO(model_bytes), "r") as inner:
  44. model_xml = inner.read("3D/3dmodel.model")
  45. zf.writestr("3D/3dmodel.model", model_xml)
  46. for n in plate_ids:
  47. # Dummy gcode is enough for the injector — it only matches the
  48. # filename to detect plate slots, not the content.
  49. zf.writestr(f"Metadata/plate_{n}.gcode", b"; dummy gcode\n")
  50. if n in have_thumbs:
  51. # 1x1 transparent PNG — pre-existing thumb sentinel; the
  52. # injector should preserve its bytes verbatim.
  53. zf.writestr(f"Metadata/plate_{n}.png", _PIXEL_PNG)
  54. zf.writestr(f"Metadata/plate_{n}_small.png", _PIXEL_PNG)
  55. return buf.getvalue()
  56. # 1x1 transparent PNG used as a pre-existing thumbnail sentinel.
  57. _PIXEL_PNG = (
  58. b"\x89PNG\r\n\x1a\n"
  59. b"\x00\x00\x00\rIHDR"
  60. b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
  61. b"\x1f\x15\xc4\x89"
  62. b"\x00\x00\x00\x0dIDATx\x9cc\xfc\xff\xff?\x03\x00\x05\xfe\x02\xfe"
  63. b"\xdc\xccY\xe7"
  64. b"\x00\x00\x00\x00IEND\xaeB`\x82"
  65. )
  66. def _names_in_zip(blob: bytes) -> set[str]:
  67. with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
  68. return set(zf.namelist())
  69. @pytest.mark.skipif(not _trimesh_available(), reason="trimesh not installed")
  70. class TestInjectPlateThumbnails:
  71. """Behaviour around when the injector renders vs returns the input."""
  72. def test_returns_input_unchanged_when_all_plates_have_thumbnails(self):
  73. """Desktop-Studio path: every plate already has plate_N.png — no work."""
  74. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  75. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails={1})
  76. result = inject_plate_thumbnails_if_missing(fixture)
  77. # Same object identity — the fast path returns the input verbatim
  78. # so the SliceResult._replace upstream never pays for a copy on the
  79. # common already-embedded case.
  80. assert result is fixture
  81. def test_injects_both_sizes_when_thumbnail_missing(self):
  82. """BS/Orca sidecar path: plate_1.gcode present, plate_1.png absent."""
  83. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  84. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  85. before = _names_in_zip(fixture)
  86. assert "Metadata/plate_1.png" not in before
  87. result = inject_plate_thumbnails_if_missing(fixture)
  88. after = _names_in_zip(result)
  89. assert "Metadata/plate_1.png" in after
  90. assert "Metadata/plate_1_small.png" in after
  91. def test_injected_pngs_have_expected_dimensions(self):
  92. """Sanity-check the render geometry — 512x512 + 128x128, RGBA PNG."""
  93. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  94. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  95. result = inject_plate_thumbnails_if_missing(fixture)
  96. with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
  97. large = zf.read("Metadata/plate_1.png")
  98. small = zf.read("Metadata/plate_1_small.png")
  99. assert large.startswith(b"\x89PNG\r\n\x1a\n")
  100. assert small.startswith(b"\x89PNG\r\n\x1a\n")
  101. # PNG IHDR dimensions live at byte offsets 16..23 (big-endian width,
  102. # then big-endian height). matplotlib's bbox_inches='tight' shaves a
  103. # few pixels off, so assert "close to" rather than exact.
  104. import struct
  105. large_w, large_h = struct.unpack(">II", large[16:24])
  106. small_w, small_h = struct.unpack(">II", small[16:24])
  107. assert 480 <= large_w <= 540 and 480 <= large_h <= 540
  108. assert 100 <= small_w <= 140 and 100 <= small_h <= 140
  109. def test_injected_thumbnail_is_shaded_not_flat(self, distinct_surface_tones):
  110. """Injected plate renders must be lit, same as library thumbnails (#2816).
  111. The archive card and the File Manager tile show the same model through
  112. two different renderers; if only one of them is lit they disagree.
  113. """
  114. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  115. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  116. result = inject_plate_thumbnails_if_missing(fixture)
  117. with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
  118. large = zf.read("Metadata/plate_1.png")
  119. # _build_sliced_3mf embeds a cube: three faces visible, three tones.
  120. assert distinct_surface_tones(large) >= 3
  121. def test_injects_for_every_missing_plate_in_multi_plate_3mf(self):
  122. """Three plates, plate_2 already has a thumbnail; only plates 1 + 3 get rendered."""
  123. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  124. fixture = _build_sliced_3mf(plate_ids=[1, 2, 3], with_thumbnails={2})
  125. result = inject_plate_thumbnails_if_missing(fixture)
  126. after = _names_in_zip(result)
  127. for n in (1, 2, 3):
  128. assert f"Metadata/plate_{n}.png" in after
  129. assert f"Metadata/plate_{n}_small.png" in after
  130. # Plate 2 had a pre-existing thumbnail — the inject must NOT clobber
  131. # it. The sentinel _PIXEL_PNG bytes should survive verbatim.
  132. with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
  133. assert zf.read("Metadata/plate_2.png") == _PIXEL_PNG
  134. assert zf.read("Metadata/plate_2_small.png") == _PIXEL_PNG
  135. def test_returns_input_when_no_model_file_in_3mf(self):
  136. """No 3D/3dmodel.model → render is impossible; degrade gracefully."""
  137. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  138. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set(), with_model=False)
  139. result = inject_plate_thumbnails_if_missing(fixture)
  140. # Same object identity — early-out before render.
  141. assert result is fixture
  142. def test_returns_input_when_not_a_zip(self):
  143. """Non-zip input must not crash — degrade to passthrough."""
  144. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  145. garbage = b"not a zip"
  146. assert inject_plate_thumbnails_if_missing(garbage) is garbage
  147. def test_idempotent_on_second_pass(self):
  148. """Re-running on a previously-injected 3MF must be a no-op."""
  149. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  150. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  151. once = inject_plate_thumbnails_if_missing(fixture)
  152. twice = inject_plate_thumbnails_if_missing(once)
  153. # Same object identity — second pass hits the no-op fast path
  154. # because every plate now has its plate_N.png.
  155. assert twice is once
  156. # --- Bambu Studio / OrcaSlicer layout (#3135) ------------------------------
  157. #
  158. # Those slicers keep every mesh in ``3D/Objects/*.model`` and write each
  159. # instance in ``3D/3dmodel.model`` as its own ``<object>`` holding one
  160. # ``<component p:path=...>``. trimesh's reader re-parsed the referenced file for
  161. # every such component and appended its meshes again each time, so N copies of
  162. # a part came back with N² copies of its triangles — 25 bins took 8.4 GB to
  163. # render and OOM-killed the server.
  164. _NS = (
  165. 'xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" '
  166. 'xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/1015/06" requiredextensions="p"'
  167. )
  168. _IDENTITY = "1 0 0 0 1 0 0 0 1 0 0 0"
  169. def _box_mesh_xml(size: float = 10.0) -> tuple[str, int]:
  170. import trimesh
  171. box = trimesh.creation.box(extents=(size, size, size))
  172. verts = "".join(f'<vertex x="{x}" y="{y}" z="{z}"/>' for x, y, z in box.vertices)
  173. tris = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in box.faces)
  174. return f"<mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh>", len(box.faces)
  175. def _bambu_layout_3mf(
  176. placements: list[tuple[str, str]],
  177. parts: dict[str, float] | None = None,
  178. extra_root_objects: str = "",
  179. extra_items: str = "",
  180. mesh_xml: str | None = None,
  181. ) -> bytes:
  182. """A sliced 3MF in the Bambu/Orca layout.
  183. ``parts``: object id -> box size, all in ``3D/Objects/object_1.model``.
  184. ``placements``: (part id, build-item transform), one wrapper object each.
  185. ``mesh_xml``: a ``<mesh>`` to use for every part instead of a box.
  186. """
  187. parts = parts or {"1": 10.0}
  188. objects = "".join(
  189. f'<object id="{oid}" type="model">{mesh_xml or _box_mesh_xml(size)[0]}</object>' for oid, size in parts.items()
  190. )
  191. part_file = f'<?xml version="1.0" encoding="UTF-8"?><model unit="millimeter" {_NS}><resources>{objects}</resources><build/></model>'
  192. wrappers = "".join(
  193. f'<object id="{100 + i}" type="model"><components>'
  194. f'<component p:path="/3D/Objects/object_1.model" objectid="{part}" transform="{_IDENTITY}"/>'
  195. f"</components></object>"
  196. for i, (part, _t) in enumerate(placements)
  197. )
  198. items = "".join(f'<item objectid="{100 + i}" transform="{t}"/>' for i, (_p, t) in enumerate(placements))
  199. root = (
  200. f'<?xml version="1.0" encoding="UTF-8"?><model unit="millimeter" {_NS}>'
  201. f"<resources>{wrappers}{extra_root_objects}</resources><build>{items}{extra_items}</build></model>"
  202. )
  203. buf = io.BytesIO()
  204. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  205. zf.writestr("3D/Objects/object_1.model", part_file)
  206. zf.writestr("3D/3dmodel.model", root)
  207. zf.writestr("Metadata/plate_1.gcode", b"; dummy gcode\n")
  208. return buf.getvalue()
  209. def _grid(n: int) -> list[str]:
  210. return [f"1 0 0 0 1 0 0 0 1 {20 * (i % 5)} {20 * (i // 5)} 5" for i in range(n)]
  211. def _geometry(blob: bytes):
  212. import trimesh
  213. from backend.app.services.plate_thumbnail import _load_plate_geometry
  214. with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
  215. return _load_plate_geometry(zf, trimesh, lambda *_a: None)
  216. @pytest.mark.skipif(not _trimesh_available(), reason="trimesh not installed")
  217. class TestBambuLayoutGeometry:
  218. def test_each_instance_is_placed_once_not_n_squared(self):
  219. _, box_faces = _box_mesh_xml()
  220. blob = _bambu_layout_3mf([("1", t) for t in _grid(25)])
  221. vertices, faces = _geometry(blob)
  222. # 25 boxes of 12 faces. trimesh returned 25 * 25 * 12 = 7500.
  223. assert len(faces) == 25 * box_faces
  224. # Laid out on the grid, not stacked: 5 columns 20 mm apart plus a 10 mm box.
  225. extent = vertices.max(axis=0) - vertices.min(axis=0)
  226. assert extent[0] == pytest.approx(4 * 20 + 10)
  227. assert extent[1] == pytest.approx(4 * 20 + 10)
  228. def test_a_component_places_only_the_object_it_names(self):
  229. # One object file holding three parts. trimesh appended all three to
  230. # every part it referenced, so each placement drew the whole file.
  231. _, box_faces = _box_mesh_xml()
  232. blob = _bambu_layout_3mf(
  233. [("1", _grid(1)[0]), ("3", "1 0 0 0 1 0 0 0 1 50 0 5")],
  234. parts={"1": 10.0, "2": 40.0, "3": 10.0},
  235. )
  236. vertices, faces = _geometry(blob)
  237. assert len(faces) == 2 * box_faces
  238. # Part 2 (40 mm) is never placed, so nothing is that tall.
  239. assert (vertices.max(axis=0) - vertices.min(axis=0))[2] == pytest.approx(10)
  240. def test_a_mirrored_instance_keeps_its_faces_pointing_out(self):
  241. import numpy as np
  242. import trimesh
  243. blob = _bambu_layout_3mf([("1", "-1 0 0 0 1 0 0 0 1 0 0 5")])
  244. vertices, faces = _geometry(blob)
  245. mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
  246. outward = mesh.triangles.mean(axis=1) - mesh.vertices.mean(axis=0)
  247. assert (np.einsum("ij,ij->i", mesh.face_normals, outward) > 0).all()
  248. def test_a_self_referencing_component_terminates(self):
  249. # Object 500 places the box and then itself; the walk must stop at the
  250. # loop rather than recurse, and still draw the box it reached once.
  251. loop = (
  252. '<object id="500" type="model"><components>'
  253. f'<component p:path="/3D/Objects/object_1.model" objectid="1" transform="{_IDENTITY}"/>'
  254. f'<component objectid="500" transform="1 0 0 0 1 0 0 0 1 30 0 0"/></components></object>'
  255. )
  256. blob = _bambu_layout_3mf(
  257. [],
  258. extra_root_objects=loop,
  259. extra_items=f'<item objectid="500" transform="{_IDENTITY}"/>',
  260. )
  261. _vertices, faces = _geometry(blob)
  262. assert len(faces) == _box_mesh_xml()[1]
  263. def test_a_build_item_can_name_the_file_its_object_lives_in(self):
  264. # Production extension: ``p:path`` on the item itself, no wrapper object.
  265. blob = _bambu_layout_3mf(
  266. [],
  267. extra_items=f'<item p:path="/3D/Objects/object_1.model" objectid="1" transform="{_IDENTITY}"/>',
  268. )
  269. _vertices, faces = _geometry(blob)
  270. assert len(faces) == _box_mesh_xml()[1]
  271. def test_a_component_pointing_at_a_missing_file_is_skipped(self):
  272. blob = _bambu_layout_3mf([("1", _grid(1)[0])])
  273. broken = io.BytesIO()
  274. with zipfile.ZipFile(io.BytesIO(blob)) as src, zipfile.ZipFile(broken, "w") as dst:
  275. for item in src.infolist():
  276. if item.filename != "3D/Objects/object_1.model":
  277. dst.writestr(item, src.read(item.filename))
  278. assert _geometry(broken.getvalue()) is None
  279. def test_many_instances_are_decimated_to_the_face_budget(self, monkeypatch):
  280. import trimesh
  281. import backend.app.services.plate_thumbnail as pt
  282. # 25 spheres of 5120 faces against a budget of 1000 per copy.
  283. sphere = trimesh.creation.icosphere(subdivisions=4)
  284. verts = "".join(f'<vertex x="{x}" y="{y}" z="{z}"/>' for x, y, z in sphere.vertices)
  285. tris = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in sphere.faces)
  286. blob = _bambu_layout_3mf(
  287. [("1", t) for t in _grid(25)],
  288. mesh_xml=f"<mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh>",
  289. )
  290. monkeypatch.setattr(pt, "_RENDER_FACE_BUDGET", 25 * 1000)
  291. _vertices, faces = _geometry(blob)
  292. # Decimated once, to its share of the budget, then placed 25 times.
  293. assert len(faces) <= 25 * 1100
  294. assert len(faces) % 25 == 0
  295. def test_a_decimation_that_fails_is_still_held_to_the_ceiling(self, monkeypatch):
  296. import trimesh
  297. import backend.app.services.plate_thumbnail as pt
  298. # The budget would bring 25 spheres to 25k faces, well under the ceiling,
  299. # so the up-front check passes. Decimation then fails and leaves each
  300. # sphere whole: 128k faces, which the render must not be handed.
  301. sphere = trimesh.creation.icosphere(subdivisions=4)
  302. verts = "".join(f'<vertex x="{x}" y="{y}" z="{z}"/>' for x, y, z in sphere.vertices)
  303. tris = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in sphere.faces)
  304. blob = _bambu_layout_3mf(
  305. [("1", t) for t in _grid(25)],
  306. mesh_xml=f"<mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh>",
  307. )
  308. monkeypatch.setattr(pt, "_RENDER_FACE_BUDGET", 25 * 1000)
  309. monkeypatch.setattr(pt, "_MAX_PLACED_FACES", 50_000)
  310. def fail(*_a, **_k):
  311. raise RuntimeError("decimation failed")
  312. monkeypatch.setattr(trimesh.Trimesh, "simplify_quadric_decimation", fail)
  313. assert _geometry(blob) is None
  314. def test_over_the_ceiling_skips_the_thumbnail_and_keeps_the_3mf(self, monkeypatch):
  315. import backend.app.services.plate_thumbnail as pt
  316. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  317. # 25 boxes can't go below 12 faces each, so a ceiling under 300 is
  318. # unreachable however hard the budget decimates.
  319. monkeypatch.setattr(pt, "_MIN_FACES_PER_MESH", 12)
  320. monkeypatch.setattr(pt, "_MAX_PLACED_FACES", 100)
  321. blob = _bambu_layout_3mf([("1", t) for t in _grid(25)])
  322. assert inject_plate_thumbnails_if_missing(blob) is blob
  323. def test_injects_thumbnails_for_a_bambu_layout_plate(self):
  324. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  325. blob = _bambu_layout_3mf([("1", t) for t in _grid(25)])
  326. out = inject_plate_thumbnails_if_missing(blob)
  327. assert {"Metadata/plate_1.png", "Metadata/plate_1_small.png"} <= _names_in_zip(out)