mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
343 lines
15 KiB
Python
343 lines
15 KiB
Python
"""STL Thumbnail Generation Service.
|
|
|
|
Generates thumbnail images from STL files using trimesh and matplotlib.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Matplotlib's font_manager emits one INFO line per font on first import
|
|
# while it builds its cache, including a noisy "Failed to extract font
|
|
# properties from NotoColorEmoji.ttf" for the COLR/COLR1 emoji format it
|
|
# doesn't support. These are not actionable — demote to WARNING so real
|
|
# font issues still surface but the first STL upload doesn't produce a
|
|
# multi-line matplotlib preamble in the journal.
|
|
logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
|
|
|
|
|
|
def _configure_matplotlib_cache() -> None:
|
|
"""Point matplotlib's config/cache directory at a writable persistent path.
|
|
|
|
Without this, matplotlib falls back to ``/tmp/matplotlib-XXXXXX`` whenever
|
|
``$HOME/.config/matplotlib`` isn't writable — which is the case under
|
|
Bambuddy's container / systemd-service deployments where ``$HOME`` is set
|
|
to a non-writable path. The fallback emits a WARNING on every cold start
|
|
AND loses the font cache on host reboot, so font_manager rebuilds it
|
|
every time → another batch of INFO lines.
|
|
|
|
Setting ``MPLCONFIGDIR`` to ``settings.base_dir / .cache / matplotlib``
|
|
eliminates both: the warning never fires, and the cache survives across
|
|
restarts so the per-font scan only runs once per deployment.
|
|
Idempotent — respects an externally-set ``MPLCONFIGDIR`` if the operator
|
|
chose their own path.
|
|
"""
|
|
if os.environ.get("MPLCONFIGDIR"):
|
|
return
|
|
try:
|
|
from backend.app.core.config import settings
|
|
|
|
cache_dir = Path(settings.base_dir) / ".cache" / "matplotlib"
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
os.environ["MPLCONFIGDIR"] = str(cache_dir)
|
|
except Exception as exc:
|
|
# Best-effort. If settings isn't importable or the mkdir fails (read-only
|
|
# FS, permission denied), let matplotlib fall back to /tmp with its
|
|
# built-in warning — same as today's behaviour, no worse.
|
|
logger.debug("Could not configure MPLCONFIGDIR: %s", exc)
|
|
|
|
|
|
# Bambu green color for rendering
|
|
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
|
|
|
|
# Minimum STL file size that could possibly contain a usable mesh:
|
|
# - Binary STL with one triangle: 80B header + 4B count + 50B triangle = 134B
|
|
# - ASCII STL with one triangle: header + "facet ... endfacet" + footer ≈ 150B
|
|
# Files below this are stubs / placeholders / corrupted; trimesh would return an
|
|
# empty mesh anyway. Pre-skipping at the call sites suppresses the warning storm
|
|
# bulk-uploaded ZIPs of small test STLs used to produce.
|
|
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,
|
|
size: int = 256,
|
|
) -> str | None:
|
|
"""Generate a thumbnail image from an STL file.
|
|
|
|
Args:
|
|
stl_path: Path to the STL file
|
|
thumbnails_dir: Directory to save the thumbnail
|
|
size: Thumbnail size in pixels (default 256x256)
|
|
|
|
Returns:
|
|
Path to the generated thumbnail, or None on failure
|
|
"""
|
|
# Callers historically pass either Path or str; coerce so the `thumbnails_dir
|
|
# / thumb_filename` join at the end of this function can't fail with the
|
|
# str-divided-by-str TypeError (see #1299).
|
|
stl_path = Path(stl_path)
|
|
thumbnails_dir = Path(thumbnails_dir)
|
|
|
|
try:
|
|
# Must precede the matplotlib import — MPLCONFIGDIR is read at
|
|
# matplotlib import time, not on subsequent attribute access.
|
|
_configure_matplotlib_cache()
|
|
|
|
import matplotlib
|
|
import trimesh
|
|
|
|
# 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
|
|
|
|
# Load the STL file
|
|
mesh = trimesh.load(str(stl_path), force="mesh")
|
|
|
|
if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
|
|
# Demoted from warning to debug: this is a per-file content
|
|
# observation (the STL is empty / stub / corrupted), not an
|
|
# actionable error. The caller proceeds correctly with no
|
|
# thumbnail. The call sites also pre-skip files below
|
|
# MIN_USABLE_STL_BYTES so the common stub-STL case never gets
|
|
# this far — this branch now catches only the rare "large
|
|
# enough but trimesh still can't parse it" case.
|
|
logger.debug("Failed to load STL or empty mesh: %s", stl_path)
|
|
return None
|
|
|
|
# Simplify large meshes for performance
|
|
if len(mesh.vertices) > MAX_VERTICES:
|
|
logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
|
|
try:
|
|
# Calculate reduction ratio (0-1 range)
|
|
# e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
|
|
keep_ratio = MAX_VERTICES / len(mesh.vertices)
|
|
target_reduction = 1.0 - keep_ratio
|
|
# Clamp to valid range (0.01 to 0.99)
|
|
target_reduction = max(0.01, min(0.99, target_reduction))
|
|
mesh = mesh.simplify_quadric_decimation(target_reduction)
|
|
logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
|
|
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)
|
|
bounds_max = vertices.max(axis=0)
|
|
center = (bounds_min + bounds_max) / 2
|
|
vertices_centered = vertices - center
|
|
|
|
# Scale to fit in view
|
|
max_extent = (bounds_max - bounds_min).max()
|
|
if max_extent > 0:
|
|
scale = 1.0 / max_extent
|
|
vertices_scaled = vertices_centered * scale
|
|
else:
|
|
vertices_scaled = vertices_centered
|
|
|
|
# Create figure with dark background
|
|
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)
|
|
|
|
# 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[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)
|
|
|
|
# Set axis limits
|
|
ax.set_xlim(-0.6, 0.6)
|
|
ax.set_ylim(-0.6, 0.6)
|
|
ax.set_zlim(-0.6, 0.6)
|
|
|
|
# Set view angle (isometric-ish)
|
|
ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
|
|
|
|
# Remove axes and grid
|
|
ax.set_axis_off()
|
|
ax.grid(False)
|
|
|
|
# Remove margins
|
|
plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
|
|
|
# Save thumbnail
|
|
thumb_filename = f"{uuid.uuid4().hex}.png"
|
|
thumb_path = thumbnails_dir / thumb_filename # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
|
|
|
|
fig.savefig(
|
|
thumb_path,
|
|
format="png",
|
|
facecolor=BACKGROUND_COLOR,
|
|
edgecolor="none",
|
|
bbox_inches="tight",
|
|
pad_inches=0.05,
|
|
dpi=100,
|
|
)
|
|
plt.close(fig)
|
|
|
|
logger.info("Generated STL thumbnail: %s", thumb_path)
|
|
return str(thumb_path)
|
|
|
|
except ImportError as e:
|
|
logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
|
|
return None
|
|
except Exception as e:
|
|
# Log the traceback, not just the message: a bare
|
|
# "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
|
|
# which line failed, and the fault is data-/environment-specific
|
|
# enough that it can't be reproduced from a clean STL — the traceback
|
|
# in the next support bundle is what pinpoints it (#1480).
|
|
logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
|
|
return None
|