test_stl_thumbnail.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. """Unit tests for the STL thumbnail service."""
  2. import os
  3. import tempfile
  4. from pathlib import Path
  5. from unittest.mock import MagicMock, patch
  6. import pytest
  7. class TestSTLThumbnailService:
  8. """Tests for STL thumbnail generation."""
  9. def test_generate_thumbnail_ascii_stl(self):
  10. """Test generating thumbnail from ASCII STL file."""
  11. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  12. # Create a simple ASCII STL cube
  13. ascii_stl = """solid cube
  14. facet normal 0 0 -1
  15. outer loop
  16. vertex 0 0 0
  17. vertex 1 0 0
  18. vertex 1 1 0
  19. endloop
  20. endfacet
  21. facet normal 0 0 -1
  22. outer loop
  23. vertex 0 0 0
  24. vertex 1 1 0
  25. vertex 0 1 0
  26. endloop
  27. endfacet
  28. facet normal 0 0 1
  29. outer loop
  30. vertex 0 0 1
  31. vertex 1 1 1
  32. vertex 1 0 1
  33. endloop
  34. endfacet
  35. facet normal 0 0 1
  36. outer loop
  37. vertex 0 0 1
  38. vertex 0 1 1
  39. vertex 1 1 1
  40. endloop
  41. endfacet
  42. facet normal 0 -1 0
  43. outer loop
  44. vertex 0 0 0
  45. vertex 1 0 1
  46. vertex 1 0 0
  47. endloop
  48. endfacet
  49. facet normal 0 -1 0
  50. outer loop
  51. vertex 0 0 0
  52. vertex 0 0 1
  53. vertex 1 0 1
  54. endloop
  55. endfacet
  56. facet normal 1 0 0
  57. outer loop
  58. vertex 1 0 0
  59. vertex 1 1 1
  60. vertex 1 1 0
  61. endloop
  62. endfacet
  63. facet normal 1 0 0
  64. outer loop
  65. vertex 1 0 0
  66. vertex 1 0 1
  67. vertex 1 1 1
  68. endloop
  69. endfacet
  70. facet normal 0 1 0
  71. outer loop
  72. vertex 0 1 0
  73. vertex 1 1 0
  74. vertex 1 1 1
  75. endloop
  76. endfacet
  77. facet normal 0 1 0
  78. outer loop
  79. vertex 0 1 0
  80. vertex 1 1 1
  81. vertex 0 1 1
  82. endloop
  83. endfacet
  84. facet normal -1 0 0
  85. outer loop
  86. vertex 0 0 0
  87. vertex 0 1 0
  88. vertex 0 1 1
  89. endloop
  90. endfacet
  91. facet normal -1 0 0
  92. outer loop
  93. vertex 0 0 0
  94. vertex 0 1 1
  95. vertex 0 0 1
  96. endloop
  97. endfacet
  98. endsolid cube
  99. """
  100. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file:
  101. stl_file.write(ascii_stl)
  102. stl_path = stl_file.name
  103. with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
  104. png_path = png_file.name
  105. try:
  106. result = generate_stl_thumbnail(stl_path, png_path)
  107. assert result is True
  108. assert os.path.exists(png_path)
  109. # Check it's a valid PNG (starts with PNG magic bytes)
  110. with open(png_path, "rb") as f:
  111. header = f.read(8)
  112. assert header[:4] == b"\x89PNG"
  113. finally:
  114. os.unlink(stl_path)
  115. if os.path.exists(png_path):
  116. os.unlink(png_path)
  117. def test_generate_thumbnail_binary_stl(self):
  118. """Test generating thumbnail from binary STL file."""
  119. import struct
  120. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  121. # Create a simple binary STL cube (minimal version)
  122. # Binary STL format:
  123. # - 80 bytes header
  124. # - 4 bytes number of triangles (uint32)
  125. # - For each triangle:
  126. # - 12 bytes normal (3 floats)
  127. # - 36 bytes vertices (9 floats, 3 vertices x 3 coords)
  128. # - 2 bytes attribute byte count (usually 0)
  129. header = b"\x00" * 80 # Empty header
  130. num_triangles = 12 # A cube has 12 triangles (2 per face)
  131. # Define cube vertices
  132. vertices = [
  133. # Bottom face (z=0)
  134. ((0, 0, -1), [(0, 0, 0), (1, 0, 0), (1, 1, 0)]),
  135. ((0, 0, -1), [(0, 0, 0), (1, 1, 0), (0, 1, 0)]),
  136. # Top face (z=1)
  137. ((0, 0, 1), [(0, 0, 1), (1, 1, 1), (1, 0, 1)]),
  138. ((0, 0, 1), [(0, 0, 1), (0, 1, 1), (1, 1, 1)]),
  139. # Front face (y=0)
  140. ((0, -1, 0), [(0, 0, 0), (1, 0, 1), (1, 0, 0)]),
  141. ((0, -1, 0), [(0, 0, 0), (0, 0, 1), (1, 0, 1)]),
  142. # Back face (y=1)
  143. ((0, 1, 0), [(0, 1, 0), (1, 1, 0), (1, 1, 1)]),
  144. ((0, 1, 0), [(0, 1, 0), (1, 1, 1), (0, 1, 1)]),
  145. # Left face (x=0)
  146. ((-1, 0, 0), [(0, 0, 0), (0, 1, 0), (0, 1, 1)]),
  147. ((-1, 0, 0), [(0, 0, 0), (0, 1, 1), (0, 0, 1)]),
  148. # Right face (x=1)
  149. ((1, 0, 0), [(1, 0, 0), (1, 1, 1), (1, 1, 0)]),
  150. ((1, 0, 0), [(1, 0, 0), (1, 0, 1), (1, 1, 1)]),
  151. ]
  152. binary_data = header + struct.pack("<I", num_triangles)
  153. for normal, verts in vertices:
  154. binary_data += struct.pack("<fff", *normal) # Normal
  155. for v in verts:
  156. binary_data += struct.pack("<fff", *v) # Vertex
  157. binary_data += struct.pack("<H", 0) # Attribute byte count
  158. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="wb") as stl_file:
  159. stl_file.write(binary_data)
  160. stl_path = stl_file.name
  161. with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
  162. png_path = png_file.name
  163. try:
  164. result = generate_stl_thumbnail(stl_path, png_path)
  165. assert result is True
  166. assert os.path.exists(png_path)
  167. finally:
  168. os.unlink(stl_path)
  169. if os.path.exists(png_path):
  170. os.unlink(png_path)
  171. def test_generate_thumbnail_invalid_file(self):
  172. """Test handling of invalid STL file."""
  173. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  174. # Create invalid STL content
  175. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file:
  176. stl_file.write("This is not valid STL content")
  177. stl_path = stl_file.name
  178. with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
  179. png_path = png_file.name
  180. try:
  181. result = generate_stl_thumbnail(stl_path, png_path)
  182. # Should return False for invalid file
  183. assert result is False
  184. finally:
  185. os.unlink(stl_path)
  186. if os.path.exists(png_path):
  187. os.unlink(png_path)
  188. def test_generate_thumbnail_nonexistent_file(self):
  189. """Test handling of nonexistent file."""
  190. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  191. result = generate_stl_thumbnail("/nonexistent/path/file.stl", "/tmp/output.png")
  192. assert result is False
  193. def test_generate_thumbnail_bytes_ascii(self):
  194. """Test generating thumbnail from ASCII STL bytes."""
  195. from backend.app.services.stl_thumbnail import generate_stl_thumbnail_bytes
  196. # Simple ASCII STL cube (same as above)
  197. ascii_stl = b"""solid cube
  198. facet normal 0 0 -1
  199. outer loop
  200. vertex 0 0 0
  201. vertex 1 0 0
  202. vertex 1 1 0
  203. endloop
  204. endfacet
  205. facet normal 0 0 1
  206. outer loop
  207. vertex 0 0 1
  208. vertex 1 0 1
  209. vertex 1 1 1
  210. endloop
  211. endfacet
  212. endsolid cube
  213. """
  214. result = generate_stl_thumbnail_bytes(ascii_stl)
  215. assert result is not None
  216. # Check it's a valid PNG
  217. assert result[:4] == b"\x89PNG"
  218. def test_generate_thumbnail_bytes_invalid(self):
  219. """Test handling of invalid STL bytes."""
  220. from backend.app.services.stl_thumbnail import generate_stl_thumbnail_bytes
  221. result = generate_stl_thumbnail_bytes(b"not valid stl data")
  222. assert result is None
  223. def test_generate_thumbnail_custom_size(self):
  224. """Test generating thumbnail with custom size."""
  225. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  226. ascii_stl = """solid cube
  227. facet normal 0 0 -1
  228. outer loop
  229. vertex 0 0 0
  230. vertex 1 0 0
  231. vertex 1 1 0
  232. endloop
  233. endfacet
  234. endsolid cube
  235. """
  236. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file:
  237. stl_file.write(ascii_stl)
  238. stl_path = stl_file.name
  239. with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
  240. png_path = png_file.name
  241. try:
  242. result = generate_stl_thumbnail(stl_path, png_path, size=(128, 128))
  243. assert result is True
  244. assert os.path.exists(png_path)
  245. finally:
  246. os.unlink(stl_path)
  247. if os.path.exists(png_path):
  248. os.unlink(png_path)
  249. class TestMeshSimplification:
  250. """Tests for mesh simplification with large files."""
  251. def test_simplification_threshold(self):
  252. """Test that MAX_VERTICES constant is defined."""
  253. from backend.app.services.stl_thumbnail import MAX_VERTICES
  254. assert MAX_VERTICES == 100000
  255. def test_large_mesh_handling(self):
  256. """Test that large meshes are simplified (mocked)."""
  257. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  258. # Create a mock mesh with many vertices
  259. with patch("trimesh.load") as mock_load:
  260. mock_mesh = MagicMock()
  261. mock_mesh.vertices = MagicMock()
  262. mock_mesh.vertices.__len__ = MagicMock(return_value=200000) # Over threshold
  263. mock_mesh.faces = MagicMock()
  264. mock_mesh.faces.__len__ = MagicMock(return_value=400000)
  265. mock_simplified = MagicMock()
  266. mock_simplified.vertices = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
  267. mock_simplified.faces = [[0, 1, 2]]
  268. mock_mesh.simplify_quadric_decimation.return_value = mock_simplified
  269. mock_load.return_value = mock_mesh
  270. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as stl_file:
  271. stl_file.write(b"dummy")
  272. stl_path = stl_file.name
  273. with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
  274. png_path = png_file.name
  275. try:
  276. # This will fail but we can verify simplification was called
  277. generate_stl_thumbnail(stl_path, png_path)
  278. # The mock should have been called for simplification
  279. mock_mesh.simplify_quadric_decimation.assert_called()
  280. finally:
  281. os.unlink(stl_path)
  282. if os.path.exists(png_path):
  283. os.unlink(png_path)