plate_thumbnail.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. """Plate thumbnail injection for sliced 3MFs.
  2. When the slicer CLI (Bambu Studio or OrcaSlicer in the docker sidecar)
  3. produces a ``.gcode.3mf`` without ``Metadata/plate_N.png``, the archive
  4. card has nothing to show. Both CLIs skip the plate-thumbnail render when
  5. invoked with ``--slice --export-3mf`` headlessly — that render is a
  6. GUI-side action that only fires in the desktop Studio. The
  7. ``--export-png`` flag exists but is mutually exclusive with
  8. ``--export-3mf`` and additionally needs a Wayland compositor in the
  9. container, so we can't reach it from the sidecar's current invocation
  10. shape.
  11. This module fills the gap server-side: it parses the sliced 3MF, and
  12. for every ``plate_N.gcode`` entry that doesn't have a matching
  13. ``plate_N.png`` it renders one from the embedded 3D model using the
  14. same trimesh + matplotlib path as :mod:`backend.app.services.stl_thumbnail`,
  15. then injects ``Metadata/plate_N.png`` (512x512) + ``Metadata/plate_N_small.png``
  16. (128x128) into the zip. Best-effort: any failure (no model file,
  17. trimesh can't parse, matplotlib render fails) returns the input bytes
  18. unchanged so the slice flow itself never breaks.
  19. """
  20. from __future__ import annotations
  21. import io
  22. import logging
  23. import re
  24. import zipfile
  25. logger = logging.getLogger(__name__)
  26. # Bambu Studio's plate covers. Match the dimensions BS uses on desktop so
  27. # the rendered images flow through the same archive UI code paths without
  28. # special-casing.
  29. _PLATE_PNG_SIZE = 512
  30. _PLATE_PNG_SMALL_SIZE = 128
  31. # Mirror stl_thumbnail.py's palette so archive cards rendered through
  32. # this path are visually consistent with the rest of Bambuddy's library
  33. # thumbnails — same Bambu green on the same dark background.
  34. _BAMBU_GREEN = "#00AE42"
  35. _BACKGROUND_COLOR = "#1a1a1a"
  36. # Above this vertex count, trimesh.simplify_quadric_decimation runs first.
  37. # Same cap stl_thumbnail.py uses; matplotlib's Poly3DCollection slows down
  38. # nonlinearly past ~100k faces and a plate thumbnail doesn't need detail
  39. # beyond what a 512x512 PNG can resolve.
  40. _MAX_VERTICES = 100_000
  41. # Plate-gcode entries look like ``Metadata/plate_1.gcode``,
  42. # ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
  43. _PLATE_GCODE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
  44. def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
  45. """Return ``threemf_bytes`` with ``plate_N.png`` injected for every
  46. plate that's missing one.
  47. No-op fast path when every plate already has a thumbnail — the input
  48. bytes are returned verbatim (same object identity), so the common
  49. case of a desktop-Studio-sliced 3MF flowing through this function
  50. is essentially free.
  51. On any failure the input bytes are returned unchanged. A missing
  52. thumbnail is a visual degradation; failing the slice would be worse.
  53. """
  54. try:
  55. with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
  56. names = set(zf.namelist())
  57. missing = _missing_plate_ids(names)
  58. if not missing:
  59. return threemf_bytes
  60. if "3D/3dmodel.model" not in names:
  61. logger.debug(
  62. "plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
  63. sorted(missing),
  64. )
  65. return threemf_bytes
  66. except (zipfile.BadZipFile, OSError) as exc:
  67. logger.warning("plate_thumbnail: input is not a readable zip: %s", exc)
  68. return threemf_bytes
  69. try:
  70. large_png, small_png = _render_model_thumbnails(threemf_bytes)
  71. except Exception as exc:
  72. logger.warning(
  73. "plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
  74. exc,
  75. exc_info=True,
  76. )
  77. return threemf_bytes
  78. if large_png is None or small_png is None:
  79. return threemf_bytes
  80. try:
  81. return _inject_pngs(threemf_bytes, missing, large_png, small_png)
  82. except (zipfile.BadZipFile, OSError) as exc:
  83. logger.warning("plate_thumbnail: zip re-pack failed: %s", exc)
  84. return threemf_bytes
  85. def _missing_plate_ids(names: set[str]) -> list[int]:
  86. """Plate IDs that have a ``plate_N.gcode`` but no ``plate_N.png``.
  87. Multi-plate slices produce one gcode per plate; we render the model
  88. once and reuse it for every missing plate. The visual is identical
  89. across plates of the same model, which matches what users see today
  90. for desktop-Studio-sliced multi-plate projects — Studio also reuses
  91. the model render across plates that share geometry.
  92. """
  93. plate_ids: list[int] = []
  94. for name in names:
  95. m = _PLATE_GCODE_RE.match(name)
  96. if not m:
  97. continue
  98. n = int(m.group(1))
  99. if f"Metadata/plate_{n}.png" not in names:
  100. plate_ids.append(n)
  101. return sorted(plate_ids)
  102. def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes | None]:
  103. """Render an isometric view of the 3MF's model at both plate sizes.
  104. Returns (large, small) PNG bytes, or (None, None) if the model
  105. couldn't be loaded. Mirrors stl_thumbnail.py's style (Bambu green
  106. mesh on dark background, ~25deg elev / 45deg azim) so this output
  107. blends into Bambuddy's existing library/archive cards.
  108. """
  109. # Local imports so a `import backend.app.services.plate_thumbnail` from
  110. # an environment without matplotlib/trimesh doesn't fail at import time —
  111. # the function will simply degrade to no-op via the exception branch.
  112. #
  113. # The light angle is IMPORTED rather than mirrored like the palette above.
  114. # "A plate card and a library thumbnail of the same model look alike" is the
  115. # whole reason these two renderers share a look, and a second copy of the
  116. # angle is exactly how that silently stops being true. A palette can afford a
  117. # copy; a number nobody would notice drifting cannot.
  118. from backend.app.services.stl_thumbnail import (
  119. _configure_matplotlib_cache,
  120. _repair_winding,
  121. _shade_kwargs,
  122. )
  123. _configure_matplotlib_cache()
  124. import matplotlib
  125. matplotlib.use("Agg")
  126. import matplotlib.pyplot as plt
  127. import trimesh
  128. from matplotlib.colors import LightSource
  129. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  130. loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
  131. if loaded is None or not hasattr(loaded, "vertices") or len(loaded.vertices) == 0:
  132. logger.debug("plate_thumbnail: trimesh produced empty mesh from 3MF")
  133. return None, None
  134. mesh = loaded
  135. if len(mesh.vertices) > _MAX_VERTICES:
  136. try:
  137. keep_ratio = _MAX_VERTICES / len(mesh.vertices)
  138. target_reduction = max(0.01, min(0.99, 1.0 - keep_ratio))
  139. mesh = mesh.simplify_quadric_decimation(target_reduction)
  140. except Exception as exc:
  141. logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
  142. # Before the vertices are read, not after: ``scaled`` below is indexed by
  143. # ``mesh.faces``, so a repair that ever moves a vertex would leave the two
  144. # out of step. Shared with stl_thumbnail rather than copied — the reason
  145. # these renderers agree is that they run the same code, not similar code.
  146. try:
  147. _repair_winding(mesh, trimesh, "plate_thumbnail")
  148. except Exception as e: # best-effort, as the whole module is
  149. logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
  150. vertices = mesh.vertices
  151. bounds_min = vertices.min(axis=0)
  152. bounds_max = vertices.max(axis=0)
  153. centered = vertices - (bounds_min + bounds_max) / 2
  154. max_extent = (bounds_max - bounds_min).max()
  155. scaled = centered / max_extent if max_extent > 0 else centered
  156. # ndarray, not a list of lists — shading walks this to build normals, and the
  157. # list form is ~30x slower to construct. Paid twice per plate: once per size.
  158. faces = mesh.faces
  159. poly3d = scaled[faces]
  160. # Resolved once and shared: both sizes must be lit identically or the 128px
  161. # card and the 512px view disagree. Empty for a mesh matplotlib cannot shade,
  162. # which keeps such a plate rendering flat instead of failing — see
  163. # ``_shade_kwargs``.
  164. shade_kw = _shade_kwargs(poly3d, LightSource)
  165. large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection, shade_kw)
  166. small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection, shade_kw)
  167. return large, small
  168. def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) -> bytes:
  169. """Render the prepared poly3d collection to an in-memory PNG."""
  170. # Local, like every other import in this module, so importing plate_thumbnail
  171. # in an environment without matplotlib still works. stl_thumbnail's own
  172. # module level is import-light, so this costs nothing after the first call.
  173. from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
  174. fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
  175. fig.patch.set_facecolor(_BACKGROUND_COLOR)
  176. ax = fig.add_subplot(111, projection="3d")
  177. ax.set_facecolor(_BACKGROUND_COLOR)
  178. # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
  179. # colours alongside the face colours, and an empty array (``"none"``) makes
  180. # it raise on the broadcast. Keep the two in step if either moves.
  181. ax.add_collection3d(
  182. Poly3DCollection(
  183. poly3d,
  184. facecolors=_BAMBU_GREEN,
  185. edgecolors=_BAMBU_GREEN,
  186. linewidths=0.1,
  187. alpha=0.9,
  188. **shade_kw,
  189. )
  190. )
  191. ax.set_xlim(-0.6, 0.6)
  192. ax.set_ylim(-0.6, 0.6)
  193. ax.set_zlim(-0.6, 0.6)
  194. ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
  195. ax.set_axis_off()
  196. ax.grid(False)
  197. plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
  198. buf = io.BytesIO()
  199. fig.savefig(
  200. buf,
  201. format="png",
  202. facecolor=_BACKGROUND_COLOR,
  203. edgecolor="none",
  204. bbox_inches="tight",
  205. pad_inches=0.05,
  206. dpi=100,
  207. )
  208. plt.close(fig)
  209. return buf.getvalue()
  210. def _inject_pngs(
  211. threemf_bytes: bytes,
  212. plate_ids: list[int],
  213. large_png: bytes,
  214. small_png: bytes,
  215. ) -> bytes:
  216. """Copy every entry from the input zip to a new one, then append the
  217. plate PNGs. Re-pack rather than mutate-in-place because zipfile doesn't
  218. support adding entries to an existing archive read from bytes."""
  219. out_buf = io.BytesIO()
  220. with (
  221. zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as src,
  222. zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as dst,
  223. ):
  224. for item in src.infolist():
  225. dst.writestr(item, src.read(item.filename))
  226. for n in plate_ids:
  227. dst.writestr(f"Metadata/plate_{n}.png", large_png)
  228. dst.writestr(f"Metadata/plate_{n}_small.png", small_png)
  229. return out_buf.getvalue()