test_stl_thumbnail.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. """Unit tests for the STL thumbnail service."""
  2. import os
  3. import tempfile
  4. from pathlib import Path
  5. import pytest
  6. def _check_trimesh_available():
  7. """Check if trimesh is available for import."""
  8. try:
  9. import trimesh
  10. return True
  11. except ImportError:
  12. return False
  13. class TestStlThumbnailService:
  14. """Tests for STL thumbnail generation service."""
  15. def test_generate_stl_thumbnail_imports_available(self):
  16. """Test that required imports are available."""
  17. try:
  18. import matplotlib
  19. import trimesh
  20. assert trimesh is not None
  21. assert matplotlib is not None
  22. except ImportError as e:
  23. pytest.skip(f"Required dependencies not installed: {e}")
  24. def test_generate_stl_thumbnail_returns_none_on_missing_deps(self):
  25. """Test graceful degradation when dependencies are missing."""
  26. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  27. with tempfile.TemporaryDirectory() as tmpdir:
  28. stl_path = Path(tmpdir) / "test.stl"
  29. thumbnails_dir = Path(tmpdir)
  30. # Create a dummy STL file (will fail to parse)
  31. stl_path.write_text("invalid stl content")
  32. # Should return None on failure, not raise
  33. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  34. assert result is None
  35. @pytest.mark.skipif(
  36. not _check_trimesh_available(),
  37. reason="trimesh not installed",
  38. )
  39. def test_generate_stl_thumbnail_with_simple_cube(self):
  40. """Test thumbnail generation with a simple cube STL."""
  41. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  42. with tempfile.TemporaryDirectory() as tmpdir:
  43. stl_path = Path(tmpdir) / "cube.stl"
  44. thumbnails_dir = Path(tmpdir)
  45. # Create a simple ASCII STL cube
  46. stl_content = """solid cube
  47. facet normal 0 0 -1
  48. outer loop
  49. vertex 0 0 0
  50. vertex 1 0 0
  51. vertex 1 1 0
  52. endloop
  53. endfacet
  54. facet normal 0 0 -1
  55. outer loop
  56. vertex 0 0 0
  57. vertex 1 1 0
  58. vertex 0 1 0
  59. endloop
  60. endfacet
  61. facet normal 0 0 1
  62. outer loop
  63. vertex 0 0 1
  64. vertex 1 1 1
  65. vertex 1 0 1
  66. endloop
  67. endfacet
  68. facet normal 0 0 1
  69. outer loop
  70. vertex 0 0 1
  71. vertex 0 1 1
  72. vertex 1 1 1
  73. endloop
  74. endfacet
  75. facet normal 0 -1 0
  76. outer loop
  77. vertex 0 0 0
  78. vertex 1 0 1
  79. vertex 1 0 0
  80. endloop
  81. endfacet
  82. facet normal 0 -1 0
  83. outer loop
  84. vertex 0 0 0
  85. vertex 0 0 1
  86. vertex 1 0 1
  87. endloop
  88. endfacet
  89. facet normal 1 0 0
  90. outer loop
  91. vertex 1 0 0
  92. vertex 1 0 1
  93. vertex 1 1 1
  94. endloop
  95. endfacet
  96. facet normal 1 0 0
  97. outer loop
  98. vertex 1 0 0
  99. vertex 1 1 1
  100. vertex 1 1 0
  101. endloop
  102. endfacet
  103. facet normal 0 1 0
  104. outer loop
  105. vertex 0 1 0
  106. vertex 1 1 0
  107. vertex 1 1 1
  108. endloop
  109. endfacet
  110. facet normal 0 1 0
  111. outer loop
  112. vertex 0 1 0
  113. vertex 1 1 1
  114. vertex 0 1 1
  115. endloop
  116. endfacet
  117. facet normal -1 0 0
  118. outer loop
  119. vertex 0 0 0
  120. vertex 0 1 0
  121. vertex 0 1 1
  122. endloop
  123. endfacet
  124. facet normal -1 0 0
  125. outer loop
  126. vertex 0 0 0
  127. vertex 0 1 1
  128. vertex 0 0 1
  129. endloop
  130. endfacet
  131. endsolid cube"""
  132. stl_path.write_text(stl_content)
  133. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  134. # Should return a path to the generated thumbnail
  135. if result:
  136. assert Path(result).exists()
  137. assert Path(result).suffix == ".png"
  138. # If result is None, dependencies might not be fully functional
  139. # which is acceptable
  140. @pytest.mark.skipif(
  141. not _check_trimesh_available(),
  142. reason="trimesh not installed",
  143. )
  144. def test_generated_thumbnail_is_shaded_not_flat(self, distinct_surface_tones):
  145. """The render must be lit, not a flat silhouette (issue #2816).
  146. Without ``shade=True`` every triangle is filled with BAMBU_GREEN
  147. regardless of its normal, so any model renders as its own outline and
  148. one file is indistinguishable from another in the File Manager.
  149. """
  150. import trimesh
  151. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  152. with tempfile.TemporaryDirectory() as tmpdir:
  153. stl_path = Path(tmpdir) / "cube.stl"
  154. trimesh.creation.box(extents=(10.0, 10.0, 10.0)).export(str(stl_path))
  155. result = generate_stl_thumbnail(stl_path, Path(tmpdir))
  156. assert result is not None
  157. # Three faces of a cube face the camera at the default isometric
  158. # view_init, and with the light on the camera's side each catches it
  159. # differently. This held at 3 before only because ``alpha=0.9`` let a
  160. # back face bleed through two IDENTICALLY lit front faces — re-render
  161. # at alpha=1.0 then and the count fell to 2. It is shading now.
  162. assert distinct_surface_tones(Path(result).read_bytes()) >= 3
  163. @pytest.mark.skipif(
  164. not _check_trimesh_available(),
  165. reason="trimesh not installed",
  166. )
  167. @pytest.mark.parametrize(
  168. ("label", "punch_holes"),
  169. [("watertight", False), ("open", True)],
  170. )
  171. def test_backwards_wound_triangles_render_the_same(self, label, punch_holes):
  172. """Vertex ORDER must not change the picture.
  173. matplotlib takes its normals from winding, so an inverted triangle shades
  174. as though it faced away — the model comes out patchy, like camouflage.
  175. Unshaded this was invisible, which makes it a regression the shading
  176. introduced rather than one it revealed, and the File Manager accepts
  177. whatever STL a user uploads.
  178. Asserted as "same picture as the correctly wound mesh", because the
  179. obvious assertion does not work: broken winding produces MORE distinct
  180. tones, not fewer, so a tone count cannot see it.
  181. Run BOTH watertight and open, because the two are repaired by different
  182. code. ``fix_inversion`` decides which way is out from the sign of the
  183. volume and gives up when the mesh is not watertight, which is the common
  184. shape of a broken STL — there, ``fix_winding`` settles inward unopposed
  185. and the centroid fallback in ``_repair_winding`` is the only thing
  186. holding this. Without it the open case renders at a mean delta of 4.56.
  187. """
  188. import numpy as np
  189. import trimesh
  190. from PIL import Image
  191. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  192. sphere = trimesh.creation.icosphere(subdivisions=3, radius=5.0)
  193. keep = sphere.faces.copy()[:-80] if punch_holes else sphere.faces.copy()
  194. good = trimesh.Trimesh(vertices=sphere.vertices.copy(), faces=keep.copy())
  195. assert good.is_watertight is not punch_holes, "fixture has the wrong topology"
  196. faces = keep.copy()
  197. faces[::2] = faces[::2][:, ::-1]
  198. bad = trimesh.Trimesh(vertices=sphere.vertices.copy(), faces=faces)
  199. assert not bad.is_winding_consistent, "fixture is supposed to be broken"
  200. with tempfile.TemporaryDirectory() as tmpdir:
  201. out = Path(tmpdir)
  202. rendered = []
  203. for name, mesh in (("good", good), ("bad", bad)):
  204. path = out / f"{name}.stl"
  205. mesh.export(str(path))
  206. result = generate_stl_thumbnail(path, out)
  207. assert result is not None
  208. rendered.append(np.asarray(Image.open(result).convert("RGB"), dtype=float))
  209. assert rendered[0].shape == rendered[1].shape
  210. mean_delta = float(np.abs(rendered[0] - rendered[1]).mean())
  211. # Repaired they are the same mesh, so this is ~0. Without the repair the
  212. # inverted half renders dark against the lit half and it is an order of
  213. # magnitude higher.
  214. assert mean_delta < 1.0, f"winding changed the {label} render (mean delta {mean_delta:.2f})"
  215. @pytest.mark.skipif(
  216. not _check_trimesh_available(),
  217. reason="trimesh not installed",
  218. )
  219. def test_degenerate_mesh_still_renders(self):
  220. """A mesh with no shadeable face must render flat, not fail.
  221. matplotlib's ``_shade_colors`` falls back to returning the colour it was
  222. given when every normal is degenerate, and for a colour STRING that is a
  223. 0-d array — ``to_rgba_array`` then raises ``TypeError: len() of unsized
  224. object``. So these files rendered fine while the output was flat, and
  225. turning the light on would have broken them.
  226. They are not hypothetical: stub and truncated STLs reach here, and
  227. ``batch_generate_stl_thumbnails`` walks a whole folder with no
  228. minimum-size pre-skip, so each one would show as a failure in the UI.
  229. """
  230. import struct
  231. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  232. def write_binary_stl(path, triangles):
  233. # Written by hand rather than via trimesh.export, which drops
  234. # degenerate facets and would quietly defeat the test.
  235. with open(path, "wb") as fh:
  236. fh.write(b"\0" * 80)
  237. fh.write(struct.pack("<I", len(triangles)))
  238. for tri in triangles:
  239. fh.write(struct.pack("<3f", 0.0, 0.0, 0.0))
  240. for vertex in tri:
  241. fh.write(struct.pack("<3f", *vertex))
  242. fh.write(b"\0\0")
  243. cases = {
  244. "zero_area": [[(0, 0, 0), (0, 0, 0), (0, 0, 0)]],
  245. "collinear": [[(0, 0, 0), (1, 1, 1), (2, 2, 2)]],
  246. }
  247. with tempfile.TemporaryDirectory() as tmpdir:
  248. out = Path(tmpdir)
  249. for name, triangles in cases.items():
  250. path = out / f"{name}.stl"
  251. write_binary_stl(path, triangles)
  252. assert generate_stl_thumbnail(path, out) is not None, f"{name} used to render and must still render"
  253. def test_generate_stl_thumbnail_nonexistent_file(self):
  254. """Test thumbnail generation with nonexistent file."""
  255. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  256. with tempfile.TemporaryDirectory() as tmpdir:
  257. stl_path = Path(tmpdir) / "nonexistent.stl"
  258. thumbnails_dir = Path(tmpdir)
  259. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  260. assert result is None
  261. def test_generate_stl_thumbnail_empty_file(self):
  262. """Test thumbnail generation with empty file."""
  263. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  264. with tempfile.TemporaryDirectory() as tmpdir:
  265. stl_path = Path(tmpdir) / "empty.stl"
  266. thumbnails_dir = Path(tmpdir)
  267. # Create empty file
  268. stl_path.write_bytes(b"")
  269. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  270. assert result is None
  271. @pytest.mark.skipif(
  272. not _check_trimesh_available(),
  273. reason="trimesh not installed",
  274. )
  275. def test_string_arguments_accepted_without_typeerror(self):
  276. """Regression for #1299: external-scan path passed both args as str.
  277. Before the fix, the function did ``thumbnails_dir / thumb_filename`` on
  278. a ``str`` and raised ``TypeError: unsupported operand type(s) for /:
  279. 'str' and 'str'`` for every STL on an external folder scan. The fix
  280. coerces both args to ``Path`` at entry. This test passes string args
  281. and asserts the function either succeeds or returns ``None`` — but
  282. never raises the TypeError.
  283. """
  284. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  285. with tempfile.TemporaryDirectory() as tmpdir:
  286. stl_path = Path(tmpdir) / "cube.stl"
  287. # Minimal valid binary STL: header (80 bytes) + tri count (0)
  288. stl_path.write_bytes(b"\x00" * 80 + (0).to_bytes(4, "little"))
  289. # str args — the exact shape the external-scan call site used.
  290. result = generate_stl_thumbnail(str(stl_path), str(tmpdir))
  291. # Zero-triangle mesh either yields no thumbnail or fails the
  292. # downstream render — both are acceptable; what's NOT acceptable
  293. # is a TypeError leaking out, which is what the str/str bug did.
  294. assert result is None or Path(result).exists()
  295. class TestStlThumbnailConstants:
  296. """Tests for STL thumbnail service constants."""
  297. def test_bambu_green_color(self):
  298. """Test that Bambu green color is defined."""
  299. from backend.app.services.stl_thumbnail import BAMBU_GREEN
  300. assert BAMBU_GREEN == "#00AE42"
  301. def test_light_gives_the_two_visible_faces_different_shades(self):
  302. """The whole point of lighting: adjacent visible faces must differ.
  303. Both halves are asserted because neither alone is the property.
  304. A positive dot product only says the light is not BEHIND the model, and
  305. that is not sufficient: azdeg=45 — "put the light where the camera is",
  306. the most natural next edit anyone would make — scores the HIGHEST dot
  307. product of any azimuth (+0.94) and lights both visible faces to the
  308. identical 0.825, which is a cube with no contrast down its front edge.
  309. The original bug (225) failed the other way, at -0.34.
  310. Shade factors are matplotlib's own: ``Normalize(-1, 1)`` into
  311. ``Normalize(0.3, 1).inverse``, i.e. ``0.3 + 0.7 * (dot + 1) / 2``.
  312. """
  313. import numpy as np
  314. from matplotlib.colors import LightSource
  315. from backend.app.services.stl_thumbnail import (
  316. LIGHT_ALTITUDE_DEG,
  317. LIGHT_AZIMUTH_DEG,
  318. VIEW_AZIM_DEG,
  319. VIEW_ELEV_DEG,
  320. )
  321. elev, azim = np.radians(VIEW_ELEV_DEG), np.radians(VIEW_AZIM_DEG)
  322. camera = np.array([np.cos(elev) * np.cos(azim), np.cos(elev) * np.sin(azim), np.sin(elev)])
  323. light = LightSource(azdeg=LIGHT_AZIMUTH_DEG, altdeg=LIGHT_ALTITUDE_DEG).direction
  324. assert float(light @ camera) > 0, "the light is behind the model"
  325. def shade(normal):
  326. return 0.3 + 0.7 * ((float(np.array(normal) @ light) + 1) / 2)
  327. # The two faces of an axis-aligned box that face the default camera.
  328. assert abs(shade([1, 0, 0]) - shade([0, 1, 0])) > 0.05, (
  329. "both visible faces are lit the same — the front edge disappears"
  330. )
  331. def test_light_is_above_the_horizon(self):
  332. """Grazing or overhead both collapse the contrast the shading exists for."""
  333. from backend.app.services.stl_thumbnail import LIGHT_ALTITUDE_DEG
  334. assert 0 < LIGHT_ALTITUDE_DEG < 90
  335. def test_background_color(self):
  336. """Test that background color is defined."""
  337. from backend.app.services.stl_thumbnail import BACKGROUND_COLOR
  338. assert BACKGROUND_COLOR == "#1a1a1a"
  339. def test_max_vertices_threshold(self):
  340. """Test that max vertices threshold is defined."""
  341. from backend.app.services.stl_thumbnail import MAX_VERTICES
  342. assert MAX_VERTICES == 100000
  343. def test_min_usable_stl_bytes_threshold(self):
  344. """MIN_USABLE_STL_BYTES is the call-site pre-skip floor.
  345. Binary STL with one triangle = 80B header + 4B count + 50B triangle
  346. = 134B. ASCII STL with one triangle ≈ 150B. Anything below this size
  347. cannot contain a usable mesh.
  348. """
  349. from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES
  350. assert MIN_USABLE_STL_BYTES == 200
  351. # Verify it sits between "smaller than smallest real STL" and
  352. # "common stub size" — the 24-byte ``solid test\nendsolid test``
  353. # stubs that triggered the warning storm.
  354. assert MIN_USABLE_STL_BYTES > 134 # smallest binary STL with one triangle
  355. assert MIN_USABLE_STL_BYTES > 150 # smallest ASCII STL with one triangle
  356. assert MIN_USABLE_STL_BYTES > 24 # the ZIP-stub case in the bug report
  357. def test_font_manager_logger_demoted_to_warning(self):
  358. """matplotlib.font_manager's per-font INFO scan is demoted at module
  359. import so the first STL upload doesn't surface a multi-line preamble
  360. of matplotlib internals in the journal."""
  361. import logging
  362. # Importing the module sets the level as a side effect.
  363. import backend.app.services.stl_thumbnail # noqa: F401
  364. assert logging.getLogger("matplotlib.font_manager").level >= logging.WARNING
  365. def test_configure_matplotlib_cache_sets_mplconfigdir(self, tmp_path, monkeypatch):
  366. """``_configure_matplotlib_cache`` points matplotlib at a writable
  367. persistent path so it doesn't fall back to ``/tmp/matplotlib-XXX``
  368. on every cold start."""
  369. from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
  370. # Ensure we start with no value so the helper actually runs.
  371. monkeypatch.delenv("MPLCONFIGDIR", raising=False)
  372. monkeypatch.setattr(
  373. "backend.app.services.stl_thumbnail.Path",
  374. __import__("pathlib").Path,
  375. )
  376. # Stub settings.base_dir to point inside tmp_path.
  377. from backend.app.core import config as core_config
  378. monkeypatch.setattr(core_config.settings, "base_dir", tmp_path, raising=False)
  379. _configure_matplotlib_cache()
  380. assert "MPLCONFIGDIR" in os.environ
  381. configured = Path(os.environ["MPLCONFIGDIR"])
  382. assert configured.exists()
  383. assert configured.is_dir()
  384. # And the directory sits under base_dir, not /tmp/matplotlib-XXX.
  385. assert tmp_path in configured.parents
  386. def test_configure_matplotlib_cache_respects_externally_set_value(self, tmp_path, monkeypatch):
  387. """If the operator (or container init) has set MPLCONFIGDIR already,
  388. the helper must leave it alone — they made a deliberate choice."""
  389. from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
  390. external = str(tmp_path / "external-mpl-cache")
  391. monkeypatch.setenv("MPLCONFIGDIR", external)
  392. _configure_matplotlib_cache()
  393. assert os.environ["MPLCONFIGDIR"] == external
  394. def test_empty_mesh_logged_at_debug_not_warning(self, caplog):
  395. """An empty STL (header present, no triangles) must log at DEBUG, not
  396. WARNING — bulk uploads used to log thousands of WARNING lines per
  397. ZIP. Per-file content observations stay observable in debug logs
  398. but don't spam production journals."""
  399. import logging
  400. import tempfile
  401. from pathlib import Path
  402. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  403. # The exact 24-byte stub from the bug report
  404. stub_content = b"solid test\nendsolid test"
  405. with tempfile.TemporaryDirectory() as tmpdir:
  406. tmpdir_path = Path(tmpdir)
  407. stl_path = tmpdir_path / "stub.stl"
  408. stl_path.write_bytes(stub_content)
  409. with caplog.at_level(logging.DEBUG, logger="backend.app.services.stl_thumbnail"):
  410. result = generate_stl_thumbnail(stl_path, tmpdir_path)
  411. assert result is None
  412. # The empty-mesh message must NOT appear at WARNING level.
  413. warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING and "empty mesh" in r.getMessage()]
  414. assert warning_records == [], (
  415. f"Empty-mesh path still logs at WARNING: {[r.getMessage() for r in warning_records]}"
  416. )