Files

508 lines
19 KiB
Python

"""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]}"
)