| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- """Unit tests for the STL thumbnail service."""
- import os
- import tempfile
- from pathlib import Path
- import pytest
- def _check_trimesh_available():
- """Check if trimesh is available for import."""
- try:
- import trimesh
- return True
- except ImportError:
- return False
- class TestStlThumbnailService:
- """Tests for STL thumbnail generation service."""
- def test_generate_stl_thumbnail_imports_available(self):
- """Test that required imports are available."""
- try:
- import matplotlib
- import trimesh
- assert trimesh is not None
- assert matplotlib is not None
- except ImportError as e:
- pytest.skip(f"Required dependencies not installed: {e}")
- def test_generate_stl_thumbnail_returns_none_on_missing_deps(self):
- """Test graceful degradation when dependencies are missing."""
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- with tempfile.TemporaryDirectory() as tmpdir:
- stl_path = Path(tmpdir) / "test.stl"
- thumbnails_dir = Path(tmpdir)
- # Create a dummy STL file (will fail to parse)
- stl_path.write_text("invalid stl content")
- # Should return None on failure, not raise
- result = generate_stl_thumbnail(stl_path, thumbnails_dir)
- assert result is None
- @pytest.mark.skipif(
- not _check_trimesh_available(),
- reason="trimesh not installed",
- )
- def test_generate_stl_thumbnail_with_simple_cube(self):
- """Test thumbnail generation with a simple cube STL."""
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- with tempfile.TemporaryDirectory() as tmpdir:
- stl_path = Path(tmpdir) / "cube.stl"
- thumbnails_dir = Path(tmpdir)
- # Create a simple ASCII STL cube
- stl_content = """solid cube
- facet normal 0 0 -1
- outer loop
- vertex 0 0 0
- vertex 1 0 0
- vertex 1 1 0
- endloop
- endfacet
- facet normal 0 0 -1
- outer loop
- vertex 0 0 0
- vertex 1 1 0
- vertex 0 1 0
- endloop
- endfacet
- facet normal 0 0 1
- outer loop
- vertex 0 0 1
- vertex 1 1 1
- vertex 1 0 1
- endloop
- endfacet
- facet normal 0 0 1
- outer loop
- vertex 0 0 1
- vertex 0 1 1
- vertex 1 1 1
- endloop
- endfacet
- facet normal 0 -1 0
- outer loop
- vertex 0 0 0
- vertex 1 0 1
- vertex 1 0 0
- endloop
- endfacet
- facet normal 0 -1 0
- outer loop
- vertex 0 0 0
- vertex 0 0 1
- vertex 1 0 1
- endloop
- endfacet
- facet normal 1 0 0
- outer loop
- vertex 1 0 0
- vertex 1 0 1
- vertex 1 1 1
- endloop
- endfacet
- facet normal 1 0 0
- outer loop
- vertex 1 0 0
- vertex 1 1 1
- vertex 1 1 0
- endloop
- endfacet
- facet normal 0 1 0
- outer loop
- vertex 0 1 0
- vertex 1 1 0
- vertex 1 1 1
- endloop
- endfacet
- facet normal 0 1 0
- outer loop
- vertex 0 1 0
- vertex 1 1 1
- vertex 0 1 1
- endloop
- endfacet
- facet normal -1 0 0
- outer loop
- vertex 0 0 0
- vertex 0 1 0
- vertex 0 1 1
- endloop
- endfacet
- facet normal -1 0 0
- outer loop
- vertex 0 0 0
- vertex 0 1 1
- vertex 0 0 1
- endloop
- endfacet
- endsolid cube"""
- stl_path.write_text(stl_content)
- result = generate_stl_thumbnail(stl_path, thumbnails_dir)
- # Should return a path to the generated thumbnail
- if result:
- assert Path(result).exists()
- assert Path(result).suffix == ".png"
- # If result is None, dependencies might not be fully functional
- # which is acceptable
- @pytest.mark.skipif(
- not _check_trimesh_available(),
- reason="trimesh not installed",
- )
- def test_generated_thumbnail_is_shaded_not_flat(self, distinct_surface_tones):
- """The render must be lit, not a flat silhouette (issue #2816).
- Without ``shade=True`` every triangle is filled with BAMBU_GREEN
- regardless of its normal, so any model renders as its own outline and
- one file is indistinguishable from another in the File Manager.
- """
- import trimesh
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- with tempfile.TemporaryDirectory() as tmpdir:
- stl_path = Path(tmpdir) / "cube.stl"
- trimesh.creation.box(extents=(10.0, 10.0, 10.0)).export(str(stl_path))
- result = generate_stl_thumbnail(stl_path, Path(tmpdir))
- assert result is not None
- # Three faces of a cube face the camera at the default isometric
- # view_init, and with the light on the camera's side each catches it
- # differently. This held at 3 before only because ``alpha=0.9`` let a
- # back face bleed through two IDENTICALLY lit front faces — re-render
- # at alpha=1.0 then and the count fell to 2. It is shading now.
- assert distinct_surface_tones(Path(result).read_bytes()) >= 3
- @pytest.mark.skipif(
- not _check_trimesh_available(),
- reason="trimesh not installed",
- )
- @pytest.mark.parametrize(
- ("label", "punch_holes"),
- [("watertight", False), ("open", True)],
- )
- def test_backwards_wound_triangles_render_the_same(self, label, punch_holes):
- """Vertex ORDER must not change the picture.
- matplotlib takes its normals from winding, so an inverted triangle shades
- as though it faced away — the model comes out patchy, like camouflage.
- Unshaded this was invisible, which makes it a regression the shading
- introduced rather than one it revealed, and the File Manager accepts
- whatever STL a user uploads.
- Asserted as "same picture as the correctly wound mesh", because the
- obvious assertion does not work: broken winding produces MORE distinct
- tones, not fewer, so a tone count cannot see it.
- Run BOTH watertight and open, because the two are repaired by different
- code. ``fix_inversion`` decides which way is out from the sign of the
- volume and gives up when the mesh is not watertight, which is the common
- shape of a broken STL — there, ``fix_winding`` settles inward unopposed
- and the centroid fallback in ``_repair_winding`` is the only thing
- holding this. Without it the open case renders at a mean delta of 4.56.
- """
- import numpy as np
- import trimesh
- from PIL import Image
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- sphere = trimesh.creation.icosphere(subdivisions=3, radius=5.0)
- keep = sphere.faces.copy()[:-80] if punch_holes else sphere.faces.copy()
- good = trimesh.Trimesh(vertices=sphere.vertices.copy(), faces=keep.copy())
- assert good.is_watertight is not punch_holes, "fixture has the wrong topology"
- faces = keep.copy()
- faces[::2] = faces[::2][:, ::-1]
- bad = trimesh.Trimesh(vertices=sphere.vertices.copy(), faces=faces)
- assert not bad.is_winding_consistent, "fixture is supposed to be broken"
- with tempfile.TemporaryDirectory() as tmpdir:
- out = Path(tmpdir)
- rendered = []
- for name, mesh in (("good", good), ("bad", bad)):
- path = out / f"{name}.stl"
- mesh.export(str(path))
- result = generate_stl_thumbnail(path, out)
- assert result is not None
- rendered.append(np.asarray(Image.open(result).convert("RGB"), dtype=float))
- assert rendered[0].shape == rendered[1].shape
- mean_delta = float(np.abs(rendered[0] - rendered[1]).mean())
- # Repaired they are the same mesh, so this is ~0. Without the repair the
- # inverted half renders dark against the lit half and it is an order of
- # magnitude higher.
- assert mean_delta < 1.0, f"winding changed the {label} render (mean delta {mean_delta:.2f})"
- @pytest.mark.skipif(
- not _check_trimesh_available(),
- reason="trimesh not installed",
- )
- def test_degenerate_mesh_still_renders(self):
- """A mesh with no shadeable face must render flat, not fail.
- matplotlib's ``_shade_colors`` falls back to returning the colour it was
- given when every normal is degenerate, and for a colour STRING that is a
- 0-d array — ``to_rgba_array`` then raises ``TypeError: len() of unsized
- object``. So these files rendered fine while the output was flat, and
- turning the light on would have broken them.
- They are not hypothetical: stub and truncated STLs reach here, and
- ``batch_generate_stl_thumbnails`` walks a whole folder with no
- minimum-size pre-skip, so each one would show as a failure in the UI.
- """
- import struct
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- def write_binary_stl(path, triangles):
- # Written by hand rather than via trimesh.export, which drops
- # degenerate facets and would quietly defeat the test.
- with open(path, "wb") as fh:
- fh.write(b"\0" * 80)
- fh.write(struct.pack("<I", len(triangles)))
- for tri in triangles:
- fh.write(struct.pack("<3f", 0.0, 0.0, 0.0))
- for vertex in tri:
- fh.write(struct.pack("<3f", *vertex))
- fh.write(b"\0\0")
- cases = {
- "zero_area": [[(0, 0, 0), (0, 0, 0), (0, 0, 0)]],
- "collinear": [[(0, 0, 0), (1, 1, 1), (2, 2, 2)]],
- }
- with tempfile.TemporaryDirectory() as tmpdir:
- out = Path(tmpdir)
- for name, triangles in cases.items():
- path = out / f"{name}.stl"
- write_binary_stl(path, triangles)
- assert generate_stl_thumbnail(path, out) is not None, f"{name} used to render and must still render"
- def test_generate_stl_thumbnail_nonexistent_file(self):
- """Test thumbnail generation with nonexistent file."""
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- with tempfile.TemporaryDirectory() as tmpdir:
- stl_path = Path(tmpdir) / "nonexistent.stl"
- thumbnails_dir = Path(tmpdir)
- result = generate_stl_thumbnail(stl_path, thumbnails_dir)
- assert result is None
- def test_generate_stl_thumbnail_empty_file(self):
- """Test thumbnail generation with empty file."""
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- with tempfile.TemporaryDirectory() as tmpdir:
- stl_path = Path(tmpdir) / "empty.stl"
- thumbnails_dir = Path(tmpdir)
- # Create empty file
- stl_path.write_bytes(b"")
- result = generate_stl_thumbnail(stl_path, thumbnails_dir)
- assert result is None
- @pytest.mark.skipif(
- not _check_trimesh_available(),
- reason="trimesh not installed",
- )
- def test_string_arguments_accepted_without_typeerror(self):
- """Regression for #1299: external-scan path passed both args as str.
- Before the fix, the function did ``thumbnails_dir / thumb_filename`` on
- a ``str`` and raised ``TypeError: unsupported operand type(s) for /:
- 'str' and 'str'`` for every STL on an external folder scan. The fix
- coerces both args to ``Path`` at entry. This test passes string args
- and asserts the function either succeeds or returns ``None`` — but
- never raises the TypeError.
- """
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- with tempfile.TemporaryDirectory() as tmpdir:
- stl_path = Path(tmpdir) / "cube.stl"
- # Minimal valid binary STL: header (80 bytes) + tri count (0)
- stl_path.write_bytes(b"\x00" * 80 + (0).to_bytes(4, "little"))
- # str args — the exact shape the external-scan call site used.
- result = generate_stl_thumbnail(str(stl_path), str(tmpdir))
- # Zero-triangle mesh either yields no thumbnail or fails the
- # downstream render — both are acceptable; what's NOT acceptable
- # is a TypeError leaking out, which is what the str/str bug did.
- assert result is None or Path(result).exists()
- class TestStlThumbnailConstants:
- """Tests for STL thumbnail service constants."""
- def test_bambu_green_color(self):
- """Test that Bambu green color is defined."""
- from backend.app.services.stl_thumbnail import BAMBU_GREEN
- assert BAMBU_GREEN == "#00AE42"
- def test_light_gives_the_two_visible_faces_different_shades(self):
- """The whole point of lighting: adjacent visible faces must differ.
- Both halves are asserted because neither alone is the property.
- A positive dot product only says the light is not BEHIND the model, and
- that is not sufficient: azdeg=45 — "put the light where the camera is",
- the most natural next edit anyone would make — scores the HIGHEST dot
- product of any azimuth (+0.94) and lights both visible faces to the
- identical 0.825, which is a cube with no contrast down its front edge.
- The original bug (225) failed the other way, at -0.34.
- Shade factors are matplotlib's own: ``Normalize(-1, 1)`` into
- ``Normalize(0.3, 1).inverse``, i.e. ``0.3 + 0.7 * (dot + 1) / 2``.
- """
- import numpy as np
- from matplotlib.colors import LightSource
- from backend.app.services.stl_thumbnail import (
- LIGHT_ALTITUDE_DEG,
- LIGHT_AZIMUTH_DEG,
- VIEW_AZIM_DEG,
- VIEW_ELEV_DEG,
- )
- elev, azim = np.radians(VIEW_ELEV_DEG), np.radians(VIEW_AZIM_DEG)
- camera = np.array([np.cos(elev) * np.cos(azim), np.cos(elev) * np.sin(azim), np.sin(elev)])
- light = LightSource(azdeg=LIGHT_AZIMUTH_DEG, altdeg=LIGHT_ALTITUDE_DEG).direction
- assert float(light @ camera) > 0, "the light is behind the model"
- def shade(normal):
- return 0.3 + 0.7 * ((float(np.array(normal) @ light) + 1) / 2)
- # The two faces of an axis-aligned box that face the default camera.
- assert abs(shade([1, 0, 0]) - shade([0, 1, 0])) > 0.05, (
- "both visible faces are lit the same — the front edge disappears"
- )
- def test_light_is_above_the_horizon(self):
- """Grazing or overhead both collapse the contrast the shading exists for."""
- from backend.app.services.stl_thumbnail import LIGHT_ALTITUDE_DEG
- assert 0 < LIGHT_ALTITUDE_DEG < 90
- def test_background_color(self):
- """Test that background color is defined."""
- from backend.app.services.stl_thumbnail import BACKGROUND_COLOR
- assert BACKGROUND_COLOR == "#1a1a1a"
- def test_max_vertices_threshold(self):
- """Test that max vertices threshold is defined."""
- from backend.app.services.stl_thumbnail import MAX_VERTICES
- assert MAX_VERTICES == 100000
- def test_min_usable_stl_bytes_threshold(self):
- """MIN_USABLE_STL_BYTES is the call-site pre-skip floor.
- Binary STL with one triangle = 80B header + 4B count + 50B triangle
- = 134B. ASCII STL with one triangle ≈ 150B. Anything below this size
- cannot contain a usable mesh.
- """
- from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES
- assert MIN_USABLE_STL_BYTES == 200
- # Verify it sits between "smaller than smallest real STL" and
- # "common stub size" — the 24-byte ``solid test\nendsolid test``
- # stubs that triggered the warning storm.
- assert MIN_USABLE_STL_BYTES > 134 # smallest binary STL with one triangle
- assert MIN_USABLE_STL_BYTES > 150 # smallest ASCII STL with one triangle
- assert MIN_USABLE_STL_BYTES > 24 # the ZIP-stub case in the bug report
- def test_font_manager_logger_demoted_to_warning(self):
- """matplotlib.font_manager's per-font INFO scan is demoted at module
- import so the first STL upload doesn't surface a multi-line preamble
- of matplotlib internals in the journal."""
- import logging
- # Importing the module sets the level as a side effect.
- import backend.app.services.stl_thumbnail # noqa: F401
- assert logging.getLogger("matplotlib.font_manager").level >= logging.WARNING
- def test_configure_matplotlib_cache_sets_mplconfigdir(self, tmp_path, monkeypatch):
- """``_configure_matplotlib_cache`` points matplotlib at a writable
- persistent path so it doesn't fall back to ``/tmp/matplotlib-XXX``
- on every cold start."""
- from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
- # Ensure we start with no value so the helper actually runs.
- monkeypatch.delenv("MPLCONFIGDIR", raising=False)
- monkeypatch.setattr(
- "backend.app.services.stl_thumbnail.Path",
- __import__("pathlib").Path,
- )
- # Stub settings.base_dir to point inside tmp_path.
- from backend.app.core import config as core_config
- monkeypatch.setattr(core_config.settings, "base_dir", tmp_path, raising=False)
- _configure_matplotlib_cache()
- assert "MPLCONFIGDIR" in os.environ
- configured = Path(os.environ["MPLCONFIGDIR"])
- assert configured.exists()
- assert configured.is_dir()
- # And the directory sits under base_dir, not /tmp/matplotlib-XXX.
- assert tmp_path in configured.parents
- def test_configure_matplotlib_cache_respects_externally_set_value(self, tmp_path, monkeypatch):
- """If the operator (or container init) has set MPLCONFIGDIR already,
- the helper must leave it alone — they made a deliberate choice."""
- from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
- external = str(tmp_path / "external-mpl-cache")
- monkeypatch.setenv("MPLCONFIGDIR", external)
- _configure_matplotlib_cache()
- assert os.environ["MPLCONFIGDIR"] == external
- def test_empty_mesh_logged_at_debug_not_warning(self, caplog):
- """An empty STL (header present, no triangles) must log at DEBUG, not
- WARNING — bulk uploads used to log thousands of WARNING lines per
- ZIP. Per-file content observations stay observable in debug logs
- but don't spam production journals."""
- import logging
- import tempfile
- from pathlib import Path
- from backend.app.services.stl_thumbnail import generate_stl_thumbnail
- # The exact 24-byte stub from the bug report
- stub_content = b"solid test\nendsolid test"
- with tempfile.TemporaryDirectory() as tmpdir:
- tmpdir_path = Path(tmpdir)
- stl_path = tmpdir_path / "stub.stl"
- stl_path.write_bytes(stub_content)
- with caplog.at_level(logging.DEBUG, logger="backend.app.services.stl_thumbnail"):
- result = generate_stl_thumbnail(stl_path, tmpdir_path)
- assert result is None
- # The empty-mesh message must NOT appear at WARNING level.
- warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING and "empty mesh" in r.getMessage()]
- assert warning_records == [], (
- f"Empty-mesh path still logs at WARNING: {[r.getMessage() for r in warning_records]}"
- )
|