stl_thumbnail.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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 generate_stl_thumbnail(
  149. stl_path: Path,
  150. thumbnails_dir: Path,
  151. size: int = 256,
  152. ) -> str | None:
  153. """Generate a thumbnail image from an STL file.
  154. Args:
  155. stl_path: Path to the STL file
  156. thumbnails_dir: Directory to save the thumbnail
  157. size: Thumbnail size in pixels (default 256x256)
  158. Returns:
  159. Path to the generated thumbnail, or None on failure
  160. """
  161. # Callers historically pass either Path or str; coerce so the `thumbnails_dir
  162. # / thumb_filename` join at the end of this function can't fail with the
  163. # str-divided-by-str TypeError (see #1299).
  164. stl_path = Path(stl_path)
  165. thumbnails_dir = Path(thumbnails_dir)
  166. try:
  167. # Must precede the matplotlib import — MPLCONFIGDIR is read at
  168. # matplotlib import time, not on subsequent attribute access.
  169. _configure_matplotlib_cache()
  170. import matplotlib
  171. import trimesh
  172. # Use Agg backend for headless rendering
  173. matplotlib.use("Agg")
  174. import matplotlib.pyplot as plt
  175. from matplotlib.colors import LightSource
  176. from mpl_toolkits.mplot3d import Axes3D # noqa: F401
  177. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  178. # Load the STL file
  179. mesh = trimesh.load(str(stl_path), force="mesh")
  180. if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
  181. # Demoted from warning to debug: this is a per-file content
  182. # observation (the STL is empty / stub / corrupted), not an
  183. # actionable error. The caller proceeds correctly with no
  184. # thumbnail. The call sites also pre-skip files below
  185. # MIN_USABLE_STL_BYTES so the common stub-STL case never gets
  186. # this far — this branch now catches only the rare "large
  187. # enough but trimesh still can't parse it" case.
  188. logger.debug("Failed to load STL or empty mesh: %s", stl_path)
  189. return None
  190. # Simplify large meshes for performance
  191. if len(mesh.vertices) > MAX_VERTICES:
  192. logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
  193. try:
  194. # Calculate reduction ratio (0-1 range)
  195. # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
  196. keep_ratio = MAX_VERTICES / len(mesh.vertices)
  197. target_reduction = 1.0 - keep_ratio
  198. # Clamp to valid range (0.01 to 0.99)
  199. target_reduction = max(0.01, min(0.99, target_reduction))
  200. mesh = mesh.simplify_quadric_decimation(target_reduction)
  201. logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
  202. except Exception as e:
  203. logger.warning("Mesh simplification failed, using original: %s", e)
  204. # Wind every face the same way, and outward, or the shading turns the
  205. # model into camouflage. See ``_repair_winding``; it must run before the
  206. # vertices below are read, since a future repair step could move them.
  207. try:
  208. _repair_winding(mesh, trimesh, str(stl_path))
  209. except Exception as e: # best-effort: a flat render beats no thumbnail
  210. logger.debug("Winding repair skipped (%s): %s", e, stl_path)
  211. # Get mesh bounds and center it
  212. vertices = mesh.vertices
  213. bounds_min = vertices.min(axis=0)
  214. bounds_max = vertices.max(axis=0)
  215. center = (bounds_min + bounds_max) / 2
  216. vertices_centered = vertices - center
  217. # Scale to fit in view
  218. max_extent = (bounds_max - bounds_min).max()
  219. if max_extent > 0:
  220. scale = 1.0 / max_extent
  221. vertices_scaled = vertices_centered * scale
  222. else:
  223. vertices_scaled = vertices_centered
  224. # Create figure with dark background
  225. fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
  226. fig.patch.set_facecolor(BACKGROUND_COLOR)
  227. ax = fig.add_subplot(111, projection="3d")
  228. ax.set_facecolor(BACKGROUND_COLOR)
  229. # Create polygon collection from mesh faces
  230. # Index with the face array rather than building a list of lists. Same
  231. # data, and Poly3DCollection accepts it directly — but shading walks this
  232. # structure to generate normals, and on an 82k-face mesh the list form
  233. # costs ~0.19s against ~0.007s for the ndarray. It speeds up the unshaded
  234. # path too.
  235. faces = mesh.faces
  236. poly3d = vertices_scaled[faces]
  237. # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
  238. # colours alongside the face colours, and an empty array (``"none"``)
  239. # makes it raise on the broadcast. Keep the two in step if either moves.
  240. collection = Poly3DCollection(
  241. poly3d,
  242. facecolors=BAMBU_GREEN,
  243. edgecolors=BAMBU_GREEN,
  244. linewidths=0.1,
  245. alpha=0.9,
  246. **_shade_kwargs(poly3d, LightSource),
  247. )
  248. ax.add_collection3d(collection)
  249. # Set axis limits
  250. ax.set_xlim(-0.6, 0.6)
  251. ax.set_ylim(-0.6, 0.6)
  252. ax.set_zlim(-0.6, 0.6)
  253. # Set view angle (isometric-ish)
  254. ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
  255. # Remove axes and grid
  256. ax.set_axis_off()
  257. ax.grid(False)
  258. # Remove margins
  259. plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
  260. # Save thumbnail
  261. thumb_filename = f"{uuid.uuid4().hex}.png"
  262. thumb_path = thumbnails_dir / thumb_filename # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
  263. fig.savefig(
  264. thumb_path,
  265. format="png",
  266. facecolor=BACKGROUND_COLOR,
  267. edgecolor="none",
  268. bbox_inches="tight",
  269. pad_inches=0.05,
  270. dpi=100,
  271. )
  272. plt.close(fig)
  273. logger.info("Generated STL thumbnail: %s", thumb_path)
  274. return str(thumb_path)
  275. except ImportError as e:
  276. logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
  277. return None
  278. except Exception as e:
  279. # Log the traceback, not just the message: a bare
  280. # "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
  281. # which line failed, and the fault is data-/environment-specific
  282. # enough that it can't be reproduced from a clean STL — the traceback
  283. # in the next support bundle is what pinpoints it (#1480).
  284. logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
  285. return None