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

This commit is contained in:
Maksim Sadontsev
2026-08-22 14:16:56 +02:00
committed by GitHub
parent 68140747f2
commit ed85677913
5 changed files with 422 additions and 9 deletions
+43 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
@@ -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