소스 검색

fix(archives): render plate thumbnails server-side when sidecar slice skips them (#1759)

      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.
maziggy 2 달 전
부모
커밋
d2232e0291
6개의 변경된 파일435개의 추가작업 그리고 2개의 파일을 삭제
  1. 0 0
      CHANGELOG.md
  2. 4 2
      README.md
  3. 11 0
      backend/app/api/routes/library.py
  4. 231 0
      backend/app/services/plate_thumbnail.py
  5. 181 0
      backend/tests/unit/services/test_plate_thumbnail.py
  6. 8 0
      requirements.txt

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
CHANGELOG.md


+ 4 - 2
README.md

@@ -141,8 +141,8 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
-- Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **build-plate Z-jog** with Studio-style not-homed warning)
+- Fan monitoring and **speed control** for part-cooling, auxiliary, and chamber fans (0–100% with customizable quick-select presets)
+- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **temperature setpoints** for nozzle / bed / **chamber heater** on H2C/H2D/H2DPro/H2S/X2D, **Z-jog / XY-jog / extruder jog**, customizable temperature & fan presets under Settings → Workflow)
 - **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)
 - **Force Refresh** menu item — request a full status push from the printer without reconnecting
 - Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
@@ -347,6 +347,8 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ## 📸 Screenshots
 
+> **Refreshed printer card in 0.2.5b2** — tighter layout, popovers for all controls (temperature setpoints, fan speeds, jog), and a bottom-aligned power row. The screenshots below predate the refresh.
+
 <details>
 <summary><strong>Click to expand screenshots</strong></summary>
 

+ 11 - 0
backend/app/api/routes/library.py

@@ -63,6 +63,7 @@ from backend.app.schemas.library import (
 )
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
+from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
@@ -3590,6 +3591,11 @@ async def slice_and_persist(
     out_filename = f"{base_name}.gcode.3mf"
     unique_name = f"{uuid.uuid4().hex}.gcode.3mf"
     out_path = get_library_files_dir() / unique_name  # SEC-PATH-OK: unique_name = uuid.uuid4().hex + ".gcode.3mf"
+    # BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
+    # inject server-side so the library card has a thumbnail. Best-effort:
+    # no-op when the slicer did embed thumbs (desktop Studio path), and
+    # falls through to the unmodified bytes on any render error.
+    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
     out_path.write_bytes(result.content)
 
     # Extract thumbnail from the produced 3MF so the library card shows a
@@ -3715,6 +3721,11 @@ async def slice_and_persist_as_archive(
     out_path = (
         archive_dir / out_filename
     )  # SEC-PATH-OK: out_filename = f"{base_name}.gcode.3mf" where base_name went through _safe_filename
+    # See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
+    # in headless --export-3mf, so the produced 3MF often has no thumbnail
+    # at all. Server-side render fills the gap; no-op when the slicer did
+    # embed (desktop Studio path) and best-effort on any render error.
+    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
     out_path.write_bytes(result.content)
 
     # Extract a thumbnail for the new archive card. Priority order:

+ 231 - 0
backend/app/services/plate_thumbnail.py

@@ -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()

+ 181 - 0
backend/tests/unit/services/test_plate_thumbnail.py

@@ -0,0 +1,181 @@
+"""Unit tests for the plate-thumbnail injection service.
+
+The service backfills ``Metadata/plate_N.png`` when the sidecar CLI
+(BS or Orca) skipped it in --slice --export-3mf. Each test builds a
+synthetic sliced-3MF fixture: a trimesh-exported cube as
+``3D/3dmodel.model`` plus dummy ``Metadata/plate_1.gcode`` so the
+inject function sees it as "plate 1, no thumbnail."
+"""
+
+from __future__ import annotations
+
+import io
+import zipfile
+
+import pytest
+
+
+def _trimesh_available() -> bool:
+    try:
+        import trimesh  # noqa: F401
+
+        return True
+    except ImportError:
+        return False
+
+
+def _build_sliced_3mf(
+    *,
+    plate_ids: list[int],
+    with_thumbnails: set[int] | None = None,
+    with_model: bool = True,
+) -> bytes:
+    """Build a synthetic sliced .gcode.3mf for injection tests.
+
+    - ``plate_ids``: which Metadata/plate_N.gcode entries to write
+    - ``with_thumbnails``: subset of plate_ids that ALSO get plate_N.png +
+      plate_N_small.png (simulates a desktop-Studio-style slice where the
+      slicer did embed thumbnails)
+    - ``with_model``: when True, embeds a trimesh-rendered cube as
+      ``3D/3dmodel.model`` so the injector can reload + render it
+    """
+    import trimesh
+
+    have_thumbs = with_thumbnails or set()
+
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        if with_model:
+            # trimesh's primitives.Box exports cleanly to 3MF.
+            mesh = trimesh.creation.box(extents=(10.0, 10.0, 10.0))
+            model_bytes = mesh.export(file_type="3mf")
+            # trimesh.export(file_type='3mf') returns a full 3MF zip; we
+            # want just the embedded 3D/3dmodel.model XML so we can place
+            # it under the sliced-3MF layout.
+            with zipfile.ZipFile(io.BytesIO(model_bytes), "r") as inner:
+                model_xml = inner.read("3D/3dmodel.model")
+            zf.writestr("3D/3dmodel.model", model_xml)
+        for n in plate_ids:
+            # Dummy gcode is enough for the injector — it only matches the
+            # filename to detect plate slots, not the content.
+            zf.writestr(f"Metadata/plate_{n}.gcode", b"; dummy gcode\n")
+            if n in have_thumbs:
+                # 1x1 transparent PNG — pre-existing thumb sentinel; the
+                # injector should preserve its bytes verbatim.
+                zf.writestr(f"Metadata/plate_{n}.png", _PIXEL_PNG)
+                zf.writestr(f"Metadata/plate_{n}_small.png", _PIXEL_PNG)
+    return buf.getvalue()
+
+
+# 1x1 transparent PNG used as a pre-existing thumbnail sentinel.
+_PIXEL_PNG = (
+    b"\x89PNG\r\n\x1a\n"
+    b"\x00\x00\x00\rIHDR"
+    b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
+    b"\x1f\x15\xc4\x89"
+    b"\x00\x00\x00\x0dIDATx\x9cc\xfc\xff\xff?\x03\x00\x05\xfe\x02\xfe"
+    b"\xdc\xccY\xe7"
+    b"\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+def _names_in_zip(blob: bytes) -> set[str]:
+    with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
+        return set(zf.namelist())
+
+
+@pytest.mark.skipif(not _trimesh_available(), reason="trimesh not installed")
+class TestInjectPlateThumbnails:
+    """Behaviour around when the injector renders vs returns the input."""
+
+    def test_returns_input_unchanged_when_all_plates_have_thumbnails(self):
+        """Desktop-Studio path: every plate already has plate_N.png — no work."""
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails={1})
+        result = inject_plate_thumbnails_if_missing(fixture)
+        # Same object identity — the fast path returns the input verbatim
+        # so the SliceResult._replace upstream never pays for a copy on the
+        # common already-embedded case.
+        assert result is fixture
+
+    def test_injects_both_sizes_when_thumbnail_missing(self):
+        """BS/Orca sidecar path: plate_1.gcode present, plate_1.png absent."""
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
+        before = _names_in_zip(fixture)
+        assert "Metadata/plate_1.png" not in before
+
+        result = inject_plate_thumbnails_if_missing(fixture)
+        after = _names_in_zip(result)
+        assert "Metadata/plate_1.png" in after
+        assert "Metadata/plate_1_small.png" in after
+
+    def test_injected_pngs_have_expected_dimensions(self):
+        """Sanity-check the render geometry — 512x512 + 128x128, RGBA PNG."""
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
+        result = inject_plate_thumbnails_if_missing(fixture)
+
+        with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
+            large = zf.read("Metadata/plate_1.png")
+            small = zf.read("Metadata/plate_1_small.png")
+
+        assert large.startswith(b"\x89PNG\r\n\x1a\n")
+        assert small.startswith(b"\x89PNG\r\n\x1a\n")
+        # PNG IHDR dimensions live at byte offsets 16..23 (big-endian width,
+        # then big-endian height). matplotlib's bbox_inches='tight' shaves a
+        # few pixels off, so assert "close to" rather than exact.
+        import struct
+
+        large_w, large_h = struct.unpack(">II", large[16:24])
+        small_w, small_h = struct.unpack(">II", small[16:24])
+        assert 480 <= large_w <= 540 and 480 <= large_h <= 540
+        assert 100 <= small_w <= 140 and 100 <= small_h <= 140
+
+    def test_injects_for_every_missing_plate_in_multi_plate_3mf(self):
+        """Three plates, plate_2 already has a thumbnail; only plates 1 + 3 get rendered."""
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        fixture = _build_sliced_3mf(plate_ids=[1, 2, 3], with_thumbnails={2})
+        result = inject_plate_thumbnails_if_missing(fixture)
+        after = _names_in_zip(result)
+
+        for n in (1, 2, 3):
+            assert f"Metadata/plate_{n}.png" in after
+            assert f"Metadata/plate_{n}_small.png" in after
+
+        # Plate 2 had a pre-existing thumbnail — the inject must NOT clobber
+        # it. The sentinel _PIXEL_PNG bytes should survive verbatim.
+        with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
+            assert zf.read("Metadata/plate_2.png") == _PIXEL_PNG
+            assert zf.read("Metadata/plate_2_small.png") == _PIXEL_PNG
+
+    def test_returns_input_when_no_model_file_in_3mf(self):
+        """No 3D/3dmodel.model → render is impossible; degrade gracefully."""
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set(), with_model=False)
+        result = inject_plate_thumbnails_if_missing(fixture)
+        # Same object identity — early-out before render.
+        assert result is fixture
+
+    def test_returns_input_when_not_a_zip(self):
+        """Non-zip input must not crash — degrade to passthrough."""
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        garbage = b"not a zip"
+        assert inject_plate_thumbnails_if_missing(garbage) is garbage
+
+    def test_idempotent_on_second_pass(self):
+        """Re-running on a previously-injected 3MF must be a no-op."""
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
+        once = inject_plate_thumbnails_if_missing(fixture)
+        twice = inject_plate_thumbnails_if_missing(once)
+        # Same object identity — second pass hits the no-op fast path
+        # because every plate now has its plate_N.png.
+        assert twice is once

+ 8 - 0
requirements.txt

@@ -61,6 +61,14 @@ reportlab>=4.0.0
 trimesh>=4.0.0
 matplotlib>=3.8.0
 fast-simplification>=0.1.0
+# trimesh's 3MF loader uses networkx for scene-graph traversal and lxml
+# for the model.xml parse. Required by plate_thumbnail.py to render the
+# model out of a sliced .gcode.3mf when the BS/Orca CLI didn't embed
+# Metadata/plate_N.png. Not strictly transitive — trimesh imports both
+# lazily inside the 3MF code path, so the load call fails at runtime
+# ("No module named 'networkx'" / "No module named 'lxml'") if absent.
+networkx>=3.0
+lxml>=5.0
 
 # System monitoring
 psutil>=6.0.0

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.