| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- """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
|