plate_thumbnail.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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. from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
  113. _configure_matplotlib_cache()
  114. import matplotlib
  115. matplotlib.use("Agg")
  116. import matplotlib.pyplot as plt
  117. import trimesh
  118. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  119. loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
  120. if loaded is None or not hasattr(loaded, "vertices") or len(loaded.vertices) == 0:
  121. logger.debug("plate_thumbnail: trimesh produced empty mesh from 3MF")
  122. return None, None
  123. mesh = loaded
  124. if len(mesh.vertices) > _MAX_VERTICES:
  125. try:
  126. keep_ratio = _MAX_VERTICES / len(mesh.vertices)
  127. target_reduction = max(0.01, min(0.99, 1.0 - keep_ratio))
  128. mesh = mesh.simplify_quadric_decimation(target_reduction)
  129. except Exception as exc:
  130. logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
  131. vertices = mesh.vertices
  132. bounds_min = vertices.min(axis=0)
  133. bounds_max = vertices.max(axis=0)
  134. centered = vertices - (bounds_min + bounds_max) / 2
  135. max_extent = (bounds_max - bounds_min).max()
  136. scaled = centered / max_extent if max_extent > 0 else centered
  137. faces = mesh.faces
  138. poly3d = [[scaled[v] for v in face] for face in faces]
  139. large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection)
  140. small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection)
  141. return large, small
  142. def _render_at_size(poly3d, size: int, plt, Poly3DCollection) -> bytes:
  143. """Render the prepared poly3d collection to an in-memory PNG."""
  144. fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
  145. fig.patch.set_facecolor(_BACKGROUND_COLOR)
  146. ax = fig.add_subplot(111, projection="3d")
  147. ax.set_facecolor(_BACKGROUND_COLOR)
  148. ax.add_collection3d(
  149. Poly3DCollection(
  150. poly3d,
  151. facecolors=_BAMBU_GREEN,
  152. edgecolors=_BAMBU_GREEN,
  153. linewidths=0.1,
  154. alpha=0.9,
  155. )
  156. )
  157. ax.set_xlim(-0.6, 0.6)
  158. ax.set_ylim(-0.6, 0.6)
  159. ax.set_zlim(-0.6, 0.6)
  160. ax.view_init(elev=25, azim=45)
  161. ax.set_axis_off()
  162. ax.grid(False)
  163. plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
  164. buf = io.BytesIO()
  165. fig.savefig(
  166. buf,
  167. format="png",
  168. facecolor=_BACKGROUND_COLOR,
  169. edgecolor="none",
  170. bbox_inches="tight",
  171. pad_inches=0.05,
  172. dpi=100,
  173. )
  174. plt.close(fig)
  175. return buf.getvalue()
  176. def _inject_pngs(
  177. threemf_bytes: bytes,
  178. plate_ids: list[int],
  179. large_png: bytes,
  180. small_png: bytes,
  181. ) -> bytes:
  182. """Copy every entry from the input zip to a new one, then append the
  183. plate PNGs. Re-pack rather than mutate-in-place because zipfile doesn't
  184. support adding entries to an existing archive read from bytes."""
  185. out_buf = io.BytesIO()
  186. with (
  187. zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as src,
  188. zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as dst,
  189. ):
  190. for item in src.infolist():
  191. dst.writestr(item, src.read(item.filename))
  192. for n in plate_ids:
  193. dst.writestr(f"Metadata/plate_{n}.png", large_png)
  194. dst.writestr(f"Metadata/plate_{n}_small.png", small_png)
  195. return out_buf.getvalue()