stl_thumbnail.py 15 KB

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