"""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 ````. 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