stl_thumbnail.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  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 threading
  7. import uuid
  8. from pathlib import Path
  9. logger = logging.getLogger(__name__)
  10. # Matplotlib's font_manager emits one INFO line per font on first import
  11. # while it builds its cache, including a noisy "Failed to extract font
  12. # properties from NotoColorEmoji.ttf" for the COLR/COLR1 emoji format it
  13. # doesn't support. These are not actionable — demote to WARNING so real
  14. # font issues still surface but the first STL upload doesn't produce a
  15. # multi-line matplotlib preamble in the journal.
  16. logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
  17. def _configure_matplotlib_cache() -> None:
  18. """Point matplotlib's config/cache directory at a writable persistent path.
  19. Without this, matplotlib falls back to ``/tmp/matplotlib-XXXXXX`` whenever
  20. ``$HOME/.config/matplotlib`` isn't writable — which is the case under
  21. Bambuddy's container / systemd-service deployments where ``$HOME`` is set
  22. to a non-writable path. The fallback emits a WARNING on every cold start
  23. AND loses the font cache on host reboot, so font_manager rebuilds it
  24. every time → another batch of INFO lines.
  25. Setting ``MPLCONFIGDIR`` to ``settings.base_dir / .cache / matplotlib``
  26. eliminates both: the warning never fires, and the cache survives across
  27. restarts so the per-font scan only runs once per deployment.
  28. Idempotent — respects an externally-set ``MPLCONFIGDIR`` if the operator
  29. chose their own path.
  30. """
  31. if os.environ.get("MPLCONFIGDIR"):
  32. return
  33. try:
  34. from backend.app.core.config import settings
  35. cache_dir = Path(settings.base_dir) / ".cache" / "matplotlib"
  36. cache_dir.mkdir(parents=True, exist_ok=True)
  37. os.environ["MPLCONFIGDIR"] = str(cache_dir)
  38. except Exception as exc:
  39. # Best-effort. If settings isn't importable or the mkdir fails (read-only
  40. # FS, permission denied), let matplotlib fall back to /tmp with its
  41. # built-in warning — same as today's behaviour, no worse.
  42. logger.debug("Could not configure MPLCONFIGDIR: %s", exc)
  43. # Bambu green color for rendering
  44. BAMBU_GREEN = "#00AE42"
  45. BACKGROUND_COLOR = "#1a1a1a"
  46. # Direction of the synthetic light used to shade the mesh. Without a light
  47. # source ``Poly3DCollection`` fills every triangle with the identical colour
  48. # regardless of its normal, so the render comes out a flat silhouette and one
  49. # model is indistinguishable from another (issue #2816).
  50. #
  51. # The azimuth is NOT free. matplotlib's light direction for (az, alt) is
  52. # ``[cos(90-az)cos(alt), sin(90-az)cos(alt), sin(alt)]``, and the camera set by
  53. # ``view_init(elev, azim)`` sits at ``[cos(elev)cos(azim), cos(elev)sin(azim),
  54. # sin(elev)]``. The dot product of the two must be POSITIVE or the light is
  55. # behind the model: at 225 it is -0.34, which lights the two hidden faces and
  56. # gives both visible ones the identical 0.475 — a cube with no contrast down its
  57. # front edge. At 315 it is +0.30, and the two visible sides come out 0.825 and
  58. # 0.475. ``test_light_is_on_the_camera_side`` holds that invariant so the pair
  59. # cannot drift apart again.
  60. LIGHT_AZIMUTH_DEG = 315
  61. LIGHT_ALTITUDE_DEG = 45
  62. # The camera the light above is chosen against. Named because the two are a PAIR:
  63. # move one without the other and the model goes back to being lit from behind.
  64. VIEW_ELEV_DEG = 25
  65. VIEW_AZIM_DEG = 45
  66. # Maximum vertices before simplification
  67. MAX_VERTICES = 100000
  68. # Minimum STL file size that could possibly contain a usable mesh:
  69. # - Binary STL with one triangle: 80B header + 4B count + 50B triangle = 134B
  70. # - ASCII STL with one triangle: header + "facet ... endfacet" + footer ≈ 150B
  71. # Files below this are stubs / placeholders / corrupted; trimesh would return an
  72. # empty mesh anyway. Pre-skipping at the call sites suppresses the warning storm
  73. # bulk-uploaded ZIPs of small test STLs used to produce.
  74. MIN_USABLE_STL_BYTES = 200
  75. def _repair_winding(mesh, trimesh, label: str) -> None:
  76. """Make every face wind the same way, and wind it OUTWARD, before shading.
  77. matplotlib derives its normals from vertex ORDER, so a triangle wound the
  78. wrong way shades as though it faced away and the model comes out patchy —
  79. camouflage rather than a surface. Unshaded this never showed, so lighting the
  80. render is what makes it matter, and the File Manager takes arbitrary user
  81. STLs. ``trimesh.load(force="mesh")`` does not repair winding; this does.
  82. ``trimesh.repair.fix_winding`` and NOT ``mesh.fix_normals()``: the latter
  83. reaches ``body_count`` -> ``scipy.csgraph``, and scipy is not a dependency of
  84. this project. fix_winding goes through networkx, which requirements.txt
  85. already pins.
  86. Three steps, because each one leaves something for the next:
  87. * ``fix_winding`` makes the winding agree but is free to settle on either
  88. orientation, and on a half-inverted sphere it picks INWARD — consistent,
  89. and consistently lit from inside.
  90. * ``fix_inversion`` corrects that off the sign of the volume, but only for a
  91. WATERTIGHT mesh. It returns early otherwise, because a volume measured
  92. across holes says nothing about which way is out.
  93. * Which leaves the common case, since a mesh with broken winding is usually
  94. not watertight either. With no usable volume, decide by whether the faces
  95. point away from the centroid. Measured on a punctured half-inverted
  96. icosphere: the first two steps alone left 0 of 1200 faces oriented like the
  97. correctly wound mesh, a mean render delta of 4.56; with this one it is
  98. 1200 of 1200 and 0.00.
  99. The centroid test runs only on a mesh whose winding was already broken, and
  100. it leaves correct ones alone: closed and punctured spheres, a flat plate, an
  101. open tube, a non-convex L and two disjoint boxes all sum positive.
  102. Gated here rather than at the call sites so the two renderers cannot drift.
  103. The check is tens of ms where the repair is seconds on a large mesh, so only
  104. meshes that would otherwise render wrong pay for it.
  105. """
  106. import numpy as np
  107. if len(mesh.faces) == 0 or mesh.is_winding_consistent:
  108. return
  109. logger.debug("Repairing inconsistent winding before render: %s", label)
  110. trimesh.repair.fix_winding(mesh)
  111. trimesh.repair.fix_inversion(mesh)
  112. if mesh.is_watertight:
  113. return
  114. outward = mesh.triangles.mean(axis=1) - mesh.vertices.mean(axis=0)
  115. if float(np.einsum("ij,ij->i", mesh.face_normals, outward).sum()) < 0:
  116. logger.debug("Winding settled inward on a non-watertight mesh, inverting: %s", label)
  117. mesh.invert()
  118. def _shade_kwargs(poly3d, LightSource) -> dict:
  119. """``shade=True`` and its light, or nothing when the mesh cannot be shaded.
  120. matplotlib's ``_shade_colors`` has a fallback for a mesh whose every face
  121. normal is degenerate, and that fallback returns the colour argument it was
  122. given, unchanged. Passing a colour STRING — which both renderers do — makes
  123. it hand back a 0-d ``<U7`` array, and ``to_rgba_array`` then calls ``len()``
  124. on it and raises ``TypeError: len() of unsized object``.
  125. So a file whose facets are all zero-area or collinear rendered fine while the
  126. output was flat, and would fail outright once lit. That population is real:
  127. stub and truncated STLs, and hand-written 3MFs with an empty ``<triangles/>``.
  128. Worse, ``batch_generate_stl_thumbnails`` walks a whole folder with no
  129. minimum-size pre-skip, so each one would count as a failure in the UI and put
  130. a traceback in the log — the exact noise ``stl_thumbnail``'s demoted logging
  131. exists to keep out.
  132. Deciding here rather than catching the TypeError keeps the flat render as a
  133. real outcome instead of an error path, and costs ~6 ms on a 227k-face mesh.
  134. Identical to matplotlib's own test: a cross product that is finite and
  135. non-zero for at least one face.
  136. """
  137. import numpy as np
  138. if len(poly3d) == 0:
  139. return {}
  140. tri = np.asarray(poly3d, dtype=float)
  141. normals = np.cross(tri[:, 0] - tri[:, 1], tri[:, 1] - tri[:, 2])
  142. lengths = np.linalg.norm(normals, axis=1)
  143. if not bool(np.any(np.isfinite(lengths) & (lengths > 0))):
  144. return {}
  145. return {
  146. "shade": True,
  147. "lightsource": LightSource(azdeg=LIGHT_AZIMUTH_DEG, altdeg=LIGHT_ALTITUDE_DEG),
  148. }
  149. def _import_renderer():
  150. """Import matplotlib (headless) and the mplot3d pieces the renderer uses."""
  151. # Must precede the matplotlib import: MPLCONFIGDIR is read at
  152. # matplotlib import time, not on subsequent attribute access.
  153. _configure_matplotlib_cache()
  154. import matplotlib
  155. # Use Agg backend for headless rendering
  156. matplotlib.use("Agg")
  157. import matplotlib.pyplot as plt
  158. from matplotlib.colors import LightSource
  159. from mpl_toolkits.mplot3d import Axes3D # noqa: F401
  160. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  161. return plt, LightSource, Poly3DCollection
  162. # One thumbnail at a time. Callers run in worker threads (asyncio.to_thread) so
  163. # a slow mesh never blocks the event loop. pyplot's current-figure state is
  164. # global and not thread-safe, so two renders at once could draw into each
  165. # other's figure; and one load at a time keeps several large meshes from being
  166. # held in memory together. Re-entrant: generate_stl_thumbnail holds it around
  167. # the load and render_mesh_png takes it again.
  168. _RENDER_LOCK = threading.RLock()
  169. def render_mesh_png(mesh, size: int = 256, label: str = "mesh") -> bytes:
  170. """Render ``mesh`` to PNG bytes; see ``_render_mesh_png``. One at a time."""
  171. with _RENDER_LOCK:
  172. return _render_mesh_png(mesh, size=size, label=label)
  173. def _render_mesh_png(mesh, size: int = 256, label: str = "mesh") -> bytes:
  174. """Render ``mesh`` (a ``trimesh.Trimesh``) to PNG bytes.
  175. The caller owns the face budget: this draws every face it is given, so a
  176. large mesh has to be simplified first (see ``generate_stl_thumbnail`` and
  177. ``mesh_combine``). Raises on failure; callers decide whether a missing
  178. preview is an error.
  179. """
  180. import io
  181. import trimesh
  182. plt, LightSource, Poly3DCollection = _import_renderer()
  183. # Wind every face the same way, and outward, or the shading turns the
  184. # model into camouflage. See ``_repair_winding``; it must run before the
  185. # vertices below are read, since a future repair step could move them.
  186. try:
  187. _repair_winding(mesh, trimesh, label)
  188. except Exception as e: # best-effort: a flat render beats no thumbnail
  189. logger.debug("Winding repair skipped (%s): %s", e, label)
  190. # Get mesh bounds and center it
  191. vertices = mesh.vertices
  192. bounds_min = vertices.min(axis=0)
  193. bounds_max = vertices.max(axis=0)
  194. center = (bounds_min + bounds_max) / 2
  195. vertices_centered = vertices - center
  196. # Scale to fit in view
  197. max_extent = (bounds_max - bounds_min).max()
  198. if max_extent > 0:
  199. scale = 1.0 / max_extent
  200. vertices_scaled = vertices_centered * scale
  201. else:
  202. vertices_scaled = vertices_centered
  203. # Create figure with dark background
  204. fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
  205. try:
  206. fig.patch.set_facecolor(BACKGROUND_COLOR)
  207. ax = fig.add_subplot(111, projection="3d")
  208. ax.set_facecolor(BACKGROUND_COLOR)
  209. # Create polygon collection from mesh faces
  210. # Index with the face array rather than building a list of lists. Same
  211. # data, and Poly3DCollection accepts it directly, but shading walks this
  212. # structure to generate normals, and on an 82k-face mesh the list form
  213. # costs ~0.19s against ~0.007s for the ndarray. It speeds up the unshaded
  214. # path too.
  215. faces = mesh.faces
  216. poly3d = vertices_scaled[faces]
  217. # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
  218. # colours alongside the face colours, and an empty array (``"none"``)
  219. # makes it raise on the broadcast. Keep the two in step if either moves.
  220. collection = Poly3DCollection(
  221. poly3d,
  222. facecolors=BAMBU_GREEN,
  223. edgecolors=BAMBU_GREEN,
  224. linewidths=0.1,
  225. alpha=0.9,
  226. **_shade_kwargs(poly3d, LightSource),
  227. )
  228. ax.add_collection3d(collection)
  229. # Set axis limits
  230. ax.set_xlim(-0.6, 0.6)
  231. ax.set_ylim(-0.6, 0.6)
  232. ax.set_zlim(-0.6, 0.6)
  233. # Set view angle (isometric-ish)
  234. ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
  235. # Remove axes and grid
  236. ax.set_axis_off()
  237. ax.grid(False)
  238. # Remove margins
  239. plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
  240. buf = io.BytesIO()
  241. fig.savefig(
  242. buf,
  243. format="png",
  244. facecolor=BACKGROUND_COLOR,
  245. edgecolor="none",
  246. bbox_inches="tight",
  247. pad_inches=0.05,
  248. dpi=100,
  249. )
  250. return buf.getvalue()
  251. finally:
  252. plt.close(fig)
  253. def generate_stl_thumbnail(
  254. stl_path: Path,
  255. thumbnails_dir: Path,
  256. size: int = 256,
  257. ) -> str | None:
  258. """Generate a thumbnail image from an STL file; see ``_generate_stl_thumbnail``.
  259. One at a time (``_RENDER_LOCK``). Blocking: call it in a worker thread.
  260. """
  261. with _RENDER_LOCK:
  262. return _generate_stl_thumbnail(stl_path, thumbnails_dir, size)
  263. def _generate_stl_thumbnail(
  264. stl_path: Path,
  265. thumbnails_dir: Path,
  266. size: int = 256,
  267. ) -> str | None:
  268. """Generate a thumbnail image from an STL file.
  269. Args:
  270. stl_path: Path to the STL file
  271. thumbnails_dir: Directory to save the thumbnail
  272. size: Thumbnail size in pixels (default 256x256)
  273. Returns:
  274. Path to the generated thumbnail, or None on failure
  275. """
  276. # Callers historically pass either Path or str; coerce so the `thumbnails_dir
  277. # / thumb_filename` join at the end of this function can't fail with the
  278. # str-divided-by-str TypeError (see #1299).
  279. stl_path = Path(stl_path)
  280. thumbnails_dir = Path(thumbnails_dir)
  281. try:
  282. _import_renderer()
  283. import trimesh
  284. # Load the STL file
  285. mesh = trimesh.load(str(stl_path), force="mesh")
  286. if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
  287. # Demoted from warning to debug: this is a per-file content
  288. # observation (the STL is empty / stub / corrupted), not an
  289. # actionable error. The caller proceeds correctly with no
  290. # thumbnail. The call sites also pre-skip files below
  291. # MIN_USABLE_STL_BYTES so the common stub-STL case never gets
  292. # this far; this branch now catches only the rare "large
  293. # enough but trimesh still can't parse it" case.
  294. logger.debug("Failed to load STL or empty mesh: %s", stl_path)
  295. return None
  296. # Simplify large meshes for performance
  297. if len(mesh.vertices) > MAX_VERTICES:
  298. logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
  299. try:
  300. # Calculate reduction ratio (0-1 range)
  301. # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
  302. keep_ratio = MAX_VERTICES / len(mesh.vertices)
  303. target_reduction = 1.0 - keep_ratio
  304. # Clamp to valid range (0.01 to 0.99)
  305. target_reduction = max(0.01, min(0.99, target_reduction))
  306. mesh = mesh.simplify_quadric_decimation(target_reduction)
  307. logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
  308. except Exception as e:
  309. logger.warning("Mesh simplification failed, using original: %s", e)
  310. png = render_mesh_png(mesh, size=size, label=str(stl_path))
  311. # Save thumbnail
  312. thumb_filename = f"{uuid.uuid4().hex}.png"
  313. thumb_path = thumbnails_dir / thumb_filename # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
  314. thumb_path.write_bytes(png)
  315. logger.info("Generated STL thumbnail: %s", thumb_path)
  316. return str(thumb_path)
  317. except ImportError as e:
  318. logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
  319. return None
  320. except Exception as e:
  321. # Log the traceback, not just the message: a bare
  322. # "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
  323. # which line failed, and the fault is data-/environment-specific
  324. # enough that it can't be reproduced from a clean STL; the traceback
  325. # in the next support bundle is what pinpoints it (#1480).
  326. logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
  327. return None