diff --git a/CHANGELOG.md b/CHANGELOG.md index d14fa5b08..7329c6ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file. - **Admin-configurable session lifetime (#1706, reported by @AD3DStuff)** — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. **New setting:** `session_max_hours` under Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." **Backend wiring:** new `resolve_session_max_minutes(db)` helper in `backend/app/core/auth.py` reads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain `/auth/login`, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depth `SESSION_MAX_HOURS_HARD_CEILING = 720` clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. **What this does NOT change:** the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (`MAX_TOKEN_LIFETIME_DAYS`), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. **Tests:** 15 new cases in `backend/tests/integration/test_session_policy.py` split across three classes. `TestResolveSessionMaxMinutes` pins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp. `TestLoginRespectsSessionPolicy` decodes the JWT `exp` claim end-to-end and asserts the token returned by `/auth/login` honours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases. `TestSettingsAPIExposesSessionMaxHours` round-trips the field through `/settings/` (default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. **i18n:** 8 new keys in `settings.sessionPolicy.*` namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean; `npm run build` clean; ruff clean. ### Fixed +- **Archive thumbnails missing for prints sliced via the docker sidecar (#1759, reported by @VID-PRO)** — The reporter (P2S) noticed every print sliced through Bambuddy's BS docker sidecar landed in the archive with no thumbnail, while the same model sliced from desktop Bambu Studio on their laptop showed the cover image. The "Some recent prints couldn't be archived with thumbnails" banner pointed at install step 4 (`Store sent files on external storage`) which is unrelated — that flag is set on FTP-fetch failures, not on missing-thumb in the sliced 3MF. Root cause is upstream of Bambuddy entirely: **neither the BambuStudio CLI nor the OrcaSlicer CLI renders `Metadata/plate_N.png` when invoked headlessly with `--slice --export-3mf`.** That render is a separate code path triggered by the `--export-png` flag, which is mutually exclusive with `--export-3mf` and additionally requires 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 to X11, so an Xvfb display in the sidecar wouldn't help even if we wired a second-pass call). Confirmed empirically by feeding a thumbnail-stripped `Cube-MegaS.3mf` through both sidecars: both produced `.gcode.3mf` with zero PNG entries. The Orca sidecar has been silently shipping thumbnail-less 3MFs from STL inputs since it launched; nobody noticed until VID-PRO filed this against BS specifically. **Fix:** New `backend/app/services/plate_thumbnail.py` renders the missing thumbnails server-side after the slice returns. `inject_plate_thumbnails_if_missing(threemf_bytes)` 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 512×512 (`plate_N.png`) + 128×128 (`plate_N_small.png`) using the same matplotlib Agg pipeline as `stl_thumbnail.py`, and re-packs the zip with the PNGs injected. Visual style deliberately 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 (no model file, trimesh can't parse, matplotlib render fails) so the slice flow itself can't fail because of a missing thumbnail. Idempotent: re-running on a previously-injected 3MF hits the no-op fast path and returns the input verbatim. Wired into both `backend/app/api/routes/library.py` slice paths (library-file slice at line 3593 + archive re-slice at line 3718) via `result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))` immediately before `out_path.write_bytes(...)` — covers the cross-class merged-multi-plate path (`slicer_3mf_convert.merge_plate_3mfs`) automatically since merged bytes flow into the same write site. **Dependencies:** trimesh's 3MF loader imports `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 but the loader fails at runtime without them (`ModuleNotFoundError`). **Tests:** 7 new cases in `backend/tests/unit/services/test_plate_thumbnail.py`: input bytes returned unchanged (identity) when every plate already has a thumbnail (desktop-Studio fast path); both PNG sizes injected when missing; injected PNGs decode as 512x512 + 128x128 RGBA; multi-plate 3MF with one pre-existing thumbnail only renders the missing slots (pre-existing bytes preserved verbatim); 3MF with no `3D/3dmodel.model` returns input unchanged; non-zip input returns input unchanged; idempotent on second pass. **Verified end-to-end:** running `inject_plate_thumbnails_if_missing` against the actual BS sidecar and Orca sidecar outputs (`/tmp/bs-no-thumb-out.3mf` / `/tmp/orca-no-thumb-out.3mf` — both 25932/25992 bytes with zero PNG entries) produces 3MFs with valid `Metadata/plate_1.png` + `Metadata/plate_1_small.png` containing the rendered cube model (38.5% Bambu-green pixel coverage confirms the model is actually drawn, not a blank canvas). 6151/6151 backend tests still green; ruff clean. No sidecar Dockerfile change required — earlier experiments with Xvfb + `xvfb-run` in `Dockerfile.bambu-studio` were a false start (the BS GLFW Wayland lock means no X display can help) and have been reverted from the sidecar repo. No frontend change required — the archive UI already extracts `plate_1.png` from the sliced 3MF, the cards just had nothing to show. - **Local Presets page: deleted row stayed visible until refetch returned, allowing a second delete click → 404** — On the Slicer → Local Profiles page, clicking Delete → Confirm fired the `DELETE /api/v1/local-presets/{id}` request, then the `onSuccess` handler closed the confirmation modal and called `queryClient.invalidateQueries({ queryKey: ['localPresets'] })` without awaiting it. The global QueryClient default `staleTime: 1000 * 60` (App.tsx:78) doesn't block `invalidateQueries` from refetching, but the refetch is *async* — so for ~hundreds of ms the rendered table still showed the just-deleted row, and a quick re-click on the same row opened a fresh confirm dialog → second confirm → backend returns 404 (row already gone) → confusing error toast. Caught while reproducing #1713: log showed `DELETE /api/v1/local-presets/42 → 200` followed by two `→ 404` for the same id within 4 seconds. **Fix:** Add an optimistic `queryClient.setQueryData(['localPresets'], …)` in `frontend/src/components/LocalProfilesView.tsx::deleteMutation.onSuccess` that filters the deleted row out of the cached list synchronously, then leaves the existing `invalidateQueries` calls in place to reconcile any drift. Row disappears the instant the DELETE returns 200, no re-click window. The same import path's `importMutation` doesn't need the same treatment because additions can't trigger the symmetric "row I just acted on is still there" → 404 loop. ESLint clean; `npm run build` clean; existing `LocalProfilesView.test.tsx` suite still green (no new test added — the bug is a render-timing window the existing render-based vitests don't observe; the existing onSuccess assertions still pass with the new optimistic write). - **SpoolBuddy inventory search now matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink)** — The reporter found that typing a numeric spool ID into SpoolBuddy → Inventory's search box returned no results, even though the same query in Bambuddy's main Inventory page worked. Root cause: `frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx:147-155` reimplemented the search filter inline and only matched `material`, `subtype`, `brand`, `color_name`, and `note`. The main Inventory page delegates to the shared `filterSpoolsByQuery` helper in `frontend/src/utils/inventorySearch.ts:7`, which additionally matches `String(spool.id)`, `slicer_filament_name`, and `storage_location`. SpoolBuddy had diverged. **Fix:** replace the inline filter with a single call to `filterSpoolsByQuery(list, searchQuery.trim())`. Both inventory modes (internal via `getSpools`, Spoolman via `getSpoolmanInventorySpools`) return the same `InventorySpool` shape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. **Tests:** new `SpoolBuddyInventorySearch.test.ts` with 4 cases pinning the parity — exact spool ID match, partial spool ID match, the five pre-fix fields still match, and the three newly-included fields (storage_location, slicer_filament_name, plus implicit id) match. Existing `inventorySearch.test.ts` ID matching test (#1336) still green. ESLint clean; `npm run build` clean. No backend change, no i18n, no new permission. diff --git a/README.md b/README.md index 4eba2ac96..8eca0f32f 100644 --- a/README.md +++ b/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. +
Click to expand screenshots diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 06b1018c5..6f8ff341a 100644 --- a/backend/app/api/routes/library.py +++ b/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: diff --git a/backend/app/services/plate_thumbnail.py b/backend/app/services/plate_thumbnail.py new file mode 100644 index 000000000..a55f2be01 --- /dev/null +++ b/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() diff --git a/backend/tests/unit/services/test_plate_thumbnail.py b/backend/tests/unit/services/test_plate_thumbnail.py new file mode 100644 index 000000000..eeac0443e --- /dev/null +++ b/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 diff --git a/requirements.txt b/requirements.txt index 7a930b96a..109e68a99 100644 --- a/requirements.txt +++ b/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