test_mesh_combine.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Unit tests for combining STLs into one multi-object 3MF."""
  2. import io
  3. import re
  4. import zipfile
  5. import pytest
  6. import trimesh
  7. from backend.app.services import mesh_combine, stl_thumbnail
  8. from backend.app.services.mesh_combine import (
  9. LAYOUT_GAP_MM,
  10. MAX_COMBINE_INSTANCES,
  11. THUMBNAIL_FACE_BUDGET,
  12. THUMBNAIL_PATH,
  13. CombinePart,
  14. MeshCombineError,
  15. combine_parts_to_3mf,
  16. layout_offsets,
  17. thumbnail_faces_per_copy,
  18. )
  19. @pytest.fixture
  20. def box_stl(tmp_path):
  21. path = tmp_path / "box.stl"
  22. path.write_bytes(trimesh.creation.box((30, 20, 10)).export(file_type="stl"))
  23. return path
  24. @pytest.fixture
  25. def cyl_stl(tmp_path):
  26. path = tmp_path / "cyl.stl"
  27. path.write_bytes(trimesh.creation.cylinder(radius=12, height=25).export(file_type="stl"))
  28. return path
  29. def _model_xml(data: bytes) -> str:
  30. with zipfile.ZipFile(io.BytesIO(data)) as zf:
  31. assert {"[Content_Types].xml", "_rels/.rels", "3D/3dmodel.model"} <= set(zf.namelist())
  32. return zf.read("3D/3dmodel.model").decode("utf-8")
  33. def test_one_object_per_source_one_item_per_copy(box_stl, cyl_stl):
  34. data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 2), CombinePart("cyl.stl", cyl_stl, 3)])
  35. xml = _model_xml(data)
  36. # Geometry is stored once per source; copies are build items.
  37. assert re.findall(r'<object id="(\d+)" name="([^"]+)"', xml) == [("1", "box"), ("2", "cyl")]
  38. assert re.findall(r'<item objectid="(\d+)"', xml) == ["1", "1", "2", "2", "2"]
  39. def test_output_reloads_with_every_copy_and_sits_on_the_bed(box_stl, cyl_stl):
  40. data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 2), CombinePart("cyl.stl", cyl_stl, 1)])
  41. scene = trimesh.load(io.BytesIO(data), file_type="3mf")
  42. meshes = scene.dump()
  43. assert len(meshes) == 3
  44. for mesh in meshes:
  45. assert mesh.bounds[0][2] == pytest.approx(0.0, abs=1e-6)
  46. # Pre-placed copies must not overlap each other.
  47. boxes = [m.bounds for m in meshes]
  48. for i, a in enumerate(boxes):
  49. for b in boxes[i + 1 :]:
  50. separated = a[1][0] <= b[0][0] or b[1][0] <= a[0][0] or a[1][1] <= b[0][1] or b[1][1] <= a[0][1]
  51. assert separated
  52. def test_layout_is_centred_and_gapped():
  53. offsets = layout_offsets([(10.0, 10.0), (10.0, 10.0)])
  54. (ax, ay), (bx, by) = offsets
  55. assert abs(bx - ax) == pytest.approx(10.0 + LAYOUT_GAP_MM) or abs(by - ay) == pytest.approx(10.0 + LAYOUT_GAP_MM)
  56. xs = [x for x, _ in offsets] + [x + 10.0 for x, _ in offsets]
  57. ys = [y for _, y in offsets] + [y + 10.0 for _, y in offsets]
  58. assert (min(xs) + max(xs)) / 2 == pytest.approx(0.0)
  59. assert (min(ys) + max(ys)) / 2 == pytest.approx(0.0)
  60. def test_layout_empty():
  61. assert layout_offsets([]) == []
  62. def test_object_names_are_xml_escaped(box_stl):
  63. # The name is the library filename, not the on-disk path.
  64. xml = _model_xml(combine_parts_to_3mf([CombinePart('a&b "q".stl', box_stl, 1)]))
  65. assert 'name="a&amp;b &quot;q&quot;"' in xml
  66. def test_rejects_no_parts():
  67. with pytest.raises(MeshCombineError):
  68. combine_parts_to_3mf([])
  69. def test_rejects_too_many_instances(box_stl):
  70. with pytest.raises(MeshCombineError, match="Too many objects"):
  71. combine_parts_to_3mf([CombinePart("box.stl", box_stl, MAX_COMBINE_INSTANCES + 1)])
  72. def test_rejects_zero_copies(box_stl):
  73. with pytest.raises(MeshCombineError):
  74. combine_parts_to_3mf([CombinePart("box.stl", box_stl, 0)])
  75. def test_rejects_empty_mesh(tmp_path):
  76. empty = tmp_path / "empty.stl"
  77. empty.write_bytes(b"solid empty\nendsolid empty\n")
  78. with pytest.raises(MeshCombineError, match="empty.stl"):
  79. combine_parts_to_3mf([CombinePart("empty.stl", empty, 1)])
  80. # --- embedded thumbnail --------------------------------------------------
  81. def test_thumbnail_is_embedded_and_found_by_the_library_parser(tmp_path, box_stl, cyl_stl):
  82. from backend.app.services.archive import ThreeMFParser
  83. data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 2), CombinePart("cyl.stl", cyl_stl, 1)])
  84. with zipfile.ZipFile(io.BytesIO(data)) as zf:
  85. png = zf.read(THUMBNAIL_PATH)
  86. rels = zf.read("_rels/.rels").decode()
  87. types = zf.read("[Content_Types].xml").decode()
  88. assert png.startswith(b"\x89PNG")
  89. assert f'Target="/{THUMBNAIL_PATH}"' in rels and "relationships/metadata/thumbnail" in rels
  90. assert 'Extension="png"' in types
  91. out = tmp_path / "combined.3mf"
  92. out.write_bytes(data)
  93. assert ThreeMFParser(out).parse().get("_thumbnail_data") == png
  94. def test_preview_stays_within_the_face_budget_however_many_copies(tmp_path, monkeypatch):
  95. # 20,480 faces per source: 100 copies would be 2M faces unsimplified.
  96. ball = tmp_path / "ball.stl"
  97. ball.write_bytes(trimesh.creation.icosphere(subdivisions=5).export(file_type="stl"))
  98. drawn = []
  99. monkeypatch.setattr(stl_thumbnail, "render_mesh_png", lambda mesh, **_: drawn.append(len(mesh.faces)) or b"png")
  100. combine_parts_to_3mf([CombinePart("ball.stl", ball, 100)])
  101. assert drawn and drawn[0] <= THUMBNAIL_FACE_BUDGET
  102. assert thumbnail_faces_per_copy(100) * 100 <= THUMBNAIL_FACE_BUDGET
  103. def test_preview_does_not_reload_the_combined_file(tmp_path, box_stl, monkeypatch):
  104. # The expensive path was loading the finished 3MF back, which expands
  105. # every build item. Only the source STL may be loaded.
  106. loaded = []
  107. real_load = trimesh.load
  108. monkeypatch.setattr(trimesh, "load", lambda path, **kw: loaded.append(str(path)) or real_load(path, **kw))
  109. combine_parts_to_3mf([CombinePart("box.stl", box_stl, 5)])
  110. assert loaded == [str(box_stl)]
  111. def test_a_failed_preview_does_not_fail_the_combine(box_stl, monkeypatch):
  112. def boom(*_, **__):
  113. raise RuntimeError("renderer unavailable")
  114. monkeypatch.setattr(stl_thumbnail, "render_mesh_png", boom)
  115. data = combine_parts_to_3mf([CombinePart("box.stl", box_stl, 1)])
  116. with zipfile.ZipFile(io.BytesIO(data)) as zf:
  117. assert THUMBNAIL_PATH not in zf.namelist()
  118. assert "thumbnail" not in zf.read("_rels/.rels").decode()
  119. assert "<item " in _model_xml(data)
  120. # --- source caps ---------------------------------------------------------
  121. def test_rejects_sources_over_the_byte_cap_before_loading(box_stl, cyl_stl, monkeypatch):
  122. monkeypatch.setattr(mesh_combine, "MAX_COMBINE_SOURCE_BYTES", box_stl.stat().st_size)
  123. monkeypatch.setattr(mesh_combine, "_load_part", lambda part: pytest.fail("loaded despite the byte cap"))
  124. with pytest.raises(MeshCombineError, match="too large"):
  125. combine_parts_to_3mf([CombinePart("box.stl", box_stl, 1), CombinePart("cyl.stl", cyl_stl, 1)])
  126. def test_rejects_sources_over_the_face_cap(box_stl, cyl_stl, monkeypatch):
  127. monkeypatch.setattr(mesh_combine, "MAX_COMBINE_SOURCE_FACES", 20)
  128. with pytest.raises(MeshCombineError, match="too detailed"):
  129. combine_parts_to_3mf([CombinePart("box.stl", box_stl, 1), CombinePart("cyl.stl", cyl_stl, 1)])
  130. def test_copies_do_not_count_against_the_face_cap(box_stl, monkeypatch):
  131. # A box has 12 faces; the mesh is stored once however many copies there are.
  132. monkeypatch.setattr(mesh_combine, "MAX_COMBINE_SOURCE_FACES", 12)
  133. combine_parts_to_3mf([CombinePart("box.stl", box_stl, 50)])
  134. # --- streamed model XML --------------------------------------------------
  135. def test_streamed_model_spans_chunks_intact(tmp_path, monkeypatch):
  136. monkeypatch.setattr(mesh_combine, "_XML_CHUNK_ROWS", 7)
  137. ball = tmp_path / "ball.stl"
  138. source = trimesh.creation.icosphere(subdivisions=2)
  139. ball.write_bytes(source.export(file_type="stl"))
  140. data = combine_parts_to_3mf([CombinePart("ball.stl", ball, 2)])
  141. xml = _model_xml(data)
  142. assert xml.count("<triangle ") == len(source.faces)
  143. assert xml.count("<vertex ") == len(trimesh.load(ball, force="mesh").vertices)
  144. reloaded = trimesh.load(io.BytesIO(data), file_type="3mf").dump()
  145. assert [len(m.faces) for m in reloaded] == [len(source.faces)] * 2