mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Bambuddy's archive cards were blank for every print sliced through the BS or Orca docker sidecars. The "Some recent prints couldn't be archived with thumbnails" banner pointed at install step 4 which is unrelated — that flag only fires on FTP-fetch failures, not on missing-thumb in the sliced 3MF. Root cause is upstream of Bambuddy: neither slicer CLI renders Metadata/plate_N.png when invoked headlessly with --slice --export-3mf. That render is a separate code path triggered by --export-png, which is mutually exclusive with --export-3mf and additionally needs a working display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland — even XDG_SESSION_TYPE=x11 + GDK_BACKEND=x11 + QT_QPA_PLATFORM=xcb don't switch it back). An Xvfb display in the sidecar wouldn't help even if we wired the second-pass call. The Orca sidecar has been silently shipping thumbnail-less 3MFs from STL inputs since launch; nobody noticed. Fill the gap on the Bambuddy side: new plate_thumbnail.py renders the missing thumbnails after the slice returns. inject_plate_thumbnails_if_missing parses the sliced zip, finds every Metadata/plate_N.gcode entry that doesn't have a matching plate_N.png, loads 3D/3dmodel.model via trimesh, renders an isometric Bambu-green-on-dark view at 512x512 + 128x128 via the same matplotlib Agg pipeline as stl_thumbnail.py, and re-packs the zip with the PNGs injected. Visual style matches Bambuddy's existing library thumbnails — archive cards stay consistent inside Bambuddy rather than chasing parity with desktop Studio's plate render. Best-effort: input bytes are returned unchanged on any failure so the slice flow itself can never fail because of a missing thumbnail. Idempotent: re-running on an already-injected 3MF returns the input verbatim. Wired into both library.py slice paths via result._replace; covers the cross-class merged-multi-plate path automatically (merged bytes flow into the same write site). No sidecar Dockerfile change required — an earlier attempt to install Xvfb in Dockerfile.bambu-studio was a false start and is not part of this drop. Dependencies: trimesh's 3MF loader uses networkx (scene-graph traversal) and lxml (model.xml parse) lazily inside the 3MF code path — both added to requirements.txt because they aren't strict trimesh transitives.
232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
"""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()
|