Bladeren bron

Light the generated thumbnails so one model differs from another (#2816) (#2861)

Maksim Sadontsev 2 weken geleden
bovenliggende
commit
ed85677913

+ 43 - 6
backend/app/services/plate_thumbnail.py

@@ -132,7 +132,17 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
     # Local imports so a `import backend.app.services.plate_thumbnail` from
     # an environment without matplotlib/trimesh doesn't fail at import time —
     # the function will simply degrade to no-op via the exception branch.
-    from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
+    #
+    # The light angle is IMPORTED rather than mirrored like the palette above.
+    # "A plate card and a library thumbnail of the same model look alike" is the
+    # whole reason these two renderers share a look, and a second copy of the
+    # angle is exactly how that silently stops being true. A palette can afford a
+    # copy; a number nobody would notice drifting cannot.
+    from backend.app.services.stl_thumbnail import (
+        _configure_matplotlib_cache,
+        _repair_winding,
+        _shade_kwargs,
+    )
 
     _configure_matplotlib_cache()
 
@@ -141,6 +151,7 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
     matplotlib.use("Agg")
     import matplotlib.pyplot as plt
     import trimesh
+    from matplotlib.colors import LightSource
     from mpl_toolkits.mplot3d.art3d import Poly3DCollection
 
     loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
@@ -157,6 +168,15 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
         except Exception as exc:
             logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
 
+    # Before the vertices are read, not after: ``scaled`` below is indexed by
+    # ``mesh.faces``, so a repair that ever moves a vertex would leave the two
+    # out of step. Shared with stl_thumbnail rather than copied — the reason
+    # these renderers agree is that they run the same code, not similar code.
+    try:
+        _repair_winding(mesh, trimesh, "plate_thumbnail")
+    except Exception as e:  # best-effort, as the whole module is
+        logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
+
     vertices = mesh.vertices
     bounds_min = vertices.min(axis=0)
     bounds_max = vertices.max(axis=0)
@@ -164,20 +184,36 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
     max_extent = (bounds_max - bounds_min).max()
     scaled = centered / max_extent if max_extent > 0 else centered
 
+    # ndarray, not a list of lists — shading walks this to build normals, and the
+    # list form is ~30x slower to construct. Paid twice per plate: once per size.
     faces = mesh.faces
-    poly3d = [[scaled[v] for v in face] for face in faces]
+    poly3d = scaled[faces]
 
-    large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection)
-    small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection)
+    # Resolved once and shared: both sizes must be lit identically or the 128px
+    # card and the 512px view disagree. Empty for a mesh matplotlib cannot shade,
+    # which keeps such a plate rendering flat instead of failing — see
+    # ``_shade_kwargs``.
+    shade_kw = _shade_kwargs(poly3d, LightSource)
+
+    large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection, shade_kw)
+    small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection, shade_kw)
     return large, small
 
 
-def _render_at_size(poly3d, size: int, plt, Poly3DCollection) -> bytes:
+def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) -> bytes:
     """Render the prepared poly3d collection to an in-memory PNG."""
+    # Local, like every other import in this module, so importing plate_thumbnail
+    # in an environment without matplotlib still works. stl_thumbnail's own
+    # module level is import-light, so this costs nothing after the first call.
+    from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
+
     fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
     fig.patch.set_facecolor(_BACKGROUND_COLOR)
     ax = fig.add_subplot(111, projection="3d")
     ax.set_facecolor(_BACKGROUND_COLOR)
+    # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
+    # colours alongside the face colours, and an empty array (``"none"``) makes
+    # it raise on the broadcast. Keep the two in step if either moves.
     ax.add_collection3d(
         Poly3DCollection(
             poly3d,
@@ -185,12 +221,13 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection) -> bytes:
             edgecolors=_BAMBU_GREEN,
             linewidths=0.1,
             alpha=0.9,
+            **shade_kw,
         )
     )
     ax.set_xlim(-0.6, 0.6)
     ax.set_ylim(-0.6, 0.6)
     ax.set_zlim(-0.6, 0.6)
-    ax.view_init(elev=25, azim=45)
+    ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
     ax.set_axis_off()
     ax.grid(False)
     plt.subplots_adjust(left=0, right=1, top=1, bottom=0)

+ 133 - 2
backend/app/services/stl_thumbnail.py

@@ -54,6 +54,28 @@ def _configure_matplotlib_cache() -> None:
 BAMBU_GREEN = "#00AE42"
 BACKGROUND_COLOR = "#1a1a1a"
 
+# Direction of the synthetic light used to shade the mesh. Without a light
+# source ``Poly3DCollection`` fills every triangle with the identical colour
+# regardless of its normal, so the render comes out a flat silhouette and one
+# model is indistinguishable from another (issue #2816).
+#
+# The azimuth is NOT free. matplotlib's light direction for (az, alt) is
+# ``[cos(90-az)cos(alt), sin(90-az)cos(alt), sin(alt)]``, and the camera set by
+# ``view_init(elev, azim)`` sits at ``[cos(elev)cos(azim), cos(elev)sin(azim),
+# sin(elev)]``. The dot product of the two must be POSITIVE or the light is
+# behind the model: at 225 it is -0.34, which lights the two hidden faces and
+# gives both visible ones the identical 0.475 — a cube with no contrast down its
+# front edge. At 315 it is +0.30, and the two visible sides come out 0.825 and
+# 0.475. ``test_light_is_on_the_camera_side`` holds that invariant so the pair
+# cannot drift apart again.
+LIGHT_AZIMUTH_DEG = 315
+LIGHT_ALTITUDE_DEG = 45
+
+# The camera the light above is chosen against. Named because the two are a PAIR:
+# move one without the other and the model goes back to being lit from behind.
+VIEW_ELEV_DEG = 25
+VIEW_AZIM_DEG = 45
+
 # Maximum vertices before simplification
 MAX_VERTICES = 100000
 
@@ -66,6 +88,97 @@ MAX_VERTICES = 100000
 MIN_USABLE_STL_BYTES = 200
 
 
+def _repair_winding(mesh, trimesh, label: str) -> None:
+    """Make every face wind the same way, and wind it OUTWARD, before shading.
+
+    matplotlib derives its normals from vertex ORDER, so a triangle wound the
+    wrong way shades as though it faced away and the model comes out patchy —
+    camouflage rather than a surface. Unshaded this never showed, so lighting the
+    render is what makes it matter, and the File Manager takes arbitrary user
+    STLs. ``trimesh.load(force="mesh")`` does not repair winding; this does.
+
+    ``trimesh.repair.fix_winding`` and NOT ``mesh.fix_normals()``: the latter
+    reaches ``body_count`` -> ``scipy.csgraph``, and scipy is not a dependency of
+    this project. fix_winding goes through networkx, which requirements.txt
+    already pins.
+
+    Three steps, because each one leaves something for the next:
+
+    * ``fix_winding`` makes the winding agree but is free to settle on either
+      orientation, and on a half-inverted sphere it picks INWARD — consistent,
+      and consistently lit from inside.
+    * ``fix_inversion`` corrects that off the sign of the volume, but only for a
+      WATERTIGHT mesh. It returns early otherwise, because a volume measured
+      across holes says nothing about which way is out.
+    * Which leaves the common case, since a mesh with broken winding is usually
+      not watertight either. With no usable volume, decide by whether the faces
+      point away from the centroid. Measured on a punctured half-inverted
+      icosphere: the first two steps alone left 0 of 1200 faces oriented like the
+      correctly wound mesh, a mean render delta of 4.56; with this one it is
+      1200 of 1200 and 0.00.
+
+    The centroid test runs only on a mesh whose winding was already broken, and
+    it leaves correct ones alone: closed and punctured spheres, a flat plate, an
+    open tube, a non-convex L and two disjoint boxes all sum positive.
+
+    Gated here rather than at the call sites so the two renderers cannot drift.
+    The check is tens of ms where the repair is seconds on a large mesh, so only
+    meshes that would otherwise render wrong pay for it.
+    """
+    import numpy as np
+
+    if len(mesh.faces) == 0 or mesh.is_winding_consistent:
+        return
+
+    logger.debug("Repairing inconsistent winding before render: %s", label)
+    trimesh.repair.fix_winding(mesh)
+    trimesh.repair.fix_inversion(mesh)
+    if mesh.is_watertight:
+        return
+
+    outward = mesh.triangles.mean(axis=1) - mesh.vertices.mean(axis=0)
+    if float(np.einsum("ij,ij->i", mesh.face_normals, outward).sum()) < 0:
+        logger.debug("Winding settled inward on a non-watertight mesh, inverting: %s", label)
+        mesh.invert()
+
+
+def _shade_kwargs(poly3d, LightSource) -> dict:
+    """``shade=True`` and its light, or nothing when the mesh cannot be shaded.
+
+    matplotlib's ``_shade_colors`` has a fallback for a mesh whose every face
+    normal is degenerate, and that fallback returns the colour argument it was
+    given, unchanged. Passing a colour STRING — which both renderers do — makes
+    it hand back a 0-d ``<U7`` array, and ``to_rgba_array`` then calls ``len()``
+    on it and raises ``TypeError: len() of unsized object``.
+
+    So a file whose facets are all zero-area or collinear rendered fine while the
+    output was flat, and would fail outright once lit. That population is real:
+    stub and truncated STLs, and hand-written 3MFs with an empty ``<triangles/>``.
+    Worse, ``batch_generate_stl_thumbnails`` walks a whole folder with no
+    minimum-size pre-skip, so each one would count as a failure in the UI and put
+    a traceback in the log — the exact noise ``stl_thumbnail``'s demoted logging
+    exists to keep out.
+
+    Deciding here rather than catching the TypeError keeps the flat render as a
+    real outcome instead of an error path, and costs ~6 ms on a 227k-face mesh.
+    Identical to matplotlib's own test: a cross product that is finite and
+    non-zero for at least one face.
+    """
+    import numpy as np
+
+    if len(poly3d) == 0:
+        return {}
+    tri = np.asarray(poly3d, dtype=float)
+    normals = np.cross(tri[:, 0] - tri[:, 1], tri[:, 1] - tri[:, 2])
+    lengths = np.linalg.norm(normals, axis=1)
+    if not bool(np.any(np.isfinite(lengths) & (lengths > 0))):
+        return {}
+    return {
+        "shade": True,
+        "lightsource": LightSource(azdeg=LIGHT_AZIMUTH_DEG, altdeg=LIGHT_ALTITUDE_DEG),
+    }
+
+
 def generate_stl_thumbnail(
     stl_path: Path,
     thumbnails_dir: Path,
@@ -98,6 +211,7 @@ def generate_stl_thumbnail(
         # Use Agg backend for headless rendering
         matplotlib.use("Agg")
         import matplotlib.pyplot as plt
+        from matplotlib.colors import LightSource
         from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
         from mpl_toolkits.mplot3d.art3d import Poly3DCollection
 
@@ -130,6 +244,14 @@ def generate_stl_thumbnail(
             except Exception as e:
                 logger.warning("Mesh simplification failed, using original: %s", e)
 
+        # Wind every face the same way, and outward, or the shading turns the
+        # model into camouflage. See ``_repair_winding``; it must run before the
+        # vertices below are read, since a future repair step could move them.
+        try:
+            _repair_winding(mesh, trimesh, str(stl_path))
+        except Exception as e:  # best-effort: a flat render beats no thumbnail
+            logger.debug("Winding repair skipped (%s): %s", e, stl_path)
+
         # Get mesh bounds and center it
         vertices = mesh.vertices
         bounds_min = vertices.min(axis=0)
@@ -153,15 +275,24 @@ def generate_stl_thumbnail(
         ax.set_facecolor(BACKGROUND_COLOR)
 
         # Create polygon collection from mesh faces
+        # Index with the face array rather than building a list of lists. Same
+        # data, and Poly3DCollection accepts it directly — but shading walks this
+        # structure to generate normals, and on an 82k-face mesh the list form
+        # costs ~0.19s against ~0.007s for the ndarray. It speeds up the unshaded
+        # path too.
         faces = mesh.faces
-        poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]
+        poly3d = vertices_scaled[faces]
 
+        # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
+        # colours alongside the face colours, and an empty array (``"none"``)
+        # makes it raise on the broadcast. Keep the two in step if either moves.
         collection = Poly3DCollection(
             poly3d,
             facecolors=BAMBU_GREEN,
             edgecolors=BAMBU_GREEN,
             linewidths=0.1,
             alpha=0.9,
+            **_shade_kwargs(poly3d, LightSource),
         )
         ax.add_collection3d(collection)
 
@@ -171,7 +302,7 @@ def generate_stl_thumbnail(
         ax.set_zlim(-0.6, 0.6)
 
         # Set view angle (isometric-ish)
-        ax.view_init(elev=25, azim=45)
+        ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
 
         # Remove axes and grid
         ax.set_axis_off()

+ 49 - 1
backend/tests/unit/services/conftest.py

@@ -1,4 +1,6 @@
-"""Test fixtures for FTP service tests.
+"""Shared fixtures for service tests.
+
+Mostly FTP.
 
 Provides a real implicit FTPS server (via mock_ftp_server) and client factory
 for integration-style testing of BambuFTPClient against a live server.
@@ -7,6 +9,7 @@ The server fixture is class-scoped to avoid the overhead of starting a new
 TLS server for every test (~67 TLS handshakes → ~9 per class).
 """
 
+import io
 import os
 import shutil
 import socket
@@ -143,3 +146,48 @@ def patch_ftp_port(ftp_server):
     """
     with patch.object(BambuFTPClient, "FTP_PORT", ftp_server.port):
         yield ftp_server
+
+
+@pytest.fixture()
+def distinct_surface_tones():
+    """Count the distinct colours covering the model's surface in a render.
+
+    Shared by the STL and plate thumbnail suites, which render the same way
+    through two different modules and need the same question answered.
+
+    Quantises to 5 bits per channel before counting and keeps only pixels where
+    green dominates. The spread being quantised away is Agg's antialiasing and
+    the alpha compositing; PNG itself is lossless and contributes none.
+
+    **This counts large flat tone regions, which is only the same thing as
+    "is it shaded" for a FLAT-FACED model.** A curved surface produces several
+    such regions with no light at all — measured unshaded at alpha=0.9: cube 1,
+    cylinder 1, but sphere 3 and torus 3. So the cube fixture is not incidental;
+    swap in anything rounder and ``>= 3`` passes on completely unlit output.
+    A cube is 1 unshaded and 3 lit, and its three margins are comfortable
+    (0.35 / 0.35 / 0.29, nothing between the noise floor and the threshold).
+
+    Note the green-dominant filter keeps the green-to-background blends along the
+    silhouette as well as the model — about 1% of the pixels it counts. They sit
+    far below ``min_share`` individually, so they change no verdict.
+    """
+
+    def _count(png: bytes, *, min_share: float = 0.02) -> int:
+        import numpy as np
+        from PIL import Image
+
+        # np.asarray, not Image.getdata(): getdata is deprecated for removal in
+        # Pillow 14 and requirements.txt pins pillow unbounded, while pyproject
+        # silences DeprecationWarning — so it would surface as an AttributeError
+        # in CI rather than as a warning anyone saw coming.
+        rgb = np.asarray(Image.open(io.BytesIO(png)).convert("RGB"), dtype=np.int16)
+        r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
+        surface_mask = (g > r) & (g > b)
+        if not surface_mask.any():
+            return 0
+
+        keys = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
+        counts = np.bincount(keys[surface_mask].ravel())
+        return int((counts / counts.sum() >= min_share).sum())
+
+    return _count

+ 17 - 0
backend/tests/unit/services/test_plate_thumbnail.py

@@ -135,6 +135,23 @@ class TestInjectPlateThumbnails:
         assert 480 <= large_w <= 540 and 480 <= large_h <= 540
         assert 100 <= small_w <= 140 and 100 <= small_h <= 140
 
+    def test_injected_thumbnail_is_shaded_not_flat(self, distinct_surface_tones):
+        """Injected plate renders must be lit, same as library thumbnails (#2816).
+
+        The archive card and the File Manager tile show the same model through
+        two different renderers; if only one of them is lit they disagree.
+        """
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
+        result = inject_plate_thumbnails_if_missing(fixture)
+
+        with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
+            large = zf.read("Metadata/plate_1.png")
+
+        # _build_sliced_3mf embeds a cube: three faces visible, three tones.
+        assert distinct_surface_tones(large) >= 3
+
     def test_injects_for_every_missing_plate_in_multi_plate_3mf(self):
         """Three plates, plate_2 already has a thumbnail; only plates 1 + 3 get rendered."""
         from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing

+ 180 - 0
backend/tests/unit/services/test_stl_thumbnail.py

@@ -156,6 +156,141 @@ endsolid cube"""
             # 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
@@ -220,6 +355,51 @@ class TestStlThumbnailConstants:
 
         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