|
|
@@ -0,0 +1,231 @@
|
|
|
+"""Plate thumbnail injection for sliced 3MFs.
|
|
|
+
|
|
|
+When the slicer CLI (Bambu Studio or OrcaSlicer in the docker sidecar)
|
|
|
+produces a ``.gcode.3mf`` without ``Metadata/plate_N.png``, the archive
|
|
|
+card has nothing to show. Both CLIs skip the plate-thumbnail render when
|
|
|
+invoked with ``--slice --export-3mf`` headlessly — that render is a
|
|
|
+GUI-side action that only fires in the desktop Studio. The
|
|
|
+``--export-png`` flag exists but is mutually exclusive with
|
|
|
+``--export-3mf`` and additionally needs a Wayland compositor in the
|
|
|
+container, so we can't reach it from the sidecar's current invocation
|
|
|
+shape.
|
|
|
+
|
|
|
+This module fills the gap server-side: it parses the sliced 3MF, and
|
|
|
+for every ``plate_N.gcode`` entry that doesn't have a matching
|
|
|
+``plate_N.png`` it renders one from the embedded 3D model using the
|
|
|
+same trimesh + matplotlib path as :mod:`backend.app.services.stl_thumbnail`,
|
|
|
+then injects ``Metadata/plate_N.png`` (512x512) + ``Metadata/plate_N_small.png``
|
|
|
+(128x128) into the zip. Best-effort: any failure (no model file,
|
|
|
+trimesh can't parse, matplotlib render fails) returns the input bytes
|
|
|
+unchanged so the slice flow itself never breaks.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import io
|
|
|
+import logging
|
|
|
+import re
|
|
|
+import zipfile
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+# Bambu Studio's plate covers. Match the dimensions BS uses on desktop so
|
|
|
+# the rendered images flow through the same archive UI code paths without
|
|
|
+# special-casing.
|
|
|
+_PLATE_PNG_SIZE = 512
|
|
|
+_PLATE_PNG_SMALL_SIZE = 128
|
|
|
+
|
|
|
+# Mirror stl_thumbnail.py's palette so archive cards rendered through
|
|
|
+# this path are visually consistent with the rest of Bambuddy's library
|
|
|
+# thumbnails — same Bambu green on the same dark background.
|
|
|
+_BAMBU_GREEN = "#00AE42"
|
|
|
+_BACKGROUND_COLOR = "#1a1a1a"
|
|
|
+
|
|
|
+# Above this vertex count, trimesh.simplify_quadric_decimation runs first.
|
|
|
+# Same cap stl_thumbnail.py uses; matplotlib's Poly3DCollection slows down
|
|
|
+# nonlinearly past ~100k faces and a plate thumbnail doesn't need detail
|
|
|
+# beyond what a 512x512 PNG can resolve.
|
|
|
+_MAX_VERTICES = 100_000
|
|
|
+
|
|
|
+# Plate-gcode entries look like ``Metadata/plate_1.gcode``,
|
|
|
+# ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
|
|
|
+_PLATE_GCODE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
|
|
|
+
|
|
|
+
|
|
|
+def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
|
|
|
+ """Return ``threemf_bytes`` with ``plate_N.png`` injected for every
|
|
|
+ plate that's missing one.
|
|
|
+
|
|
|
+ No-op fast path when every plate already has a thumbnail — the input
|
|
|
+ bytes are returned verbatim (same object identity), so the common
|
|
|
+ case of a desktop-Studio-sliced 3MF flowing through this function
|
|
|
+ is essentially free.
|
|
|
+
|
|
|
+ On any failure the input bytes are returned unchanged. A missing
|
|
|
+ thumbnail is a visual degradation; failing the slice would be worse.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
|
|
|
+ names = set(zf.namelist())
|
|
|
+ missing = _missing_plate_ids(names)
|
|
|
+ if not missing:
|
|
|
+ return threemf_bytes
|
|
|
+ if "3D/3dmodel.model" not in names:
|
|
|
+ logger.debug(
|
|
|
+ "plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
|
|
|
+ sorted(missing),
|
|
|
+ )
|
|
|
+ return threemf_bytes
|
|
|
+ except (zipfile.BadZipFile, OSError) as exc:
|
|
|
+ logger.warning("plate_thumbnail: input is not a readable zip: %s", exc)
|
|
|
+ return threemf_bytes
|
|
|
+
|
|
|
+ try:
|
|
|
+ large_png, small_png = _render_model_thumbnails(threemf_bytes)
|
|
|
+ except Exception as exc:
|
|
|
+ logger.warning(
|
|
|
+ "plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
|
|
|
+ exc,
|
|
|
+ exc_info=True,
|
|
|
+ )
|
|
|
+ return threemf_bytes
|
|
|
+
|
|
|
+ if large_png is None or small_png is None:
|
|
|
+ return threemf_bytes
|
|
|
+
|
|
|
+ try:
|
|
|
+ return _inject_pngs(threemf_bytes, missing, large_png, small_png)
|
|
|
+ except (zipfile.BadZipFile, OSError) as exc:
|
|
|
+ logger.warning("plate_thumbnail: zip re-pack failed: %s", exc)
|
|
|
+ return threemf_bytes
|
|
|
+
|
|
|
+
|
|
|
+def _missing_plate_ids(names: set[str]) -> list[int]:
|
|
|
+ """Plate IDs that have a ``plate_N.gcode`` but no ``plate_N.png``.
|
|
|
+
|
|
|
+ Multi-plate slices produce one gcode per plate; we render the model
|
|
|
+ once and reuse it for every missing plate. The visual is identical
|
|
|
+ across plates of the same model, which matches what users see today
|
|
|
+ for desktop-Studio-sliced multi-plate projects — Studio also reuses
|
|
|
+ the model render across plates that share geometry.
|
|
|
+ """
|
|
|
+ plate_ids: list[int] = []
|
|
|
+ for name in names:
|
|
|
+ m = _PLATE_GCODE_RE.match(name)
|
|
|
+ if not m:
|
|
|
+ continue
|
|
|
+ n = int(m.group(1))
|
|
|
+ if f"Metadata/plate_{n}.png" not in names:
|
|
|
+ plate_ids.append(n)
|
|
|
+ return sorted(plate_ids)
|
|
|
+
|
|
|
+
|
|
|
+def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes | None]:
|
|
|
+ """Render an isometric view of the 3MF's model at both plate sizes.
|
|
|
+
|
|
|
+ Returns (large, small) PNG bytes, or (None, None) if the model
|
|
|
+ couldn't be loaded. Mirrors stl_thumbnail.py's style (Bambu green
|
|
|
+ mesh on dark background, ~25deg elev / 45deg azim) so this output
|
|
|
+ blends into Bambuddy's existing library/archive cards.
|
|
|
+ """
|
|
|
+ # Local imports so a `import backend.app.services.plate_thumbnail` from
|
|
|
+ # an environment without matplotlib/trimesh doesn't fail at import time —
|
|
|
+ # the function will simply degrade to no-op via the exception branch.
|
|
|
+ from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
|
|
|
+
|
|
|
+ _configure_matplotlib_cache()
|
|
|
+
|
|
|
+ import matplotlib
|
|
|
+
|
|
|
+ matplotlib.use("Agg")
|
|
|
+ import matplotlib.pyplot as plt
|
|
|
+ import trimesh
|
|
|
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
|
+
|
|
|
+ loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
|
|
|
+ if loaded is None or not hasattr(loaded, "vertices") or len(loaded.vertices) == 0:
|
|
|
+ logger.debug("plate_thumbnail: trimesh produced empty mesh from 3MF")
|
|
|
+ return None, None
|
|
|
+
|
|
|
+ mesh = loaded
|
|
|
+ if len(mesh.vertices) > _MAX_VERTICES:
|
|
|
+ try:
|
|
|
+ keep_ratio = _MAX_VERTICES / len(mesh.vertices)
|
|
|
+ target_reduction = max(0.01, min(0.99, 1.0 - keep_ratio))
|
|
|
+ mesh = mesh.simplify_quadric_decimation(target_reduction)
|
|
|
+ except Exception as exc:
|
|
|
+ logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
|
|
|
+
|
|
|
+ vertices = mesh.vertices
|
|
|
+ bounds_min = vertices.min(axis=0)
|
|
|
+ bounds_max = vertices.max(axis=0)
|
|
|
+ centered = vertices - (bounds_min + bounds_max) / 2
|
|
|
+ max_extent = (bounds_max - bounds_min).max()
|
|
|
+ scaled = centered / max_extent if max_extent > 0 else centered
|
|
|
+
|
|
|
+ faces = mesh.faces
|
|
|
+ poly3d = [[scaled[v] for v in face] for face in faces]
|
|
|
+
|
|
|
+ large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection)
|
|
|
+ small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection)
|
|
|
+ return large, small
|
|
|
+
|
|
|
+
|
|
|
+def _render_at_size(poly3d, size: int, plt, Poly3DCollection) -> bytes:
|
|
|
+ """Render the prepared poly3d collection to an in-memory PNG."""
|
|
|
+ fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
|
|
|
+ fig.patch.set_facecolor(_BACKGROUND_COLOR)
|
|
|
+ ax = fig.add_subplot(111, projection="3d")
|
|
|
+ ax.set_facecolor(_BACKGROUND_COLOR)
|
|
|
+ ax.add_collection3d(
|
|
|
+ Poly3DCollection(
|
|
|
+ poly3d,
|
|
|
+ facecolors=_BAMBU_GREEN,
|
|
|
+ edgecolors=_BAMBU_GREEN,
|
|
|
+ linewidths=0.1,
|
|
|
+ alpha=0.9,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ ax.set_xlim(-0.6, 0.6)
|
|
|
+ ax.set_ylim(-0.6, 0.6)
|
|
|
+ ax.set_zlim(-0.6, 0.6)
|
|
|
+ ax.view_init(elev=25, azim=45)
|
|
|
+ ax.set_axis_off()
|
|
|
+ ax.grid(False)
|
|
|
+ plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
|
|
+
|
|
|
+ buf = io.BytesIO()
|
|
|
+ fig.savefig(
|
|
|
+ buf,
|
|
|
+ format="png",
|
|
|
+ facecolor=_BACKGROUND_COLOR,
|
|
|
+ edgecolor="none",
|
|
|
+ bbox_inches="tight",
|
|
|
+ pad_inches=0.05,
|
|
|
+ dpi=100,
|
|
|
+ )
|
|
|
+ plt.close(fig)
|
|
|
+ return buf.getvalue()
|
|
|
+
|
|
|
+
|
|
|
+def _inject_pngs(
|
|
|
+ threemf_bytes: bytes,
|
|
|
+ plate_ids: list[int],
|
|
|
+ large_png: bytes,
|
|
|
+ small_png: bytes,
|
|
|
+) -> bytes:
|
|
|
+ """Copy every entry from the input zip to a new one, then append the
|
|
|
+ plate PNGs. Re-pack rather than mutate-in-place because zipfile doesn't
|
|
|
+ support adding entries to an existing archive read from bytes."""
|
|
|
+ out_buf = io.BytesIO()
|
|
|
+ with (
|
|
|
+ zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as src,
|
|
|
+ zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as dst,
|
|
|
+ ):
|
|
|
+ for item in src.infolist():
|
|
|
+ dst.writestr(item, src.read(item.filename))
|
|
|
+ for n in plate_ids:
|
|
|
+ dst.writestr(f"Metadata/plate_{n}.png", large_png)
|
|
|
+ dst.writestr(f"Metadata/plate_{n}_small.png", small_png)
|
|
|
+ return out_buf.getvalue()
|