stl_thumbnail.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. """STL Thumbnail Generation Service.
  2. Generates thumbnail images from STL files using trimesh and matplotlib.
  3. """
  4. import logging
  5. import os
  6. import uuid
  7. from pathlib import Path
  8. logger = logging.getLogger(__name__)
  9. # Matplotlib's font_manager emits one INFO line per font on first import
  10. # while it builds its cache, including a noisy "Failed to extract font
  11. # properties from NotoColorEmoji.ttf" for the COLR/COLR1 emoji format it
  12. # doesn't support. These are not actionable — demote to WARNING so real
  13. # font issues still surface but the first STL upload doesn't produce a
  14. # multi-line matplotlib preamble in the journal.
  15. logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
  16. def _configure_matplotlib_cache() -> None:
  17. """Point matplotlib's config/cache directory at a writable persistent path.
  18. Without this, matplotlib falls back to ``/tmp/matplotlib-XXXXXX`` whenever
  19. ``$HOME/.config/matplotlib`` isn't writable — which is the case under
  20. Bambuddy's container / systemd-service deployments where ``$HOME`` is set
  21. to a non-writable path. The fallback emits a WARNING on every cold start
  22. AND loses the font cache on host reboot, so font_manager rebuilds it
  23. every time → another batch of INFO lines.
  24. Setting ``MPLCONFIGDIR`` to ``settings.base_dir / .cache / matplotlib``
  25. eliminates both: the warning never fires, and the cache survives across
  26. restarts so the per-font scan only runs once per deployment.
  27. Idempotent — respects an externally-set ``MPLCONFIGDIR`` if the operator
  28. chose their own path.
  29. """
  30. if os.environ.get("MPLCONFIGDIR"):
  31. return
  32. try:
  33. from backend.app.core.config import settings
  34. cache_dir = Path(settings.base_dir) / ".cache" / "matplotlib"
  35. cache_dir.mkdir(parents=True, exist_ok=True)
  36. os.environ["MPLCONFIGDIR"] = str(cache_dir)
  37. except Exception as exc:
  38. # Best-effort. If settings isn't importable or the mkdir fails (read-only
  39. # FS, permission denied), let matplotlib fall back to /tmp with its
  40. # built-in warning — same as today's behaviour, no worse.
  41. logger.debug("Could not configure MPLCONFIGDIR: %s", exc)
  42. # Bambu green color for rendering
  43. BAMBU_GREEN = "#00AE42"
  44. BACKGROUND_COLOR = "#1a1a1a"
  45. # Maximum vertices before simplification
  46. MAX_VERTICES = 100000
  47. # Minimum STL file size that could possibly contain a usable mesh:
  48. # - Binary STL with one triangle: 80B header + 4B count + 50B triangle = 134B
  49. # - ASCII STL with one triangle: header + "facet ... endfacet" + footer ≈ 150B
  50. # Files below this are stubs / placeholders / corrupted; trimesh would return an
  51. # empty mesh anyway. Pre-skipping at the call sites suppresses the warning storm
  52. # bulk-uploaded ZIPs of small test STLs used to produce.
  53. MIN_USABLE_STL_BYTES = 200
  54. def generate_stl_thumbnail(
  55. stl_path: Path,
  56. thumbnails_dir: Path,
  57. size: int = 256,
  58. ) -> str | None:
  59. """Generate a thumbnail image from an STL file.
  60. Args:
  61. stl_path: Path to the STL file
  62. thumbnails_dir: Directory to save the thumbnail
  63. size: Thumbnail size in pixels (default 256x256)
  64. Returns:
  65. Path to the generated thumbnail, or None on failure
  66. """
  67. # Callers historically pass either Path or str; coerce so the `thumbnails_dir
  68. # / thumb_filename` join at the end of this function can't fail with the
  69. # str-divided-by-str TypeError (see #1299).
  70. stl_path = Path(stl_path)
  71. thumbnails_dir = Path(thumbnails_dir)
  72. try:
  73. # Must precede the matplotlib import — MPLCONFIGDIR is read at
  74. # matplotlib import time, not on subsequent attribute access.
  75. _configure_matplotlib_cache()
  76. import matplotlib
  77. import trimesh
  78. # Use Agg backend for headless rendering
  79. matplotlib.use("Agg")
  80. import matplotlib.pyplot as plt
  81. from mpl_toolkits.mplot3d import Axes3D # noqa: F401
  82. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  83. # Load the STL file
  84. mesh = trimesh.load(str(stl_path), force="mesh")
  85. if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
  86. # Demoted from warning to debug: this is a per-file content
  87. # observation (the STL is empty / stub / corrupted), not an
  88. # actionable error. The caller proceeds correctly with no
  89. # thumbnail. The call sites also pre-skip files below
  90. # MIN_USABLE_STL_BYTES so the common stub-STL case never gets
  91. # this far — this branch now catches only the rare "large
  92. # enough but trimesh still can't parse it" case.
  93. logger.debug("Failed to load STL or empty mesh: %s", stl_path)
  94. return None
  95. # Simplify large meshes for performance
  96. if len(mesh.vertices) > MAX_VERTICES:
  97. logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
  98. try:
  99. # Calculate reduction ratio (0-1 range)
  100. # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
  101. keep_ratio = MAX_VERTICES / len(mesh.vertices)
  102. target_reduction = 1.0 - keep_ratio
  103. # Clamp to valid range (0.01 to 0.99)
  104. target_reduction = max(0.01, min(0.99, target_reduction))
  105. mesh = mesh.simplify_quadric_decimation(target_reduction)
  106. logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
  107. except Exception as e:
  108. logger.warning("Mesh simplification failed, using original: %s", e)
  109. # Get mesh bounds and center it
  110. vertices = mesh.vertices
  111. bounds_min = vertices.min(axis=0)
  112. bounds_max = vertices.max(axis=0)
  113. center = (bounds_min + bounds_max) / 2
  114. vertices_centered = vertices - center
  115. # Scale to fit in view
  116. max_extent = (bounds_max - bounds_min).max()
  117. if max_extent > 0:
  118. scale = 1.0 / max_extent
  119. vertices_scaled = vertices_centered * scale
  120. else:
  121. vertices_scaled = vertices_centered
  122. # Create figure with dark background
  123. fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
  124. fig.patch.set_facecolor(BACKGROUND_COLOR)
  125. ax = fig.add_subplot(111, projection="3d")
  126. ax.set_facecolor(BACKGROUND_COLOR)
  127. # Create polygon collection from mesh faces
  128. faces = mesh.faces
  129. poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]
  130. collection = Poly3DCollection(
  131. poly3d,
  132. facecolors=BAMBU_GREEN,
  133. edgecolors=BAMBU_GREEN,
  134. linewidths=0.1,
  135. alpha=0.9,
  136. )
  137. ax.add_collection3d(collection)
  138. # Set axis limits
  139. ax.set_xlim(-0.6, 0.6)
  140. ax.set_ylim(-0.6, 0.6)
  141. ax.set_zlim(-0.6, 0.6)
  142. # Set view angle (isometric-ish)
  143. ax.view_init(elev=25, azim=45)
  144. # Remove axes and grid
  145. ax.set_axis_off()
  146. ax.grid(False)
  147. # Remove margins
  148. plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
  149. # Save thumbnail
  150. thumb_filename = f"{uuid.uuid4().hex}.png"
  151. thumb_path = thumbnails_dir / thumb_filename # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
  152. fig.savefig(
  153. thumb_path,
  154. format="png",
  155. facecolor=BACKGROUND_COLOR,
  156. edgecolor="none",
  157. bbox_inches="tight",
  158. pad_inches=0.05,
  159. dpi=100,
  160. )
  161. plt.close(fig)
  162. logger.info("Generated STL thumbnail: %s", thumb_path)
  163. return str(thumb_path)
  164. except ImportError as e:
  165. logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
  166. return None
  167. except Exception as e:
  168. # Log the traceback, not just the message: a bare
  169. # "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
  170. # which line failed, and the fault is data-/environment-specific
  171. # enough that it can't be reproduced from a clean STL — the traceback
  172. # in the next support bundle is what pinpoints it (#1480).
  173. logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
  174. return None