"""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" # 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 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 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) # 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 faces = mesh.faces poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces] collection = Poly3DCollection( poly3d, facecolors=BAMBU_GREEN, edgecolors=BAMBU_GREEN, linewidths=0.1, alpha=0.9, ) 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=25, azim=45) # 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