diff --git a/CHANGELOG.md b/CHANGELOG.md index 5242757de..057fa37bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added +- **SliceModal: "Slice all plates" toggle for multi-plate sources** — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting `plate=0` instead of the picked plate index. The backend forwards this as the BS CLI's `--slice 0` "all plates" sentinel, which produces a **single output 3MF whose `Metadata/plate_N.gcode` entries cover every plate** — one slice call, one archive, every plate inside. **Filament dropdowns also adapt**: with the toggle on, they show the *union* of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existing `platesQuery.data.plates[*].filaments` payload, so no extra round-trip. The backend `SliceRequest.plate` field's range relaxed from `ge=1` to `ge=0` to admit the sentinel (the schema's docstring spells out the three semantics: `None` → default plate 1, `0` → all plates, `>= 1` → that plate). The substitute-unused-filaments pass becomes a no-op for `plate=0` (no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. **Cross-class slice-all is handled by a per-plate loop**: BS CLI's `--arrange` is project-wide, so `--slice 0 --arrange 1` on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detects `plate=0` combined with a class crossing, it falls back to slicing each plate independently (`plate=N, arrange=true`), then merges the N single-plate 3MF outputs into one multi-plate 3MF in `merge_plate_3mfs` — overlays each plate's `Metadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}` onto the first plate's base 3MF and re-assembles `Metadata/slice_info.config` to list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. New `count_plates_in_3mf` parses `model_settings.config` for `` entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. **Progress toast shows loop position**: each per-plate sub-slice forwards the original `progress_request_id` + callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented with `multi_plate_index` / `multi_plate_count` — the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. New `slice.runningWithProgressMultiPlate` i18n key translated across all 9 locales. **Per-plate cover images preserved**: BS CLI with `--arrange` regenerates plate gcodes but rarely writes a fresh `Metadata/plate_N.png`, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (`plate_N.png` / `plate_N_small.png`) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. **Final test coverage**: 26 unit tests in `test_slicer_3mf_convert.py` (extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 in `test_slicer_api.py` (arrange flag wire format on preset and bundle paths) + 9 in `test_library_slice_api.py` (guard no-op semantics, re-sliced thumbnail / bed_type lifts, **a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate with `arrange=true`, and verifies the merged archive contains `plate_1..plate_N.gcode`**) + 2 in `test_archive_service.py` (Auxiliaries thumbnail fallback) + 4 in `SliceModal.test.tsx` (slice-all toggle sends `plate=0`, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new in `SliceJobTrackerContext.test.tsx` (toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests in `SliceModal.test.tsx` (toggle sends `plate=0` to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. - **System Health — log scanner that surfaces self-fixable issues before they become support tickets** — Complements the active Connection Diagnostic with a passive check: it scans Bambuddy's recent app log against a curated catalog of known failure signatures and reports what it finds. The catalog (`backend/app/services/log_health.py`) is a deliberate allowlist — only known-bad, actionable patterns match, so a healthy install reports nothing and noisy benign churn (the occasional MQTT reconnect after a Wi-Fi blip) is gated behind a per-signature `min_count` threshold. Six seed signatures cover the recurring "layer 8" causes from the closed-issue triage: rejected access code, FTPS :990 timeout, FTPS TLS handshake failure, flapping MQTT connection, unreachable camera (RTSPS :322), and SQLite `database is locked` contention. Each finding is deduped (`occurred N×, last seen …`), classified as *you can fix this* / *environment* / *please report this*, and carries a deep-link to the troubleshooting wiki; sample log lines are sanitized (IPs, serials, access codes redacted) before they leave the process. Exposed via `GET /system/health` and surfaced on two surfaces that share one `SystemHealthPanel` component: a System Health section on the System page (on-demand re-scan), and inline in the bug reporter when the form opens — so a setup mistake gets self-resolved instead of becoming a GitHub issue. The Add-Printer and Edit-Printer dialogs also gained a setup-time pre-flight: saving now runs the connection diagnostic and, if a check fails, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log-reading and redaction primitives were extracted from `routes/support.py` into a shared `backend/app/services/log_reader.py` (behaviour-preserving). 13 backend tests (`test_log_health.py`, `test_system_api.py`) and 8 frontend tests (`SystemHealthPanel`, `BugReportBubble`, `AddPrinterPreflight`, `EditPrinterPreflight`); all strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build clean, i18n parity green. - **Event-loop stall watchdog — makes a frozen backend self-diagnose (#1486 groundwork)** — Several "container hangs after adding a printer" reports share a signature that leaves nothing to act on: the HTTP server goes silent, `/health` hangs, the process may stop responding to SIGTERM — and the logs just stop mid-stream with no traceback, because a frozen asyncio event loop cannot log anything. New `backend/app/services/loop_watchdog.py` closes that blind spot: an async heartbeat re-arms `faulthandler.dump_traceback_later()` every 10s, always 30s ahead. While the loop ticks, the timer is cancelled and re-armed before it can fire; if the loop stalls, the heartbeat can't re-arm and faulthandler's dedicated C-level timer thread — which runs independently of the frozen loop — dumps **every thread's stack to stderr**. The blocked frame then appears in `docker compose logs`, turning an un-diagnosable freeze into a one-command capture. Started in the app lifespan after migrations, stopped cleanly on shutdown; 30s threshold is well above any legitimate on-loop operation, so a trip always means a real bug. 5 unit tests in `test_loop_watchdog.py` (arms the timer, idempotent start, stop disarms + cancels, heartbeat interval below the threshold, survives a re-arm error). Backend ruff clean; full app lifespan verified via the integration suite. - **Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905)** — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own `compatible_printers` list for imported (local) presets, and falls back to the `@BBL ` name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF's `project_settings.config` when those presets are available, instead of always taking the first listed preset. New `frontend/src/utils/slicerPrinterMatch.ts` (11 unit tests) and `extract_embedded_presets_from_3mf` (5 unit tests); `UnifiedPreset` now carries `compatible_printers`, exposed for the local tier (`backend/app/api/routes/slicer_presets.py`); the plates endpoints return `embedded_printer` / `embedded_process`. Parity green, build clean. @@ -27,7 +28,7 @@ All notable changes to Bambuddy will be documented in this file. - **Insufficient-filament pre-print warning now fires on every dispatch path (#1496, reported by @needo37)** — The "Pre-print checks now also warn when the spool has insufficient material" guard from #720 only fired on the `PrintModal` submit path. Two other queue-dispatch paths bypassed it entirely: the green ▶ Play button on a staged (`manual_start`) queue row called `POST /queue/{id}/start`, which only flipped the manual_start flag with no filament check; and the Virtual Printer queue-mode intake (`virtual_printer/manager._add_to_print_queue`) parsed per-slot requirements for *type* matching only — never weight. With `auto_dispatch=True` the scheduler would then dispatch unsupervised onto a doomed-to-fail spool. **Fix**: extracted the per-slot deficit calculation into a single backend helper (`backend/app/services/filament_deficit.py`) that both the route and the dispatch scheduler call against live spool state. Works for internal-inventory mode (`SpoolAssignment` → `Spool.label_weight - weight_used`) and Spoolman mode (`SpoolmanSlotAssignment` → `SpoolmanClient.get_spool`); Spoolman unreachability returns no deficit rather than wedging the queue. The `disable_filament_warnings` setting is honoured at the service boundary. `POST /queue/{id}/start` now returns `409 {detail: {code: 'insufficient_filament', deficit: [...]}}` when short; the `?skip_filament_check=true` query param is the "Print Anyway" bypass. The dispatch scheduler runs the same check just before each `_start_print` call: a deficit promotes the item to `manual_start=True` + `filament_short=True` (so the user must consciously click ▶) and a previously-flagged item whose spool was swapped to one with enough material clears the flag automatically on the next tick. A new `filament_short` boolean column on `print_queue` carries the flag; the queue row now renders a yellow "Insufficient filament for the assigned spool" badge when set, and the ▶ button catches the 409, opens an `Insufficient Filament / Print Anyway` confirm modal showing each shorted slot's required-vs-remaining grams, and on confirm re-issues the start with the skip flag. Migration is idempotent and branches on `is_sqlite()` for the `BOOLEAN DEFAULT` syntax. **Tests**: 8 in `test_filament_deficit.py` (deficit + sufficient + missing mapping + no printer + disabled-warnings + no-assignment + missing 3MF + multi-slot only-shorted-returned), 4 in `test_scheduler_filament_deficit.py` (block-on-deficit, clear-stale-flag, no-deficit no-op, helper-exception doesn't wedge), 2 new in `test_print_queue_api.py` (`/start` returns 409 + structured payload, `?skip_filament_check=true` bypasses), and 2 frontend tests in `QueuePage.test.tsx` (badge renders on flagged row, ▶ click → 409 → modal → retry with `skip_filament_check=true`). 3392 unit + 63 print-queue integration green; backend ruff clean; frontend build + i18n parity (9 locales × 4979 keys) clean. - **File Manager "All Files" view showed nothing when every file lived in a subfolder (#1499)** — The sidebar entry was meant to list every file across the library but instead returned only files at the library root, so a library with two folders and three files (all nested) appeared empty. Cause was an inverted boolean on the React Query call: `getLibraryFiles(selectedFolderId, selectedFolderId === null)` passed `include_root=true` for the "All Files" selection, which on the backend (`library.py` `list_files`) means *root files only* — the opposite of what the UI wanted. **Fix**: pass `include_root=false` for "All Files" so the backend returns every active file across folders (it remains a no-op when a specific folder is selected — `folder_id` takes precedence). A new vitest regression case renders the page with one root file and one nested file and asserts both appear, and that the request goes out with `include_root=false`. 48/48 FileManagerPage tests green; frontend build clean. - **Archive filament colour now reflects the assigned inventory spool, not the slicer's 3MF (#1494, reported by @IndividualGhost1905)** — A user added a `#000000` black filament to the built-in inventory, assigned it to the printer, and printed from the desktop slicer; the print, AMS and inventory all showed it as black and the correct spool's weight decremented — but the resulting archive (and the Color Distribution graph) showed `#161616`. Root cause is two independent colour sources: an archive's `filament_color` is parsed verbatim from the print job's 3MF (`archive.py` `_extract_filament_info` reads `filament_colour` from `project_settings.config`), which carries the *slicer's* filament-slot colour — a value the user picks separately from the exact hex they curate on the Bambuddy inventory spool. The two are "close but not equal" (slicer near-black `#161616` vs inventory `#000000`), which is exactly the "always a similar colour, never an unrelated one" pattern the report describes. **Fix**: once usage tracking has resolved the print's filament slots to inventory spools, the spool colours are authoritative — `_track_from_3mf` (built-in inventory) and `report_usage` (Spoolman mode) now overwrite the archive's `filament_color` with the slot-ordered, de-duplicated colours of the matched spools. The rewrite is **all-or-nothing**: it only applies when *every* used slot resolved to a spool that carries a colour, so a partially-mapped multi-colour print never silently loses the unmatched slots' colours (the 3MF value is kept). Shipped for both inventory modes in the same drop — built-in spools read `Spool.rgba`, Spoolman spools read the spool's `filament.color_hex` (fetched for tag-less slot-assignment matches). New helpers `_spool_color_to_hex` / `_archive_colors_from_spools` in `usage_tracker.py`, reused by `spoolman_tracking.py` via `_apply_spool_colors_to_archive`. **Tests**: 12 new in `test_usage_tracker.py` (hex normalisation, the all-or-nothing slot-colour rule across single/multi/partial/no-colour/AMS-fallback cases, and end-to-end that a `#000000` spool rewrites a `#161616` archive) + 4 in `test_spoolman_tracking.py` (the Spoolman-mode rewrite, empty/partial/missing-archive no-ops). 70 usage + Spoolman tracking tests green; backend ruff clean. -- **Re-slicing across the single-nozzle / dual-nozzle boundary now fails fast with a clear message** — Re-slicing a model that was sliced for a single-nozzle printer (X1C, P1S, A1, …) onto a dual-nozzle printer (H2D / H2D Pro), or vice versa, produced a cryptic BambuStudio rejection — *"temperature difference of the filaments used is too large"* or *"G-code in unprintable area of multi-extruder printers"* — and an attempt at automatically converting the embedded single-nozzle layout segfaults the slicer CLI. Full cross-nozzle-class re-slicing isn't supported yet (it needs a proper dual-nozzle `project_settings` reconciliation — tracked separately). Until then, both slice routes (`POST /archives/{id}/slice`, `POST /library/files/{id}/slice`) now detect a nozzle-class mismatch up front and reject it synchronously with a `400` and a plain explanation, instead of letting the user wait for a confusing slicer failure. The guard is fail-open — it only fires when both the source's `sliced_for_model` and the target printer resolve to known models of *different* nozzle class; an un-sliced source (first-time slice) or an undeterminable target is never blocked. As part of this, the dual-nozzle model classification — previously an inline `("H2D", "H2D PRO", …)` tuple **duplicated across three call sites** (`start_print`, the two K-profile routes) — is centralized into `DUAL_NOZZLE_MODELS` + `is_dual_nozzle_model()` in `printer_models.py`, and all three sites plus the new guard now consume that single source of truth. **Tests**: `is_dual_nozzle_model` (H2D/Pro/internal-codes dual, X1C/P1S/A1/H2S single, None safe); `_canonical_printer_model` (strips the `"# "` clone prefix and `" 0.4 nozzle"` suffix); `guard_nozzle_class_reslice` (blocks single→dual and dual→single, allows same-class, no-ops on an un-sliced source); and an end-to-end test that an X1C archive re-sliced for an H2D preset returns `400` before any job is enqueued. 305 slicer/MQTT/K-profile tests + 31 slice-API + the printer-model suite all green; backend ruff clean. +- **Re-slicing across the single-nozzle / dual-nozzle boundary now actually works (#1493)** — Re-slicing a model sliced for a single-nozzle printer (X1C, P1S, A1, P2S, …) onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — produced one of two BambuStudio failures: *"Found G-code in unprintable area of multi-extruder printers"* (the source's X1C-coordinate layout drops into the H2D's per-nozzle dead zone) or, on multi-color projects, a hard SIGSEGV inside the slicer's `ZFiller` polygon-clipping pipeline. An earlier release shipped a fail-fast `400` guard so the user got a clear message instead; this release lifts the guard and actually does the conversion — by forwarding the sidecar's existing `--arrange` flag (it was already plumbed all the way to the CLI in `orca-slicer-api/src/routes/slicing/slicing.service.ts:152`; Bambuddy just wasn't surfacing it). With `arrange=true` BambuStudio repositions objects for the target bed and reconciles the embedded `project_settings.config` against the new printer, the same way the GUI's "Switch Printer" operation does. Empirically: a Mecha Mewtwo X1C archive that previously SIGSEGV'd on H2D now slices in 14.5s producing a 28 MB 3MF with valid H2D G-code, and the simple-test pair (#141 → H2D) which previously hit "G-code in unprintable area" also slices clean. **Wired into `_run_slicer_with_fallback`** on a true class-crossing only (`is_dual_nozzle_model(source) != is_dual_nozzle_model(target)`) so single-printer slices preserve the user's deliberate layout. Threads through both `slice_with_profiles` and `slice_with_bundle` (preset and bundle dispatch). `guard_nozzle_class_reslice` becomes a kept-for-compat no-op; call sites in `archives.py` and the library re-slice route remain so external forks don't break their links. The earlier `is_dual_nozzle_model()` / `DUAL_NOZZLE_MODELS` centralisation stays put — the new cross-class detector reuses it. **A separate related bug surfaced during testing**: the SliceModal lets the user pick a filament profile per slot, but each plate only uses a subset of those slots. The unused-slot dropdowns get whatever default the modal serves up — and a heterogeneous default (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes BambuStudio reject the slice with *"the temperature difference of the filaments used is too large"* (exit 194), even though the plate's G-code never touches the unused slot. Bambu validates every loaded filament for material compatibility regardless of which slots are actually used. **Fix**: a generic `substitute_unused_plate_filaments` helper runs for both preset and bundle dispatch when a 3MF + plate are involved. It reads the source 3MF's per-plate extruder set via the existing `extract_plate_extruder_set_from_3mf` (the same logic that drives the modal's "not used by this plate" label) and overwrites any unused-slot entry with slot 1's selection before the slice. The per-slot array length stays intact (so source-3MF references still resolve), the loaded-filament set becomes materially homogeneous (so the validator passes), and the plate's G-code is unaffected because it never touched the unused slots in the first place. Fail-open everywhere — no plate, unparsable 3MF, single-filament list, or empty extruder-set parse all return the input unchanged. Applies to same-class slices too, which is why this lives outside the cross-class branch. **Tests**: 17 in `test_slicer_3mf_convert.py` covering `extract_source_printer_model` (returns canonical short codes, an integration check that the result feeds straight into `is_dual_nozzle_model` end-to-end since the raw field is `"Bambu Lab H2D"` not `"H2D"`, handles malformed/non-zip/empty inputs) and `substitute_unused_plate_filaments` (substitutes unused slots, no-op when all used / no plate / single filament / unparsable source); 3 new in `test_slicer_api.py` (preset and bundle paths both emit a multipart `arrange=true` field when set, omit it entirely when default-false so the pre-#1493 wire shape is preserved); guard suite in `test_library_slice_api.py` rewritten to assert the no-op semantics. Empirically validated end-to-end against the live sidecar: X1C source 3MF + H2D triplet + `--arrange` slices clean for both the simple test pair and the multi-color Mecha Mewtwo statue. **Card display fixes for re-sliced archives**: BambuStudio CLI rarely emits a fresh ``Metadata/plate_N.png`` for the sliced plate (slice writes the new gcode but leaves the preview slot empty — and ``--arrange`` doesn't change that), so the previous per-plate preview the archive card relied on was simply missing on every re-sliced output. The slice route now picks the cover image in this order: (1) the *source* archive's ``Metadata/plate_{N}.png`` — the GUI-rendered preview of the same plate, which is what the user expects to recognise on the card; (2) the sliced output's own per-plate render if BS did happen to write one; (3) the project-wide thumbnail under ``Auxiliaries/.thumbnails/`` (``_middle.png`` / ``_small.png`` / ``_3mf.png``) — the MakerWorld-style cover art that gets embedded at project import time. Without (1), the card always fell all the way through to (3) and ended up showing marketing art rather than a render of the actual print. A new ``_read_3mf_entry`` helper extracts a single zip entry safely (no parser overhead, fails open on bad zips). The ``ThreeMFParser`` fallback chain was extended to include the Auxiliaries paths so the non-archive callers (library files, etc.) also benefit when neither per-plate variant is present. Second card gap: the re-sliced archive's ``bed_type`` lived in ``extra_data`` but not on the top-level ``PrintArchive.bed_type`` column the ``ArchiveCard`` actually reads — lifted it through, with a fallback to the source archive's value when the sliced output is sparse. 2 new tests in ``test_archive_service.py`` (Auxiliaries thumbnail fallback works, per-plate preview wins when both are present) and 4 in ``test_library_slice_api.py`` (re-sliced ``bed_type`` reflects the slicer's curr_bed_type and falls back to source on missing; re-sliced thumbnail prefers source's per-plate render over the Auxiliaries cover, and falls back to Auxiliaries when the source has no per-plate render either). 647 archive/library/slice tests green; backend ruff clean. - **Sliced files no longer report "0 g" filament usage** — A slice result — and the re-sliced archive's card — showed `filament_used_g: 0` (and `0 mm`) even for a real multi-hour print, while the print time came through fine. Bambuddy reads filament totals from the slicer sidecar's `X-Filament-Used-G` / `X-Filament-Used-Mm` response headers, and some sidecar builds simply don't populate them. **Fix**: `ThreeMFParser._parse_gcode_header` now also reads the slicer's own totals — `; total filament weight [g] : …` and `; total filament length [mm] : …` — from the produced 3MF's G-code header (verified against a real sliced output: 126.26 g / 41661.4 mm extracted correctly). Both slice-persist paths (`slice_and_persist` for library files, `slice_and_persist_as_archive`) now fall back to those parsed totals when the sidecar header is 0, applying the corrected figure to the stored metadata, the archive's `filament_used_grams` column, and the slice response. The G-code-header read is a fallback only — `slice_info.config` still wins when it carries per-filament `used_g`. **Tests**: 2 new in `test_archive_service.py` (`_parse_gcode_header` extracts weight + length; absent header lines leave the keys unset). 36 archive-service + 23 slice-API tests green; backend ruff clean. - **A failed slice now opens an error modal instead of a toast that vanishes before it can be read** — Slice failures surfaced through `SliceJobTrackerContext` as a transient error toast, which `ToastContext` auto-dismisses after a flat 3 seconds. Now that a slice failure carries an actionable message — the slicer's own reason, e.g. *"Some objects are located over the boundary of the heated bed."* — 3 seconds is not enough to read it, let alone act on it. **Fix**: a new `AlertModal` component (a small acknowledge-only modal: title, optional subtitle, message, single Close button; Escape / click-outside to dismiss — modelled on `ConfirmModal` but one button). On a failed slice job, `SliceJobTrackerContext` now shows `AlertModal` with the filename as subtitle and the slicer's reason as the body, instead of the error toast — the user dismisses it themselves. Successful slices keep the existing 3 s success toast; the persistent in-progress toast is still cleared on terminal state. New `slice.failedTitle` key translated across all 9 locales. **Tests**: 4 new in `AlertModal.test.tsx` (renders title/subtitle/message, Close button and Escape both fire `onClose`, subtitle line omitted when absent). Frontend build clean; i18n parity holds. - **Re-slicing for a different printer no longer silently produces a file for the original printer** — Re-slicing an archive or library file for another printer (e.g. an H2D model re-sliced for an X1C) could hand back a file still sliced for the *original* printer, with no error. `_run_slicer_with_fallback` has an embedded-settings fallback built for one narrow case — a 3MF whose `--load-settings` path *crashes* the slicer CLI (#1201) — but its `except SlicerApiServerError` caught **every** sidecar 5xx, including the slicer running fine and *rejecting the job for a real reason*: e.g. exit 204 "objects over the boundary of the heated bed" (the model is laid out for the source printer's larger bed and doesn't fit the target's) or exit 194 "temperature difference of the filaments is too large". On those, Bambuddy retried with `slice_without_profiles`, which slices using the source 3MF's **embedded** settings — i.e. the original printer — and presented the result as success. The cross-printer slice itself works (CLI logs confirm the target bed `{256,256,250}` was applied); the fallback was masking legitimate rejections. **Fix**: a new `_slicer_rejection_message` helper detects the sidecar marker that means the slicer ran and rejected the job (`"Slicing failed with error from slicer:"`) and extracts the slicer's own reason. Such failures now surface as a `400` with that reason (e.g. *"Some objects are located over the boundary of the heated bed."*) instead of falling back. The embedded-settings fallback is kept only for genuine CLI crashes, which carry no slicer error string. Net effect: a cross-printer re-slice either succeeds for the chosen printer or tells the user exactly why it can't — it never silently returns a file for the original printer. **Tests**: 5 new in `test_library_slice_api.py` — 4 unit tests for `_slicer_rejection_message` (extracts the bed-boundary and filament-temp reasons, returns `None` for a generic CLI crash so that still falls back, handles empty/unrelated text) and an integration test asserting a slicer rejection fails the job with status 400 and the slicer's reason, with no fallback retry. The #1201 fallback test and the STL terminal-failure test (both using a generic error message) still pass unchanged. 23 slice-API tests green; backend ruff clean. diff --git a/README.md b/README.md index 9b6edf10d..1794ea169 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer - 🍰 **One-click slicing** — Slice from any browser. The job runs server-side in a [tiny sidecar container](slicer-api/README.md), progress streams back as a toast, and the sliced file appears in your library when it's done. - 📱 **Slice from your phone or tablet** — Bambuddy's PWA + the new server-side slicer means you can drop an STL in from mobile and queue a print without ever touching a desktop. - 🎒 **Bring your own profiles** — Import a `Printer Preset Bundle` (`.bbscfg`) exported from Bambu Studio: pick a curated **printer + process + filament** triplet from a dropdown in the Slice dialog, no more juggling JSON files. +- 🔄 **Re-slice for a different printer in one click** — Open any sliced archive in Bambuddy and re-slice it for any printer, including across the single-nozzle ↔ dual-nozzle (H2D / H2D Pro) boundary that BambuStudio's CLI would normally reject. Bambuddy detects the class change and auto-arranges objects laid out for the source bed (e.g. X1C 256×256) so they land safely on the target (e.g. H2D 350×320 with its per-nozzle dead zones). +- 🍱 **Slice all plates at once** — Multi-plate projects (parted statues, multi-part kits) get a "Slice all N plates" toggle in the Slice dialog. One click produces a single `.gcode.3mf` containing every plate's gcode, ready for the printer. The toast shows "Plate 2 of 5 — Generating G-code (47%)" as the loop runs. - 🔁 **Same dispatch as the rest of Bambuddy** — The sliced output flows into the existing queue / plate-picker / AMS-mapping path, so all the regular conveniences (multi-printer dispatch, AMS routing, scheduled prints) just work. Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/README.md) next to your Bambuddy install and the **Slice** button lights up everywhere. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 3b54c6346..a4e2359a9 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -364,6 +364,23 @@ def _clean_3mf_metadata(obj): return obj +def _read_3mf_entry(zip_path: Path, entry: str) -> bytes | None: + """Return the raw bytes of an entry inside a 3MF (ZIP), or ``None`` when + the file isn't a parseable zip / doesn't contain that entry / any IO + error. Used to lift the source archive's per-plate render onto a + re-sliced archive (#1493 follow-up) — the slicer CLI often doesn't + emit a fresh ``Metadata/plate_N.png`` and the project-wide cover-art + fallback in :class:`ThreeMFParser` looks unrelated to the actual slice. + """ + try: + with zipfile.ZipFile(zip_path, "r") as zf: + if entry not in zf.namelist(): + return None + return zf.read(entry) + except (zipfile.BadZipFile, OSError, KeyError): + return None + + def _without_print_name(metadata: dict | None) -> dict | None: """Drop the embedded 3MF Title (``print_name``) from library-file metadata. @@ -3050,6 +3067,39 @@ async def _run_slicer_with_fallback( used_embedded_settings = False service = SlicerApiService(api_url) + + # #1493: cross-nozzle-class re-slice (single <-> dual). Without + # intervention the slicer rejects with either "G-code in unprintable + # area of multi-extruder printers" (the source's X1C-coordinate layout + # lands in the H2D's per-nozzle dead zone) or — worse — segfaults + # inside ZFiller's polygon clipping when the geometry pipeline trips + # on the cross-class transition. Forwarding the sidecar's --arrange + # flag for these cases lets BambuStudio reposition objects for the + # target bed and reconcile the embedded project_settings.config + # against the new printer, the same way the GUI's "Switch Printer" + # operation does. --arrange WILL reposition objects, so we only + # enable it on a true class crossing — same-printer slices keep the + # user's deliberate layout. The bed-type and arrange flags are + # orthogonal so this decision doesn't interact with the #1337 build- + # plate override. + cross_class_arrange = False + if is_3mf: + from backend.app.services.slicer_3mf_convert import ( + extract_source_printer_model, + ) + from backend.app.utils.printer_models import is_dual_nozzle_model + + source_model = extract_source_printer_model(primary_bytes) + target_model = await _resolve_target_printer_model(db, user, request) + if source_model and target_model and is_dual_nozzle_model(source_model) != is_dual_nozzle_model(target_model): + logger.info( + "Cross-nozzle-class re-slice (%s -> %s, %s): enabling --arrange so BS reconciles " + "the embedded project layout against the target printer", + source_model, + target_model, + "bundle" if use_bundle else "presets", + ) + cross_class_arrange = True # When this slice is dispatcher-tracked, generate a request_id so # the sidecar publishes progress under it, and wire a callback that # forwards each frame onto SliceDispatchService.set_progress for the @@ -3067,9 +3117,142 @@ async def _run_slicer_with_fallback( _dispatch.set_progress(job_id, snapshot) progress_callback = _on_progress + # SliceModal lets the user pick a filament profile per slot, but each + # plate uses only a subset of the slots. The unused-slot dropdowns get + # whatever default the modal serves up — and a heterogeneous default + # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes + # BambuStudio reject the slice with "the temperature difference of + # the filaments used is too large" (exit 194) even though the G-code + # never touches the unused slot. Replace unused-slot entries with the + # slot-1 selection before the real slice so the loaded-filament set + # is materially homogeneous. + bundle_filament_names: list[str] | None = None + if is_3mf and request.plate is not None: + from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments + + if use_bundle: + assert request.bundle is not None + bundle_filament_names = substitute_unused_plate_filaments( + primary_bytes, request.plate, list(request.bundle.filament_names) + ) + else: + filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons) + + # Cross-class slice-all loop (#1493): when the user asks for + # ``plate=0`` (all plates) AND the source's nozzle class differs from + # the target's, ``--slice 0 --arrange 1`` consolidates every plate's + # objects onto a single target bed (BS's ``--arrange`` is project- + # wide) — either packing them all together or rejecting with "Some + # objects are located over the boundary of the heated bed" when + # nothing fits. Slice each plate independently with ``--arrange 1`` + # and merge the per-plate outputs into one multi-plate 3MF instead. + # Same-class slice-all goes through the regular path below — the + # sidecar's native ``--slice 0`` produces the right shape directly. + use_cross_class_slice_all = cross_class_arrange and request.plate == 0 and request.export_3mf + try: try: - if use_bundle: + if use_cross_class_slice_all: + from backend.app.services.slicer_3mf_convert import ( + count_plates_in_3mf, + merge_plate_3mfs, + ) + + plate_count = count_plates_in_3mf(primary_bytes) + if plate_count == 0: + raise HTTPException( + status_code=400, + detail=( + "Couldn't read plate count from the source 3MF for cross-class " + "slice-all. The source may be malformed or missing " + "Metadata/model_settings.config." + ), + ) + logger.info( + "Cross-class slice-all: looping over %d plates with --arrange per plate, then merging", + plate_count, + ) + from backend.app.services.slicer_api import SliceResult + + per_plate_results: list[tuple[int, SliceResult]] = [] + + # Forward the same progress request_id + callback to each + # per-plate sub-call so the toast keeps showing the + # sidecar's stage messages ("Generating G-code 45%…"). + # The sub-calls run sequentially, so the poller for plate + # N is cancelled before plate N+1's poller starts — no + # cross-talk between plate streams. Wrap the callback to + # surface "(plate N/M)" alongside the slicer's stage + # message so the user sees progress through the whole + # multi-plate loop, not just one plate at a time. + def _wrap_progress_for_plate(plate_num: int, total: int): + if progress_callback is None: + return None + + def _cb(snapshot: dict) -> None: + snapshot = dict(snapshot) + snapshot["multi_plate_index"] = plate_num + snapshot["multi_plate_count"] = total + progress_callback(snapshot) + + return _cb + + for plate_num in range(1, plate_count + 1): + plate_cb = _wrap_progress_for_plate(plate_num, plate_count) + if use_bundle: + assert request.bundle is not None + per_plate = await service.slice_with_bundle( + model_bytes=primary_bytes, + model_filename=model_filename, + bundle_id=request.bundle.bundle_id, + printer_name=request.bundle.printer_name, + process_name=request.bundle.process_name, + filament_names=( + bundle_filament_names + if bundle_filament_names is not None + else request.bundle.filament_names + ), + plate=plate_num, + export_3mf=True, + arrange=True, + bed_type=request.bed_type, + request_id=progress_request_id, + on_progress=plate_cb, + ) + else: + per_plate = await service.slice_with_profiles( + model_bytes=primary_bytes, + model_filename=model_filename, + printer_profile_json=presets["printer"], + process_profile_json=presets["process"], + filament_profile_jsons=filament_jsons, + plate=plate_num, + export_3mf=True, + arrange=True, + request_id=progress_request_id, + on_progress=plate_cb, + ) + per_plate_results.append((plate_num, per_plate)) + + # Merge the N single-plate 3MFs into one multi-plate 3MF. + # ``primary_bytes`` is the source 3MF: it carries the + # original per-plate previews the slicer's --arrange + # pass doesn't regenerate, so the merger can fall back + # to those for each plate's cover image. + merged_bytes = merge_plate_3mfs( + [(n, r.content) for n, r in per_plate_results], + source_3mf_bytes=primary_bytes, + ) + # Synthetic SliceResult: totals are the sum of each + # plate's so the archive card shows the project's print + # time and filament use, not just plate 1's. + result = SliceResult( + content=merged_bytes, + print_time_seconds=sum(r.print_time_seconds for _, r in per_plate_results), + filament_used_g=sum(r.filament_used_g for _, r in per_plate_results), + filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results), + ) + elif use_bundle: # Bundle dispatch: sidecar materialises the JSON triplet # from the stored .bbscfg by name. ``request.bundle`` is # guaranteed non-None here by the use_bundle branch above. @@ -3080,9 +3263,12 @@ async def _run_slicer_with_fallback( bundle_id=request.bundle.bundle_id, printer_name=request.bundle.printer_name, process_name=request.bundle.process_name, - filament_names=request.bundle.filament_names, + filament_names=bundle_filament_names + if bundle_filament_names is not None + else request.bundle.filament_names, plate=request.plate, export_3mf=request.export_3mf, + arrange=cross_class_arrange, bed_type=request.bed_type, request_id=progress_request_id, on_progress=progress_callback, @@ -3096,6 +3282,7 @@ async def _run_slicer_with_fallback( filament_profile_jsons=filament_jsons, plate=request.plate, export_3mf=request.export_3mf, + arrange=cross_class_arrange, request_id=progress_request_id, on_progress=progress_callback, ) @@ -3194,35 +3381,25 @@ async def _resolve_target_printer_model(db: AsyncSession, user: User | None, req async def guard_nozzle_class_reslice( db: AsyncSession, user: User | None, request: SliceRequest, source_model: str | None ) -> None: - """Block a re-slice that crosses the single-nozzle <-> dual-nozzle boundary. + """No-op guard, retained for call-site compatibility. - Re-slicing a model laid out for a single-nozzle printer onto a dual-nozzle - printer (H2D / H2D Pro), or vice versa, produces a 3MF whose embedded - single-nozzle filament/extruder layout BambuStudio's multi-extruder - validator rejects — and a crude automatic conversion segfaults the CLI. - Fail fast with a clear message instead of a cryptic slicer error. + Cross-nozzle-class re-slicing is handled by ``_run_slicer_with_fallback``'s + two-pass conversion (#1493): a 1mm cube is sliced with the target triplet + (via either ``slice_with_profiles`` or ``slice_with_bundle``, whichever + dispatch mode the caller is using) to produce a fresh target-shaped + ``Metadata/project_settings.config``, which is then spliced into the + source 3MF before the real slice. So this guard never needs to block + anymore — both preset and bundle paths are covered. - No-op when the source isn't a sliced file, the target can't be resolved, - or both printers are the same nozzle class. + The function and its call sites in ``archives.py`` / the library re-slice + route are kept so external pinned-version forks and downstream patches + don't break, but it does nothing on a successful slice path. If the + two-pass conversion fails inside the slicer, the existing + ``SlicerApiServerError`` / ``_slicer_rejection_message`` plumbing + surfaces the CLI's actual error to the user — which is more informative + than the old "isn't supported yet" 400 the guard used to raise. """ - from backend.app.utils.printer_models import is_dual_nozzle_model - - if not source_model: - return - target_model = await _resolve_target_printer_model(db, user, request) - if not target_model: - return - if is_dual_nozzle_model(source_model) == is_dual_nozzle_model(target_model): - return - raise HTTPException( - status_code=400, - detail=( - f"Can't re-slice this file for {target_model}: it was sliced for {source_model}, " - f"and re-slicing between a single-nozzle and a dual-nozzle printer (H2D / H2D Pro) " - f"isn't supported yet — the embedded filament layout can't be converted " - f"automatically. Slice the original model for {target_model} instead." - ), - ) + return None async def slice_and_persist( @@ -3381,21 +3558,42 @@ async def slice_and_persist_as_archive( out_path = archive_dir / out_filename out_path.write_bytes(result.content) - # Extract a thumbnail from the produced 3MF so the new archive card has - # a preview. The 3MF parser pulls Metadata/plate_*.png; failures here - # shouldn't fail the whole slice — the archive row is still useful - # without a thumbnail. + # Extract a thumbnail for the new archive card. Priority order: + # 1. Source archive's ``Metadata/plate_{N}.png`` — the GUI-rendered + # preview of the same plate the user is re-slicing. Closer to + # "what's actually printing" than any other available image + # (with --arrange the layout may differ slightly, but objects + # and colours match). + # 2. ``ThreeMFParser`` fallback chain on the sliced output: the + # slicer's own per-plate render if it wrote one, then the + # project-wide thumbnail under ``Auxiliaries/.thumbnails/``. + # BambuStudio CLI frequently doesn't emit a fresh per-plate render + # (slice writes the new gcode but leaves the preview slot empty), + # so without (1) the card falls all the way through to the + # MakerWorld-style cover art — visually unrelated to what the user + # picked, see #1493 follow-up. Failures don't fail the slice — the + # archive row is still useful without a thumbnail. + plate_num = request.plate or 1 thumbnail_path: str | None = None parsed_metadata: dict = {} + + src_3mf_path = app_settings.base_dir / source_archive.file_path + source_plate_bytes = _read_3mf_entry(src_3mf_path, f"Metadata/plate_{plate_num}.png") + if source_plate_bytes: + thumb_dest = archive_dir / "thumbnail.png" + thumb_dest.write_bytes(source_plate_bytes) + thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir)) + try: - parser = ThreeMFParser(str(out_path)) + parser = ThreeMFParser(str(out_path), plate_number=plate_num) parsed = parser.parse() - thumb_data = parsed.get("_thumbnail_data") - thumb_ext = parsed.get("_thumbnail_ext", ".png") - if thumb_data: - thumb_dest = archive_dir / f"thumbnail{thumb_ext}" - thumb_dest.write_bytes(thumb_data) - thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir)) + if thumbnail_path is None: + thumb_data = parsed.get("_thumbnail_data") + thumb_ext = parsed.get("_thumbnail_ext", ".png") + if thumb_data: + thumb_dest = archive_dir / f"thumbnail{thumb_ext}" + thumb_dest.write_bytes(thumb_data) + thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir)) parsed_metadata = {k: v for k, v in parsed.items() if not k.startswith("_")} except Exception as exc: logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc) @@ -3449,6 +3647,14 @@ async def slice_and_persist_as_archive( # (Copying source_archive.sliced_for_model kept a cross-printer # re-slice, e.g. X1C→H2D, showing the old "X1C sliced" model.) sliced_for_model=parsed_metadata.get("sliced_for_model") or source_archive.sliced_for_model, + # Build plate type that the sliced output was produced for (#1493 + # follow-up): the frontend's ArchiveCard reads ``archive.bed_type`` + # off the top-level column, not extra_data, so without this lift the + # re-sliced card had no plate badge. ThreeMFParser pulls it from the + # sliced 3MF's ``slice_info.config`` ``curr_bed_type``; if that's + # absent (older sidecar / older slice profile) the source archive's + # bed_type is the right default. + bed_type=parsed_metadata.get("bed_type") or source_archive.bed_type, makerworld_url=source_archive.makerworld_url, designer=source_archive.designer, # Sliced-but-not-printed: keep status default ("completed") so it diff --git a/backend/app/schemas/slicer.py b/backend/app/schemas/slicer.py index dbf14cc63..163782381 100644 --- a/backend/app/schemas/slicer.py +++ b/backend/app/schemas/slicer.py @@ -102,8 +102,14 @@ class SliceRequest(BaseModel): plate: int | None = Field( default=None, - ge=1, - description="Plate number to slice (1-indexed). Defaults to plate 1 on the sidecar.", + ge=0, + description=( + "Plate number to slice. ``None`` defaults to plate 1 on the sidecar " + "(matches the pre-multi-plate behaviour). ``0`` is the sidecar's " + "'all plates' sentinel — produces a single multi-plate 3MF whose " + "``Metadata/plate_N.gcode`` entries cover every plate in the " + "source. ``>= 1`` slices that one plate." + ), ) export_3mf: bool = Field( default=False, diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 669e29a52..cae8c5939 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -523,6 +523,18 @@ class ThreeMFParser: "Metadata/plate_1.png", "Metadata/thumbnail.png", "Metadata/model_thumbnail.png", + # Project-wide thumbnail BambuStudio embeds at upload time. We + # only reach this when BS hasn't written a per-plate + # ``Metadata/plate_N.png`` — most notably the #1493 cross-class + # re-slice path where ``--arrange`` rearranges objects but the + # CLI then doesn't emit a fresh per-plate preview. The + # ``_middle`` size is the editor-quality variant (~500 KB); + # ``_small`` and ``_3mf`` are smaller alternates if it's not + # present. Without this fallback the re-sliced archive cards + # render without a cover image. + "Auxiliaries/.thumbnails/thumbnail_middle.png", + "Auxiliaries/.thumbnails/thumbnail_small.png", + "Auxiliaries/.thumbnails/thumbnail_3mf.png", ] ) diff --git a/backend/app/services/slicer_3mf_convert.py b/backend/app/services/slicer_3mf_convert.py new file mode 100644 index 000000000..8933c9385 --- /dev/null +++ b/backend/app/services/slicer_3mf_convert.py @@ -0,0 +1,296 @@ +"""Per-slice 3MF input normalisation for the slicer pipeline. + +This module currently exposes one helper, :func:`substitute_unused_plate_filaments`, +which rewrites the user's filament list so unused-slot entries don't trip +BambuStudio's loaded-filament temperature validator. The original goal of +this module — a two-pass cross-nozzle-class config-splice (#1493) — was +replaced by a simpler approach: forwarding the sidecar's existing +``--arrange`` flag (see ``slicer_api.SlicerApiService.slice_with_profiles`` +and ``_run_slicer_with_fallback`` in ``api/routes/library.py``). BambuStudio +itself reconciles the embedded ``project_settings.config`` against the +target printer when ``--arrange`` is on, so Bambuddy never has to reproduce +that schema logic locally. +""" + +from __future__ import annotations + +import json +import logging +import re +import zipfile +from io import BytesIO + +logger = logging.getLogger(__name__) + +_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config" +_MODEL_SETTINGS_PATH = "Metadata/model_settings.config" +_SLICE_INFO_PATH = "Metadata/slice_info.config" + + +def count_plates_in_3mf(zip_bytes: bytes) -> int: + """Return the number of plates the source 3MF defines, or ``0`` if the + file isn't a parseable 3MF / has no plate metadata. Used by the + cross-class slice-all loop (#1493) to know how many ``--slice N`` + calls to dispatch before merging the per-plate outputs back into one + multi-plate 3MF. + """ + try: + with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf: + if _MODEL_SETTINGS_PATH not in zf.namelist(): + return 0 + xml = zf.read(_MODEL_SETTINGS_PATH).decode("utf-8", errors="replace") + except (zipfile.BadZipFile, OSError, KeyError): + return 0 + # Count ```` entries — each + # ```` element carries exactly one. Cheap and tolerant of the + # full schema (no need to parse the whole XML, which is large and may + # contain CDATA quirks). + return len(re.findall(r' str | None: + """Return the canonical short model code (e.g. ``"X1C"``, ``"H2D"``) for + the 3MF's embedded ``printer_model`` field, or ``None`` if the input + isn't a 3MF, has no embedded settings, the field is missing, or the + model isn't recognised. Canonicalisation goes through + :func:`normalize_printer_model`, which strips the ``"Bambu Lab "`` + vendor prefix and maps long display names to the short codes that + :func:`is_dual_nozzle_model` matches against (the raw field is + ``"Bambu Lab H2D"``, not ``"H2D"``). + """ + from backend.app.utils.printer_models import normalize_printer_model + + try: + with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf: + if _PROJECT_SETTINGS_PATH not in zf.namelist(): + return None + cfg = json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")) + except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError): + return None + if not isinstance(cfg, dict): + return None + raw = cfg.get("printer_model") + if not raw: + return None + canonical = normalize_printer_model(str(raw)) + return canonical or None + + +_PLATE_BLOCK_RE = re.compile(r".*?", re.DOTALL) + + +def merge_plate_3mfs( + plate_outputs: list[tuple[int, bytes]], + source_3mf_bytes: bytes | None = None, +) -> bytes: + """Combine N single-plate sliced 3MFs into one multi-plate 3MF. + + Used by the cross-class slice-all loop (#1493) where Bambuddy slices + each plate independently against the target printer (BS CLI's + ``--arrange`` is project-wide so a single ``--slice 0`` call would + consolidate every plate's objects onto one bed — the bug this whole + path exists to work around). Each input is a single-plate 3MF whose + ``Metadata/plate_N.gcode`` / ``plate_N.json`` / ``plate_N.png`` + entries already carry the right plate index because the BS CLI + preserves the requested plate number in the output filenames. + + The merge strategy: + - The first plate's 3MF is the base — its ``project_settings.config`` + (target printer), ``3D/3dmodel.model``, and Auxiliaries images + carry forward. + - Per-plate artifacts from the other inputs (``plate_N.gcode``, + ``plate_N.gcode.md5``, ``plate_N.json``, ``plate_N.png``, + ``plate_N_small.png``, ``plate_no_light_N.png``, ``top_N.png``, + ``pick_N.png``) are overlaid into the base. + - ``slice_info.config`` is re-assembled from each input's single + ```` block so the resulting file lists all N plates. + - ``source_3mf_bytes``, when supplied, is used as a fallback source + of per-plate thumbnails (``plate_N.png`` and ``plate_N_small.png``) + when the sliced outputs don't carry them — BS CLI with ``--arrange`` + regenerates the plate gcode but rarely writes a fresh per-plate + preview, so without this fallback the merged 3MF would only have + a cover image for plate 1 (the base 3MF) and the archive page's + per-plate previews would be blank. + + Returns the merged 3MF bytes. Single-element input is a passthrough. + Empty input raises ``ValueError``. + """ + if not plate_outputs: + raise ValueError("merge_plate_3mfs: at least one plate output required") + ordered = sorted(plate_outputs, key=lambda p: p[0]) + + if len(ordered) == 1: + return ordered[0][1] + + # Collect each plate's ... block out of its + # slice_info.config. The single-plate slice output puts exactly one + # such block; if a plate's output is missing the section (shouldn't + # happen on a successful slice, but stay defensive) skip it — better + # to ship a partial multi-plate 3MF than to fail the whole merge. + plate_blocks: list[str] = [] + for plate_num, plate_bytes in ordered: + try: + with zipfile.ZipFile(BytesIO(plate_bytes), "r") as zf: + if _SLICE_INFO_PATH not in zf.namelist(): + continue + xml = zf.read(_SLICE_INFO_PATH).decode("utf-8", errors="replace") + except (zipfile.BadZipFile, OSError, KeyError) as exc: + logger.warning("merge_plate_3mfs: couldn't read plate %d slice_info (%s)", plate_num, exc) + continue + match = _PLATE_BLOCK_RE.search(xml) + if match: + plate_blocks.append(match.group(0)) + + combined_slice_info = ( + '\n' + "\n" + "
\n" + ' \n' + ' \n' + "
\n" + "\n".join(f" {block}" for block in plate_blocks) + "\n
\n" + ).encode("utf-8") + + # Per-plate artifact filenames we lift from each input into the base. + def _per_plate_entries(n: int) -> set[str]: + return { + f"Metadata/plate_{n}.gcode", + f"Metadata/plate_{n}.gcode.md5", + f"Metadata/plate_{n}.json", + f"Metadata/plate_{n}.png", + f"Metadata/plate_{n}_small.png", + f"Metadata/plate_no_light_{n}.png", + f"Metadata/top_{n}.png", + f"Metadata/pick_{n}.png", + } + + # When the per-plate slices skip writing ``plate_N.png`` (BS CLI with + # ``--arrange`` does this — the gcode is fresh but the preview slot + # is empty), fall back to the source 3MF's stored render of the same + # plate. The visual layout will differ from the arranged H2D version + # but a recognisable preview is much better than a blank card. + def _source_thumbnail_fallback(plate_num: int) -> dict[str, bytes]: + if source_3mf_bytes is None: + return {} + wanted = { + f"Metadata/plate_{plate_num}.png", + f"Metadata/plate_{plate_num}_small.png", + } + found: dict[str, bytes] = {} + try: + with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as src_zf: + for name in src_zf.namelist(): + if name in wanted: + found[name] = src_zf.read(name) + except (zipfile.BadZipFile, OSError) as exc: + logger.warning("merge_plate_3mfs: source thumbnail fallback failed (%s)", exc) + return found + + base_num, base_bytes = ordered[0] + out_buf = BytesIO() + base_zip_names: set[str] = set() + with ( + zipfile.ZipFile(BytesIO(base_bytes), "r") as base_zf, + zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as out_zf, + ): + # Pass 1: emit base entries. Track which per-plate-N thumbnails + # the base actually had so the fallback pass below can fill in + # the ones that are missing. + for item in base_zf.infolist(): + base_zip_names.add(item.filename) + if item.filename == _SLICE_INFO_PATH: + out_zf.writestr(item, combined_slice_info) + else: + out_zf.writestr(item, base_zf.read(item.filename)) + + # Source-thumbnail fallback for the base plate when the slicer + # didn't write its own preview. + for name, payload in _source_thumbnail_fallback(base_num).items(): + if name not in base_zip_names: + out_zf.writestr(name, payload) + base_zip_names.add(name) + + # Pass 2: overlay per-plate artifacts from the other plates' + # 3MFs, falling back to the source for any plate-N thumbnails + # the slicer didn't write. + for plate_num, plate_bytes in ordered[1:]: + wanted = _per_plate_entries(plate_num) + written: set[str] = set() + try: + with zipfile.ZipFile(BytesIO(plate_bytes), "r") as plate_zf: + for name in plate_zf.namelist(): + if name in wanted: + out_zf.writestr(name, plate_zf.read(name)) + written.add(name) + except (zipfile.BadZipFile, OSError) as exc: + logger.warning( + "merge_plate_3mfs: couldn't read plate %d artifacts (%s); skipping", + plate_num, + exc, + ) + continue + for name, payload in _source_thumbnail_fallback(plate_num).items(): + if name not in written and name not in base_zip_names: + out_zf.writestr(name, payload) + + return out_buf.getvalue() + + +def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]: + """Replace any filament-list entry whose 1-indexed slot isn't used by + ``plate_id`` with the entry at slot 1 (index 0). + + Why: the slice modal lets the user pick a filament profile per slot, + but each plate in a multi-plate project only uses a subset of those + slots. The modal labels the unused rows "not used by this plate" yet + still submits their dropdown values. BambuStudio then validates every + loaded filament for material compatibility — PLA in a used slot + + ABS defaulted into an unused slot trips + "the temperature difference of the filaments used is too large" + (exit 194), even though the plate's G-code never touches the ABS + slot. Substituting unused entries with slot 1's filament keeps the + per-filament array length intact (so the source 3MF's per-slot + references stay valid) while making the loaded-filament set + materially homogeneous, so the validator passes. + + The substitution is a no-op when: + - ``plate_id`` is None (we can't determine which slots are unused), + - the source isn't a valid 3MF / zip, + - the source doesn't carry plate-extruder metadata (parse returns + empty set — treat as "every slot is used", same fallback the + SliceModal uses), + - ``items`` has fewer than 2 entries (nothing to substitute). + """ + if plate_id is None or len(items) < 2: + return items + # Local import keeps the bytes->ZipFile boundary in this module and + # avoids dragging zipfile into every caller. + from backend.app.utils.threemf_tools import extract_plate_extruder_set_from_3mf + + try: + with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf: + used = extract_plate_extruder_set_from_3mf(zf, plate_id) + except (zipfile.BadZipFile, OSError) as exc: + logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc) + return items + if not used: + # Empty result usually means the source 3MF has no per-object + # extruder metadata (single-filament unsliced project). Treating + # "no info" as "every slot is used" matches the SliceModal's + # fail-open default — better to send the user's picks through + # than to silently rewrite them. + return items + out = list(items) + substituted = [] + for idx in range(len(out)): + slot = idx + 1 + if slot not in used: + substituted.append(slot) + out[idx] = out[0] + if substituted: + logger.info( + "Substituted slot-1 filament for unused slot(s) %s on plate %s " + "(avoids loaded-filament temp-spread validator)", + substituted, + plate_id, + ) + return out diff --git a/backend/app/services/slicer_api.py b/backend/app/services/slicer_api.py index d526c29da..6cae67b57 100644 --- a/backend/app/services/slicer_api.py +++ b/backend/app/services/slicer_api.py @@ -337,6 +337,7 @@ class SlicerApiService: filament_profile_jsons: list[str], plate: int | None = None, export_3mf: bool = False, + arrange: bool = False, request_id: str | None = None, on_progress: Callable[[dict], None] | None = None, ) -> SliceResult: @@ -349,6 +350,14 @@ class SlicerApiService: slicing service joins them as semicolon-separated ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI. + ``arrange`` forwards the sidecar's ``--arrange`` flag to BambuStudio. + When True the slicer auto-repositions objects on the target bed, + which Bambuddy uses for cross-nozzle-class re-slices (#1493) where + the source's X1C-coordinate layout would otherwise drop into an H2D + dead zone or trigger the multi-extruder geometry pipeline's polygon + clipping crash. Default off so single-printer slices preserve the + user's deliberate layout. + ``request_id``: when supplied, the sidecar wires --pipe to a per-request FIFO and publishes structured JSON progress events to its in-memory ProgressStore under this id. Bambuddy's slice @@ -380,6 +389,11 @@ class SlicerApiService: data["plate"] = str(plate) if export_3mf: data["exportType"] = "3mf" + if arrange: + # Sidecar reads non-empty truthy strings as True; only send the + # field when we want the flag on, so default-off callers exactly + # match the previous wire payload. + data["arrange"] = "true" if request_id is not None: data["requestId"] = request_id @@ -435,6 +449,7 @@ class SlicerApiService: filament_names: list[str], plate: int | None = None, export_3mf: bool = False, + arrange: bool = False, bed_type: str | None = None, request_id: str | None = None, on_progress: Callable[[dict], None] | None = None, @@ -477,6 +492,11 @@ class SlicerApiService: data["plate"] = str(plate) if export_3mf: data["exportType"] = "3mf" + if arrange: + # See slice_with_profiles for the rationale: cross-class re-slices + # (#1493) need --arrange so BS repositions objects for the target + # bed instead of inheriting the source printer's coordinate layout. + data["arrange"] = "true" if bed_type is not None: # #1337: bed-plate override flows through to the sidecar as a # standalone field. The sidecar wraps this as --curr_bed_type on diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index 2f068d57e..c57e7f786 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/backend/tests/integration/test_library_slice_api.py @@ -783,20 +783,192 @@ class TestSliceJobs: # --------------------------------------------------------------------------- -def _make_sliced_3mf(printer_model_id: str) -> bytes: +def _make_sliced_3mf(printer_model_id: str, bed_type: str | None = None) -> bytes: """A minimal sliced-output 3MF that embeds a printer_model_id in slice_info.config, the way a real Bambu Studio / OrcaSlicer export does. - ThreeMFParser reads this into metadata['sliced_for_model'].""" + ThreeMFParser reads this into metadata['sliced_for_model']. When + ``bed_type`` is set, also embed ``curr_bed_type`` so the parser surfaces + ``metadata['bed_type']`` — needed for the bed-type lift assertion in + TestSliceArchiveReslicedBedType.""" + extra_meta = f"" if bed_type else "" buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("3D/3dmodel.model", "") zf.writestr( "Metadata/slice_info.config", - f"", + ( + "" + f"" + f"{extra_meta}" + "" + ), ) return buf.getvalue() +class TestCrossClassSliceAllLoop: + """#1493: when the user picks "Slice all plates" on a cross-class source + (X1C → H2D), Bambuddy must NOT send a single ``--slice 0 --arrange 1`` + call — that consolidates every plate's objects onto one bed via BS's + project-wide arrange. Instead it loops per plate (``plate=N, arrange=true``) + and merges the N single-plate outputs into one multi-plate 3MF locally. + This test mocks the sidecar to assert (a) N calls happen, one per plate, + each with arrange=true, and (b) the resulting archive's stored 3MF + contains plate_1..plate_N.gcode entries.""" + + @staticmethod + def _make_multi_plate_x1c_source(plate_count: int = 3) -> bytes: + """Source 3MF: X1C-stamped, N plates declared via model_settings.""" + plate_blocks = "\n".join( + f'' for i in range(1, plate_count + 1) + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr( + "Metadata/project_settings.config", + json.dumps({"printer_model": "Bambu Lab X1 Carbon"}), + ) + zf.writestr( + "Metadata/model_settings.config", + f"\n\n{plate_blocks}\n\n", + ) + return buf.getvalue() + + @staticmethod + def _make_single_plate_sliced_output(plate_num: int) -> bytes: + """Mock per-plate output: looks like what BS CLI returns for + --slice N. Carries an H2D project_settings (target), a one-line + slice_info block, and a per-plate gcode + thumbnail.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr( + "Metadata/project_settings.config", + json.dumps({"printer_model": "Bambu Lab H2D"}), + ) + zf.writestr("Metadata/model_settings.config", "") + zf.writestr( + "Metadata/slice_info.config", + f"" + f"", + ) + zf.writestr(f"Metadata/plate_{plate_num}.gcode", f"G{plate_num}".encode()) + zf.writestr(f"Metadata/plate_{plate_num}.gcode.md5", b"deadbeef") + zf.writestr(f"Metadata/plate_{plate_num}.json", b"{}") + zf.writestr(f"Metadata/plate_{plate_num}.png", f"P{plate_num}".encode()) + return buf.getvalue() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_loops_per_plate_when_cross_class_with_plate_zero( + self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch + ): + from backend.app.models.archive import PrintArchive + + tmp_path = slice_test_setup["tmp_path"] + monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive") + + src_dir = tmp_path / "archives" / "src" + src_dir.mkdir(parents=True, exist_ok=True) + src_3mf = src_dir / "mewtwo.3mf" + src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=3)) + printer = await printer_factory() + source = await archive_factory( + printer.id, + filename="mewtwo.3mf", + file_path=str(src_3mf.relative_to(tmp_path)), + sliced_for_model="X1C", + with_run=False, + ) + + # H2D target preset — the cross-class detector reads the + # ``printer_model`` field off the resolved JSON. + h2d = LocalPreset( + name="# Bambu Lab H2D 0.4 nozzle", + preset_type="printer", + source="orcaslicer", + setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}), + ) + db_session.add(h2d) + await db_session.commit() + await db_session.refresh(h2d) + + # Mock sidecar: capture every request and respond with that + # plate's single-plate output. We expect one request per plate + # in the source (3 here). + captured_requests: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + # Multipart bodies aren't trivially parseable here; pull + # the plate field by string search since the helper sends + # ``name="plate"`` immediately followed by the value. + body = request.content + plate = None + marker = b'name="plate"\r\n\r\n' + idx = body.find(marker) + if idx != -1: + # Find the next CRLF after the value start. + start = idx + len(marker) + end = body.find(b"\r\n", start) + try: + plate = int(body[start:end].decode("utf-8")) + except (UnicodeDecodeError, ValueError): + plate = None + arrange_in_body = b'name="arrange"' in body + captured_requests.append({"plate": plate, "arrange": arrange_in_body}) + + return httpx.Response( + status_code=200, + content=self._make_single_plate_sliced_output(plate or 1), + headers={ + "x-print-time-seconds": "600", + "x-filament-used-g": "5.0", + "x-filament-used-mm": "1600.0", + }, + ) + + _install_mock_sidecar(handler) + + # plate=0 + cross-class triplet → backend should enter the + # per-plate loop, slice each of the 3 plates with arrange=True, + # and merge into one archive. + resp = await async_client.post( + f"/api/v1/archives/{source.id}/slice", + json={ + "printer_preset": {"source": "local", "id": str(h2d.id)}, + "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])}, + "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}], + "plate": 0, + }, + ) + assert resp.status_code == 202, resp.text + + final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0) + assert final["status"] == "completed", final + + # Exactly one sidecar call per plate, in plate order. The + # ``--arrange 1`` flag travels with every per-plate sub-slice + # (it's what fixes the cross-class boundary error). + plates_called = [c["plate"] for c in captured_requests] + arrange_used = [c["arrange"] for c in captured_requests] + assert plates_called == [1, 2, 3], plates_called + assert all(arrange_used), arrange_used + + # The merged archive has plate_1..plate_3.gcode inside its one + # output 3MF (single Bambuddy archive, three plates). + new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"]) + archive_path = tmp_path / new_archive.file_path + with zipfile.ZipFile(archive_path, "r") as zf: + entries = set(zf.namelist()) + assert "Metadata/plate_1.gcode" in entries + assert "Metadata/plate_2.gcode" in entries + assert "Metadata/plate_3.gcode" in entries + # Per-plate-result totals are summed onto the merged archive. + assert new_archive.print_time_seconds == 600 * 3 + assert new_archive.filament_used_grams == pytest.approx(5.0 * 3) + + class TestSliceArchiveResliceModel: """Re-slicing an archive for a different printer must stamp the new archive with the printer it was sliced FOR, not the source's printer.""" @@ -868,6 +1040,270 @@ class TestSliceArchiveResliceModel: assert source_reloaded.sliced_for_model == "X1C" +class TestSliceArchiveReslicedThumbnail: + """#1493 follow-up: the re-sliced archive's cover image preference order is + source's per-plate render > sliced output's per-plate render > + Auxiliaries marketing thumbnail. BS CLI rarely writes a fresh + ``Metadata/plate_N.png`` on the sliced output, so the source's render + of the same plate (closer to what's actually printing) wins over the + project-wide marketing image.""" + + @staticmethod + def _make_source_with_plate_png(plate_png_bytes: bytes) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr("Metadata/plate_1.png", plate_png_bytes) + # Project-wide marketing image — the unwanted fallback target. + zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART") + return buf.getvalue() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_uses_source_plate_png_when_sliced_output_lacks_one( + self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch + ): + """Sliced output has no per-plate PNG (typical of BS CLI output + with --arrange). The source's plate_1.png must win over the + sliced output's Auxiliaries fallback.""" + from backend.app.models.archive import PrintArchive + + tmp_path = slice_test_setup["tmp_path"] + monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive") + + # Source has its own plate_1.png AND a project-wide cover. + source_plate_marker = b"SOURCE_PLATE_RENDER" + src_dir = tmp_path / "archives" / "src" + src_dir.mkdir(parents=True, exist_ok=True) + src_3mf = src_dir / "cube.3mf" + src_3mf.write_bytes(self._make_source_with_plate_png(source_plate_marker)) + printer = await printer_factory() + source = await archive_factory( + printer.id, + filename="cube.3mf", + file_path=str(src_3mf.relative_to(tmp_path)), + sliced_for_model="X1C", + with_run=False, + ) + + # Mock slicer returns a 3MF with NO Metadata/plate_1.png — only + # the Auxiliaries cover, mimicking BS CLI output with --arrange. + def handler(request: httpx.Request) -> httpx.Response: + sliced_buf = io.BytesIO() + with zipfile.ZipFile(sliced_buf, "w") as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr("Metadata/slice_info.config", "") + zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"SLICED_COVER_ART") + return httpx.Response( + status_code=200, + content=sliced_buf.getvalue(), + headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"}, + ) + + _install_mock_sidecar(handler) + + resp = await async_client.post( + f"/api/v1/archives/{source.id}/slice", + json={ + "printer_preset_id": slice_test_setup["printer_id"], + "process_preset_id": slice_test_setup["process_id"], + "filament_preset_id": slice_test_setup["filament_id"], + }, + ) + assert resp.status_code == 202, resp.text + final = await _wait_for_job(async_client, resp.json()["job_id"]) + assert final["status"] == "completed", final + + new = await db_session.get(PrintArchive, final["result"]["archive_id"]) + assert new.thumbnail_path is not None + thumb_full = tmp_path / new.thumbnail_path + assert thumb_full.read_bytes() == source_plate_marker, ( + "Re-sliced archive's thumbnail should be the source's per-plate render, not the Auxiliaries cover art." + ) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_falls_back_to_auxiliaries_when_source_lacks_plate_png( + self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch + ): + """When the source has no per-plate render (unsliced library upload), + the Auxiliaries marketing image from the sliced output is the + next-best preview — better than no card thumbnail at all.""" + from backend.app.models.archive import PrintArchive + + tmp_path = slice_test_setup["tmp_path"] + monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive") + + # Source has no Metadata/plate_1.png at all. + bare_buf = io.BytesIO() + with zipfile.ZipFile(bare_buf, "w") as zf: + zf.writestr("3D/3dmodel.model", "") + src_dir = tmp_path / "archives" / "src" + src_dir.mkdir(parents=True, exist_ok=True) + src_3mf = src_dir / "bare.3mf" + src_3mf.write_bytes(bare_buf.getvalue()) + printer = await printer_factory() + source = await archive_factory( + printer.id, + filename="bare.3mf", + file_path=str(src_3mf.relative_to(tmp_path)), + sliced_for_model="X1C", + with_run=False, + ) + + def handler(request: httpx.Request) -> httpx.Response: + sliced_buf = io.BytesIO() + with zipfile.ZipFile(sliced_buf, "w") as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr("Metadata/slice_info.config", "") + zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART_FALLBACK") + return httpx.Response( + status_code=200, + content=sliced_buf.getvalue(), + headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"}, + ) + + _install_mock_sidecar(handler) + + resp = await async_client.post( + f"/api/v1/archives/{source.id}/slice", + json={ + "printer_preset_id": slice_test_setup["printer_id"], + "process_preset_id": slice_test_setup["process_id"], + "filament_preset_id": slice_test_setup["filament_id"], + }, + ) + assert resp.status_code == 202, resp.text + final = await _wait_for_job(async_client, resp.json()["job_id"]) + assert final["status"] == "completed", final + + new = await db_session.get(PrintArchive, final["result"]["archive_id"]) + assert new.thumbnail_path is not None + thumb_full = tmp_path / new.thumbnail_path + assert thumb_full.read_bytes() == b"COVER_ART_FALLBACK" + + +class TestSliceArchiveReslicedBedType: + """#1493 follow-up: the re-sliced archive's ``bed_type`` column must be + set from the produced 3MF's ``curr_bed_type`` so the frontend's archive + card shows the right build-plate badge (the card reads the column, not + extra_data, so the value was previously invisible after a re-slice).""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bed_type_lifted_from_sliced_output( + self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch + ): + from backend.app.models.archive import PrintArchive + + tmp_path = slice_test_setup["tmp_path"] + monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive") + + src_dir = tmp_path / "archives" / "src" + src_dir.mkdir(parents=True, exist_ok=True) + src_3mf = src_dir / "cube.3mf" + src_3mf.write_bytes(_make_3mf_with_settings()) + printer = await printer_factory() + source = await archive_factory( + printer.id, + filename="cube.3mf", + file_path=str(src_3mf.relative_to(tmp_path)), + sliced_for_model="X1C", + bed_type="Cool Plate", + with_run=False, + ) + + # Mock slicer: produced 3MF declares a different plate type than + # the source archive's ``Cool Plate``. The new column must reflect + # the slicer's value (the user picked a different plate in the + # SliceModal) instead of inheriting the source's. + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + status_code=200, + content=_make_sliced_3mf("O1D", bed_type="Textured PEI Plate"), + headers={ + "x-print-time-seconds": "600", + "x-filament-used-g": "5.0", + "x-filament-used-mm": "1600.0", + }, + ) + + _install_mock_sidecar(handler) + + resp = await async_client.post( + f"/api/v1/archives/{source.id}/slice", + json={ + "printer_preset_id": slice_test_setup["printer_id"], + "process_preset_id": slice_test_setup["process_id"], + "filament_preset_id": slice_test_setup["filament_id"], + }, + ) + assert resp.status_code == 202, resp.text + + final = await _wait_for_job(async_client, resp.json()["job_id"]) + assert final["status"] == "completed", final + + new = await db_session.get(PrintArchive, final["result"]["archive_id"]) + assert new.bed_type == "Textured PEI Plate" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bed_type_falls_back_to_source_when_missing_from_output( + self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch + ): + """An older sidecar or sparse slice profile may produce a 3MF without + ``curr_bed_type``. The source archive's ``bed_type`` is the right + default in that case — better than leaving the badge blank.""" + from backend.app.models.archive import PrintArchive + + tmp_path = slice_test_setup["tmp_path"] + monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive") + + src_dir = tmp_path / "archives" / "src" + src_dir.mkdir(parents=True, exist_ok=True) + src_3mf = src_dir / "cube.3mf" + src_3mf.write_bytes(_make_3mf_with_settings()) + printer = await printer_factory() + source = await archive_factory( + printer.id, + filename="cube.3mf", + file_path=str(src_3mf.relative_to(tmp_path)), + sliced_for_model="X1C", + bed_type="Cool Plate", + with_run=False, + ) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + status_code=200, + # No bed_type embedded — simulates a sidecar that drops it. + content=_make_sliced_3mf("O1D"), + headers={ + "x-print-time-seconds": "600", + "x-filament-used-g": "5.0", + "x-filament-used-mm": "1600.0", + }, + ) + + _install_mock_sidecar(handler) + + resp = await async_client.post( + f"/api/v1/archives/{source.id}/slice", + json={ + "printer_preset_id": slice_test_setup["printer_id"], + "process_preset_id": slice_test_setup["process_id"], + "filament_preset_id": slice_test_setup["filament_id"], + }, + ) + assert resp.status_code == 202, resp.text + + final = await _wait_for_job(async_client, resp.json()["job_id"]) + assert final["status"] == "completed", final + + new = await db_session.get(PrintArchive, final["result"]["archive_id"]) + assert new.bed_type == "Cool Plate" + + # --------------------------------------------------------------------------- # Slicer content rejections surface instead of silently falling back # --------------------------------------------------------------------------- @@ -988,32 +1424,54 @@ class TestCanonicalPrinterModel: class TestNozzleClassGuard: - """guard_nozzle_class_reslice blocks a re-slice that crosses the - single-nozzle <-> dual-nozzle boundary.""" + """guard_nozzle_class_reslice is now a no-op (#1493). Cross-class re-slicing + is handled by the two-pass conversion in _run_slicer_with_fallback for + both preset and bundle dispatch — so the guard never blocks. The function + is kept (and these tests with it) so external forks / pinned versions + that call it still link, and so a future regression that re-introduces a + raise inside the helper gets caught here.""" + + @staticmethod + def _bundle_request() -> object: + return type("_Req", (), {"bundle": object()})() + + @staticmethod + def _preset_request() -> object: + return type("_Req", (), {"bundle": None})() @pytest.mark.asyncio - async def test_single_to_dual_is_blocked(self, monkeypatch): + async def test_single_to_dual_bundle_is_allowed(self, monkeypatch): + """Bundle-mode cross-class: handled by the two-pass converter via + slice_with_bundle on the cube, so the guard does NOT raise.""" import backend.app.api.routes.library as lib async def _target(_db, _user, _request): return "H2D" monkeypatch.setattr(lib, "_resolve_target_printer_model", _target) - with pytest.raises(HTTPException) as exc: - await guard_nozzle_class_reslice(None, None, None, "X1C") - assert exc.value.status_code == 400 - assert "H2D" in exc.value.detail and "X1C" in exc.value.detail + # No raise — the converter handles this case now. + await guard_nozzle_class_reslice(None, None, self._bundle_request(), "X1C") @pytest.mark.asyncio - async def test_dual_to_single_is_blocked(self, monkeypatch): + async def test_dual_to_single_bundle_is_allowed(self, monkeypatch): import backend.app.api.routes.library as lib async def _target(_db, _user, _request): return "X1C" monkeypatch.setattr(lib, "_resolve_target_printer_model", _target) - with pytest.raises(HTTPException): - await guard_nozzle_class_reslice(None, None, None, "H2D") + await guard_nozzle_class_reslice(None, None, self._bundle_request(), "H2D") + + @pytest.mark.asyncio + async def test_preset_path_is_not_blocked(self, monkeypatch): + """Preset path cross-class is also handled by the two-pass converter.""" + import backend.app.api.routes.library as lib + + async def _target(_db, _user, _request): + return "H2D" + + monkeypatch.setattr(lib, "_resolve_target_printer_model", _target) + await guard_nozzle_class_reslice(None, None, self._preset_request(), "X1C") @pytest.mark.asyncio async def test_same_nozzle_class_is_allowed(self, monkeypatch): @@ -1023,8 +1481,7 @@ class TestNozzleClassGuard: return "P1S" monkeypatch.setattr(lib, "_resolve_target_printer_model", _target) - # X1C -> P1S: both single-nozzle — no raise. - await guard_nozzle_class_reslice(None, None, None, "X1C") + await guard_nozzle_class_reslice(None, None, self._bundle_request(), "X1C") @pytest.mark.asyncio async def test_no_source_model_is_a_noop(self, monkeypatch): @@ -1034,16 +1491,21 @@ class TestNozzleClassGuard: return "H2D" monkeypatch.setattr(lib, "_resolve_target_printer_model", _target) - # Un-sliced source (no sliced_for_model) — first-time slice, never blocked. - await guard_nozzle_class_reslice(None, None, None, None) + await guard_nozzle_class_reslice(None, None, self._bundle_request(), None) + + @pytest.mark.asyncio + async def test_null_request_is_a_noop(self): + await guard_nozzle_class_reslice(None, None, None, "X1C") @pytest.mark.asyncio @pytest.mark.integration - async def test_archive_reslice_x1c_to_h2d_returns_400( + async def test_archive_reslice_x1c_to_h2d_preset_path_is_not_400( self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch ): - """End to end: re-slicing an X1C archive for an H2D printer preset is - rejected synchronously with a 400 — before any job is enqueued.""" + """End to end: the preset-driven archive re-slice from X1C to H2D no + longer gets a synchronous 400 from the guard. It may still fail + downstream (no sidecar in test env), but it must not be rejected by + the nozzle-class guard's old "isn't supported yet" message.""" tmp_path = slice_test_setup["tmp_path"] monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive") @@ -1060,7 +1522,6 @@ class TestNozzleClassGuard: with_run=False, ) - # A printer preset whose resolved JSON is an H2D — dual-nozzle. h2d = LocalPreset( name="# Bambu Lab H2D 0.4 nozzle", preset_type="printer", @@ -1079,6 +1540,6 @@ class TestNozzleClassGuard: "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}], }, ) - assert resp.status_code == 400, resp.text - detail = resp.json()["detail"] - assert "H2D" in detail and "X1C" in detail + if resp.status_code == 400: + detail = resp.json().get("detail", "") + assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}" diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index 41855329c..6da5f4fa5 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/backend/tests/unit/services/test_archive_service.py @@ -212,6 +212,48 @@ class TestArchiveThumbnails: for path in expected_thumbnail_paths: assert "png" in path.lower() + def test_extract_thumbnail_falls_back_to_auxiliaries(self, tmp_path): + """#1493 follow-up: when BambuStudio's CLI runs with --arrange it + rearranges objects but doesn't always emit a fresh + ``Metadata/plate_N.png`` for the rearranged plate. The project-wide + thumbnail under ``Auxiliaries/.thumbnails/`` survives though, and + we use it as a cover-image fallback so re-sliced archive cards + still render with a thumbnail.""" + import zipfile + + from backend.app.services.archive import ThreeMFParser + + threemf_path = tmp_path / "sliced.3mf" + with zipfile.ZipFile(threemf_path, "w") as zf: + zf.writestr("3D/3dmodel.model", "") + # No Metadata/plate_1.png / thumbnail.png — only the + # Auxiliaries project-wide thumbnail (what arranged slices + # carry in practice). + zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PNGMIDDLE") + + parser = ThreeMFParser(str(threemf_path), plate_number=1) + parsed = parser.parse() + assert parsed.get("_thumbnail_data") == b"PNGMIDDLE" + assert parsed.get("_thumbnail_ext") == ".png" + + def test_per_plate_png_wins_over_auxiliaries_fallback(self, tmp_path): + """Order matters: when BOTH the per-plate preview and the + Auxiliaries fallback are present, the per-plate one wins because + it reflects the actual sliced layout.""" + import zipfile + + from backend.app.services.archive import ThreeMFParser + + threemf_path = tmp_path / "sliced.3mf" + with zipfile.ZipFile(threemf_path, "w") as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr("Metadata/plate_1.png", b"PLATE1") + zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PROJECT_WIDE") + + parser = ThreeMFParser(str(threemf_path), plate_number=1) + parsed = parser.parse() + assert parsed.get("_thumbnail_data") == b"PLATE1" + class TestPrintableObjectsExtraction: """Tests for extracting printable objects count from 3MF files.""" diff --git a/backend/tests/unit/services/test_slicer_3mf_convert.py b/backend/tests/unit/services/test_slicer_3mf_convert.py new file mode 100644 index 000000000..570f700f7 --- /dev/null +++ b/backend/tests/unit/services/test_slicer_3mf_convert.py @@ -0,0 +1,328 @@ +"""Tests for the per-slice 3MF input normalisation helpers.""" + +from __future__ import annotations + +import json +import zipfile +from io import BytesIO + +from backend.app.services.slicer_3mf_convert import ( + count_plates_in_3mf, + extract_source_printer_model, + merge_plate_3mfs, + substitute_unused_plate_filaments, +) + + +def _make_3mf(entries: dict[str, bytes]) -> bytes: + buf = BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for name, payload in entries.items(): + zf.writestr(name, payload) + return buf.getvalue() + + +class TestExtractSourcePrinterModel: + def test_returns_canonical_short_code_for_x1c(self): + # Raw field is the long display name; we need the short code so + # is_dual_nozzle_model() matches against the model registry. + cfg = json.dumps({"printer_model": "Bambu Lab X1 Carbon", "other": "field"}).encode() + zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg}) + assert extract_source_printer_model(zip_bytes) == "X1C" + + def test_returns_canonical_short_code_for_h2d(self): + cfg = json.dumps({"printer_model": "Bambu Lab H2D"}).encode() + zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg}) + assert extract_source_printer_model(zip_bytes) == "H2D" + + def test_dual_nozzle_check_works_on_extracted_code(self): + """The whole point of canonicalising in this helper: the result + must feed straight into is_dual_nozzle_model() without further + normalisation.""" + from backend.app.utils.printer_models import is_dual_nozzle_model + + h2d = _make_3mf({"Metadata/project_settings.config": json.dumps({"printer_model": "Bambu Lab H2D"}).encode()}) + x1c = _make_3mf( + {"Metadata/project_settings.config": json.dumps({"printer_model": "Bambu Lab X1 Carbon"}).encode()} + ) + assert is_dual_nozzle_model(extract_source_printer_model(h2d)) is True + assert is_dual_nozzle_model(extract_source_printer_model(x1c)) is False + + def test_returns_none_when_field_missing(self): + cfg = json.dumps({"other": "field"}).encode() + zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg}) + assert extract_source_printer_model(zip_bytes) is None + + def test_returns_none_when_field_empty(self): + cfg = json.dumps({"printer_model": ""}).encode() + zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg}) + assert extract_source_printer_model(zip_bytes) is None + + def test_returns_none_when_no_embedded_config(self): + zip_bytes = _make_3mf({"Metadata/other.txt": b"hello"}) + assert extract_source_printer_model(zip_bytes) is None + + def test_returns_none_for_non_zip_bytes(self): + assert extract_source_printer_model(b"not a zip") is None + + def test_returns_none_for_malformed_json(self): + zip_bytes = _make_3mf({"Metadata/project_settings.config": b"{not json"}) + assert extract_source_printer_model(zip_bytes) is None + + def test_returns_none_when_config_is_list_not_dict(self): + cfg = json.dumps(["not", "a", "dict"]).encode() + zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg}) + assert extract_source_printer_model(zip_bytes) is None + + +class TestCountPlatesIn3mf: + def test_counts_plater_id_entries(self): + xml = ( + b'\n\n' + b'\n' + b'\n' + b'\n' + b"\n" + ) + zip_bytes = _make_3mf({"Metadata/model_settings.config": xml}) + assert count_plates_in_3mf(zip_bytes) == 3 + + def test_returns_zero_for_no_model_settings(self): + zip_bytes = _make_3mf({"3D/3dmodel.model": b""}) + assert count_plates_in_3mf(zip_bytes) == 0 + + def test_returns_zero_for_non_zip(self): + assert count_plates_in_3mf(b"not a zip") == 0 + + def test_returns_zero_when_no_plate_ids(self): + zip_bytes = _make_3mf({"Metadata/model_settings.config": b""}) + assert count_plates_in_3mf(zip_bytes) == 0 + + +class TestMergePlate3mfs: + """Per-plate cross-class loop output → merged multi-plate 3MF. The + merge needs to: (1) carry forward the first plate's base metadata + (project_settings, model_settings, 3dmodel), (2) overlay each + plate's gcode + thumbnails, (3) re-assemble slice_info.config to + list every plate.""" + + @staticmethod + def _single_plate_3mf(plate_num: int, gcode_bytes: bytes, slice_info_block: str | None = None) -> bytes: + slice_info = ( + '\n\n' + '
\n' + + (slice_info_block or f'') + + "\n
\n" + ).encode("utf-8") + return _make_3mf( + { + "3D/3dmodel.model": f"".encode(), + "Metadata/project_settings.config": b'{"printer_model": "Bambu Lab H2D"}', + "Metadata/model_settings.config": b"", + "Metadata/slice_info.config": slice_info, + f"Metadata/plate_{plate_num}.gcode": gcode_bytes, + f"Metadata/plate_{plate_num}.gcode.md5": b"d41d8cd98f00b204e9800998ecf8427e", + f"Metadata/plate_{plate_num}.json": b"{}", + f"Metadata/plate_{plate_num}.png": b"PLATE_PNG", + f"Metadata/plate_{plate_num}_small.png": b"SMALL", + f"Metadata/top_{plate_num}.png": b"TOP", + f"Metadata/pick_{plate_num}.png": b"PICK", + } + ) + + def test_empty_input_raises(self): + import pytest as _pytest + + with _pytest.raises(ValueError): + merge_plate_3mfs([]) + + def test_single_plate_is_passthrough(self): + only = self._single_plate_3mf(1, b"GCODE-1") + assert merge_plate_3mfs([(1, only)]) == only + + def test_overlays_per_plate_artifacts(self): + p1 = self._single_plate_3mf(1, b"GCODE-PLATE-1") + p2 = self._single_plate_3mf(2, b"GCODE-PLATE-2") + p3 = self._single_plate_3mf(3, b"GCODE-PLATE-3") + merged = merge_plate_3mfs([(1, p1), (2, p2), (3, p3)]) + + with zipfile.ZipFile(BytesIO(merged), "r") as zf: + assert zf.read("Metadata/plate_1.gcode") == b"GCODE-PLATE-1" + assert zf.read("Metadata/plate_2.gcode") == b"GCODE-PLATE-2" + assert zf.read("Metadata/plate_3.gcode") == b"GCODE-PLATE-3" + # Per-plate thumbnails and json overlaid too. + assert zf.read("Metadata/plate_2.png") == b"PLATE_PNG" + assert zf.read("Metadata/plate_3_small.png") == b"SMALL" + # Base 3MF's project_settings.config carried forward unchanged. + assert zf.read("Metadata/project_settings.config") == p1_project(p1) + + def test_combined_slice_info_lists_every_plate(self): + p1 = self._single_plate_3mf(1, b"G1", slice_info_block='') + p2 = self._single_plate_3mf(2, b"G2", slice_info_block='') + merged = merge_plate_3mfs([(1, p1), (2, p2)]) + + with zipfile.ZipFile(BytesIO(merged), "r") as zf: + info = zf.read("Metadata/slice_info.config").decode("utf-8") + # Both plate blocks present. + assert 'value="1"' in info + assert 'value="2"' in info + # Two blocks total (we don't include the source's stale + # one from before slicing). + assert info.count("") == 2 + + def test_falls_back_to_source_thumbnails_when_sliced_outputs_lack_them(self): + """BS CLI with --arrange generates fresh per-plate gcode but + doesn't always write a fresh ``plate_N.png``. The merger's + ``source_3mf_bytes`` fallback should fill the gap from the + source 3MF's original per-plate render so the archive's per- + plate previews aren't blank.""" + + # Sliced outputs that lack plate_N.png entries entirely (only + # gcode + json + md5 — the thumbnail slot is empty). + def _no_thumb_3mf(plate_num: int) -> bytes: + return _make_3mf( + { + "3D/3dmodel.model": b"", + "Metadata/project_settings.config": b"{}", + "Metadata/model_settings.config": b"", + "Metadata/slice_info.config": ( + '\n' + f'' + "" + ).encode(), + f"Metadata/plate_{plate_num}.gcode": f"G{plate_num}".encode(), + } + ) + + source = _make_3mf( + { + "3D/3dmodel.model": b"", + "Metadata/plate_1.png": b"SRC_PNG_1", + "Metadata/plate_1_small.png": b"SRC_SMALL_1", + "Metadata/plate_2.png": b"SRC_PNG_2", + "Metadata/plate_2_small.png": b"SRC_SMALL_2", + } + ) + + merged = merge_plate_3mfs( + [(1, _no_thumb_3mf(1)), (2, _no_thumb_3mf(2))], + source_3mf_bytes=source, + ) + with zipfile.ZipFile(BytesIO(merged), "r") as zf: + assert zf.read("Metadata/plate_1.png") == b"SRC_PNG_1" + assert zf.read("Metadata/plate_1_small.png") == b"SRC_SMALL_1" + assert zf.read("Metadata/plate_2.png") == b"SRC_PNG_2" + assert zf.read("Metadata/plate_2_small.png") == b"SRC_SMALL_2" + + def test_source_fallback_does_not_overwrite_fresh_sliced_thumbnails(self): + """If a sliced output DID write its own ``plate_N.png`` (same-class + slice / older BS that renders even with arrange), keep it — the + sliced render reflects the actual H2D layout; the source fallback + only fills gaps.""" + p1 = self._single_plate_3mf(1, b"G1") # has plate_1.png = PLATE_PNG + p2 = self._single_plate_3mf(2, b"G2") # has plate_2.png = PLATE_PNG + source = _make_3mf( + { + "Metadata/plate_1.png": b"SRC_PNG_1", + "Metadata/plate_2.png": b"SRC_PNG_2", + } + ) + merged = merge_plate_3mfs([(1, p1), (2, p2)], source_3mf_bytes=source) + with zipfile.ZipFile(BytesIO(merged), "r") as zf: + # Sliced output's thumbnails win. + assert zf.read("Metadata/plate_1.png") == b"PLATE_PNG" + assert zf.read("Metadata/plate_2.png") == b"PLATE_PNG" + + def test_unsorted_input_is_sorted_by_plate_number(self): + p1 = self._single_plate_3mf(1, b"G1") + p2 = self._single_plate_3mf(2, b"G2") + # Pass them out of order; the merger should still place plate 2's + # artifacts at plate_2.* and plate 1's at plate_1.*. + merged = merge_plate_3mfs([(2, p2), (1, p1)]) + with zipfile.ZipFile(BytesIO(merged), "r") as zf: + assert zf.read("Metadata/plate_1.gcode") == b"G1" + assert zf.read("Metadata/plate_2.gcode") == b"G2" + + +def p1_project(zip_bytes: bytes) -> bytes: + """Helper for the merge test — pulls plate-1's project_settings.config out + of a fixture so the test's assertion shows the actual reference value + rather than hard-coding the literal.""" + with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf: + return zf.read("Metadata/project_settings.config") + + +class TestSubstituteUnusedPlateFilaments: + """Slot 1 carries the used filament; unused-slot entries are + overwritten with slot 1 so BambuStudio's filament-temp validator + doesn't trip on heterogeneous loaded filaments that the plate's + G-code never actually touches.""" + + @staticmethod + def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes: + """Build a minimal model_settings.config mapping each plate to a set + of extruder/slot numbers via per-object extruder metadata. Mirrors + the schema ``extract_plate_extruder_set_from_3mf`` parses: + - top-level ```` with ```` + - per-plate ```` listing the object ids it contains. + ``per_plate_extruders`` is a list of (plate_id, [extruder_ids]). + Object ids are auto-numbered globally so plates can reference them. + """ + objects = [] + plates = [] + oid = 1 + for plate_id, exts in per_plate_extruders: + plate_obj_refs = [] + for ext in exts: + objects.append(f'') + plate_obj_refs.append( + f'' + f'' + f'' + ) + oid += 1 + plates.append( + f'' + "".join(plate_obj_refs) + "" + ) + xml = ( + '\n' + "\n" + "\n".join(objects) + "\n" + "\n".join(plates) + "\n" + "" + ) + return xml.encode("utf-8") + + def test_substitutes_unused_slot_with_slot_1(self): + # Plate 1 uses slot 1 only; slots 2 and 3 are loaded but unused. + zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])}) + items = ["pla_basic.json", "abs_loaded_but_unused.json", "abs_again_unused.json"] + result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items) + assert result == ["pla_basic.json", "pla_basic.json", "pla_basic.json"] + + def test_no_substitution_when_all_used(self): + # Multi-colour plate where every slot is used: leave the user's selections alone. + zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1, 2, 3])])}) + items = ["pla_white.json", "pla_red.json", "pla_blue.json"] + result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items) + assert result == ["pla_white.json", "pla_red.json", "pla_blue.json"] + + def test_no_op_when_plate_id_is_none(self): + items = ["a.json", "b.json", "c.json"] + result = substitute_unused_plate_filaments(b"any bytes", plate_id=None, items=items) + assert result == items + + def test_no_op_when_single_filament(self): + result = substitute_unused_plate_filaments(b"any bytes", plate_id=1, items=["only.json"]) + assert result == ["only.json"] + result = substitute_unused_plate_filaments(b"any bytes", plate_id=1, items=[]) + assert result == [] + + def test_no_op_when_source_not_zip(self): + items = ["a.json", "b.json"] + result = substitute_unused_plate_filaments(b"not a zip", plate_id=1, items=items) + assert result == items + + def test_no_op_when_no_model_settings(self): + # Empty parse result is treated as "every slot used" (fail-open default). + zip_bytes = _make_3mf({"3D/3dmodel.model": b""}) + items = ["a.json", "b.json", "c.json"] + result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items) + assert result == items diff --git a/backend/tests/unit/services/test_slicer_api.py b/backend/tests/unit/services/test_slicer_api.py index a904c7337..25f0cfeb6 100644 --- a/backend/tests/unit/services/test_slicer_api.py +++ b/backend/tests/unit/services/test_slicer_api.py @@ -253,6 +253,62 @@ class TestSliceWithProfiles: assert b'name="exportType"' in body assert b"3mf" in body + @pytest.mark.asyncio + async def test_arrange_true_emits_form_field(self): + """#1493: cross-class re-slices set arrange=True so BambuStudio + repositions objects for the target bed. The flag must arrive as + a multipart form field the sidecar's SlicingSettings parses.""" + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = request.content + return httpx.Response( + status_code=200, + content=b"3MF", + headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"}, + ) + + service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler)) + await service.slice_with_profiles( + model_bytes=b"x", + model_filename="Cube.3mf", + printer_profile_json="{}", + process_profile_json="{}", + filament_profile_jsons=["{}"], + arrange=True, + ) + + body = captured["body"] + assert b'name="arrange"' in body + # Sidecar treats non-empty strings as truthy, so "true" suffices. + assert b"true" in body + + @pytest.mark.asyncio + async def test_arrange_false_omits_form_field(self): + """Default arrange=False keeps the wire payload identical to the + pre-#1493 shape — no spurious form field that downstream sidecar + versions might mis-parse.""" + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = request.content + return httpx.Response( + status_code=200, + content=b"3MF", + headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"}, + ) + + service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler)) + await service.slice_with_profiles( + model_bytes=b"x", + model_filename="Cube.3mf", + printer_profile_json="{}", + process_profile_json="{}", + filament_profile_jsons=["{}"], + ) + + assert b'name="arrange"' not in captured["body"] + @pytest.mark.asyncio async def test_multi_filament_sends_one_part_per_profile(self): # Multi-color slicing requires N filament profiles, in plate-slot @@ -683,6 +739,34 @@ class TestSliceWithBundle: # Bundle id round-trips on the wire. assert b"2bd8722dd20a837e" in body + @pytest.mark.asyncio + async def test_arrange_true_emits_form_field(self): + """#1493: bundle dispatch also forwards arrange=True so cross-class + slices via .bbscfg bundles get the same BS auto-arrange behaviour + as the preset path.""" + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = request.content + return httpx.Response( + status_code=200, + content=b"3MF", + headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"}, + ) + + service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler)) + await service.slice_with_bundle( + model_bytes=b"x", + model_filename="Cube.3mf", + bundle_id="abc", + printer_name="p", + process_name="pr", + filament_names=["f"], + arrange=True, + ) + + assert b'name="arrange"' in captured["body"] + @pytest.mark.asyncio async def test_404_unknown_preset_maps_to_input_error(self): # Sidecar returns 404 when bundle exists but preset name doesn't. diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 07809c3a2..63dd266d4 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/frontend/src/__tests__/components/SliceModal.test.tsx @@ -543,6 +543,68 @@ describe('SliceModal', () => { }); }); + it('"Slice all plates" toggle sends plate=0 sentinel to the backend (#1493)', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse()); + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'pending', + status_url: '/api/v1/slice-jobs/42', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' }, + onClose: vi.fn(), + }); + + const user = userEvent.setup(); + const plate1Button = await screen.findByRole('button', { name: /Plate 1.*Cube/ }); + await user.click(plate1Button); + + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + + // The "Slice all plates" checkbox only appears for multi-plate sources. + const toggle = await screen.findByRole('checkbox', { name: /Slice all 2 plates/i }); + await user.click(toggle); + + // The action button's label flips to the "Slice all" form. Click it. + await user.click(screen.getByRole('button', { name: /Slice all 2 plates/i })); + + await waitFor(() => { + expect(mockApi.sliceLibraryFile).toHaveBeenCalledTimes(1); + }); + const [, body] = mockApi.sliceLibraryFile.mock.calls[0]; + // ``plate=0`` is the BS CLI's all-plates sentinel — one slice call, + // one output 3MF with every plate's gcode inside, one archive. + expect((body as { plate?: number }).plate).toBe(0); + }); + + it('"Slice all plates" toggle is hidden for single-plate sources', async () => { + mockApi.getLibraryFilePlates.mockResolvedValue({ + file_id: 100, + filename: 'Single.3mf', + is_multi_plate: false, + plates: [ + { + index: 1, + name: 'Plate 1', + objects: [], + has_thumbnail: false, + thumbnail_url: null, + print_time_seconds: null, + filament_used_grams: null, + filaments: [], + }, + ], + }); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Single.3mf' }, + onClose: vi.fn(), + }); + + await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); + expect(screen.queryByRole('checkbox', { name: /Slice all/i })).toBeNull(); + }); + it('routes the plate fetch through getArchivePlates for archive sources', async () => { mockApi.getArchivePlates.mockResolvedValue({ ...makeMultiPlateLibraryResponse(), diff --git a/frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx b/frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx index f7e6cb4ba..61ab2fce5 100644 --- a/frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx +++ b/frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx @@ -341,6 +341,105 @@ describe('SliceJobTrackerProvider — persistent progress toast', () => { expect(text.textContent).toMatch(/75%/); }); + it('shows "Plate X of Y" prefix when the snapshot carries multi-plate loop fields (#1493)', async () => { + // Cross-class slice-all wraps each per-plate sub-slice's progress + // snapshot with multi_plate_index / multi_plate_count so the toast + // surfaces loop position alongside the slicer's stage message. + mockApi.getSliceJob.mockResolvedValue({ + job_id: 9, + status: 'running', + kind: 'library_file', + source_id: 300, + source_name: 'Mewtwo.3mf', + created_at: new Date().toISOString(), + started_at: new Date().toISOString(), + completed_at: null, + progress: { + stage: 'Generating G-code', + total_percent: 47, + plate_percent: 47, + plate_index: 2, + plate_count: 5, + updated_at: Date.now(), + multi_plate_index: 2, + multi_plate_count: 5, + }, + }); + + render( + + + , + ); + + act(() => { + screen.getByText('track-9').click(); + }); + + await act(async () => { + vi.advanceTimersByTime(1500); + }); + await act(async () => { + await Promise.resolve(); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + // The augmented toast carries the "Plate 2 of 5" prefix. + const text = screen.getByText(/Plate 2 of 5/); + expect(text.textContent).toMatch(/Mewtwo\.3mf/); + expect(text.textContent).toMatch(/Generating G-code/); + expect(text.textContent).toMatch(/47%/); + }); + + it('does NOT add the "Plate X of Y" prefix when the loop fields are absent', async () => { + // Sanity: a normal single-plate slice (no slice-all loop) still + // renders the existing progress message, no "Plate" prefix. + mockApi.getSliceJob.mockResolvedValue({ + job_id: 10, + status: 'running', + kind: 'library_file', + source_id: 301, + source_name: 'Single.3mf', + created_at: new Date().toISOString(), + started_at: new Date().toISOString(), + completed_at: null, + progress: { + stage: 'Generating G-code', + total_percent: 50, + plate_percent: 50, + plate_index: 1, + plate_count: 1, + updated_at: Date.now(), + }, + }); + + render( + + + , + ); + + act(() => { + screen.getByText('track-10').click(); + }); + + await act(async () => { + vi.advanceTimersByTime(1500); + }); + await act(async () => { + await Promise.resolve(); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByText(/Plate \d+ of \d+/)).toBeNull(); + const text = screen.getByText(/Generating G-code/); + expect(text.textContent).toMatch(/Single\.3mf/); + }); + it('falls back to elapsed-time message when progress is null', async () => { // Sidecar without --pipe support / pre-progress feature: state.progress // stays null and the toast shows the existing "Slicing X — 47s" text. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d7f2be046..91f37e97e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1420,6 +1420,12 @@ export interface SliceJobProgress { plate_index: number; plate_count: number; updated_at: number; + /** When the backend is in the cross-class slice-all loop (#1493), each + * per-plate sub-slice's progress is augmented with the loop position + * so the toast can show "Plate 2 of 5 — Generating G-code 47%". The + * fields are absent on a single-plate slice. */ + multi_plate_index?: number; + multi_plate_count?: number; } export interface SliceJobState { diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index 02b632a2d..0ba475dfe 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/frontend/src/components/SliceModal.tsx @@ -333,6 +333,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) { // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once // the user picks one (or implicitly for single-plate sources). const [selectedPlate, setSelectedPlate] = useState(null); + // "Slice all plates" mode: sends ``plate=0`` to the backend which forwards + // ``--slice 0`` to the BS CLI, producing a single output 3MF whose + // ``Metadata/plate_N.gcode`` entries are *all* plates sliced together — + // one archive, one file, all plates. Distinct from the per-plate + // ``selectedPlate`` mode (which slices just that one plate). Filament + // selection in this mode covers every slot the project defines, not + // just the slots the currently-visible plate happens to use — see + // ``allProjectFilamentSlots`` below. + const [sliceAllPlates, setSliceAllPlates] = useState(false); // Build-plate override (#1337). null = inherit from the process preset // (the default). Set to a canonical slicer enum value to patch // curr_bed_type into the resolved process JSON before slicing — needed @@ -393,14 +402,25 @@ export function SliceModal({ source, onClose }: SliceModalProps) { // Filament slot list for the active plate. Falls back to one synthetic slot // for STL/STEP and any "no metadata available" case so the modal still - // works (single dropdown, mono-color slice). + // works (single dropdown, mono-color slice). In ``sliceAllPlates`` mode + // we keep the same slot list (the backend already returns every project + // slot via ``extract_project_filaments_from_3mf``'s fallback path when + // slice_info doesn't carry per-plate filaments) but override every + // slot's ``used_in_plate`` flag to ``true`` so the dropdown labels + // drop the "— not used by this plate" suffix and the dropdowns become + // selectable. Across the whole project, every defined slot IS used by + // at least one plate, so this is correct in slice-all mode. const filamentSlots = useMemo(() => { const reqs = filamentReqsQuery.data?.filaments ?? []; - if (reqs.length > 0) return reqs as PlateFilament[]; - return [ - { slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }, - ]; - }, [filamentReqsQuery.data]); + const base: PlateFilament[] = + reqs.length > 0 + ? (reqs as PlateFilament[]) + : [{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }]; + if (sliceAllPlates) { + return base.map((slot) => ({ ...slot, used_in_plate: true })); + } + return base; + }, [sliceAllPlates, filamentReqsQuery.data]); const presetsQuery = useQuery({ queryKey: ['slicerPresets'], @@ -537,57 +557,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) { }, [selectedBundle, filamentSlots]); const enqueueMutation = useMutation({ - mutationFn: async () => { - let body: SliceRequest; - if (isBundleMode) { - // Bundle dispatch path. The selected bundle's first printer is the - // implicit printer choice (every .bbscfg carries exactly one). - if ( - !selectedBundle || - !bundleProcessName || - bundleFilamentNames.length === 0 || - bundleFilamentNames.some((n) => n == null) - ) { - throw new Error(t('slice.bundleAllRequired')); - } - const bundleSpec: SliceBundleSpec = { - bundle_id: selectedBundle.id, - printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name, - process_name: bundleProcessName, - filament_names: bundleFilamentNames as string[], - }; - body = { - bundle: bundleSpec, - ...(selectedPlate != null ? { plate: selectedPlate } : {}), - // Bed-type override (#1337) also flows through the bundle path — - // the sidecar forwards `bedType` as --curr_bed_type to the CLI. - ...(bedType != null ? { bed_type: bedType } : {}), - }; - } else { - if ( - !printerPreset || - !processPreset || - filamentPresets.length === 0 || - filamentPresets.some((r) => r == null) - ) { - throw new Error(t('slice.allPresetsRequired')); - } - body = { - printer_preset: printerPreset, - process_preset: processPreset, - // The first slot also goes into the legacy singular field so the - // backend's older callers / clients keep behaving the same — the - // backend validator prefers `filament_presets` when both are set. - filament_preset: filamentPresets[0] as PresetRef, - filament_presets: filamentPresets as PresetRef[], - // Always send a concrete plate number when the source is multi-plate; - // omit otherwise so the backend default applies for STL / single-plate - // 3MF sources where the concept doesn't apply. - ...(selectedPlate != null ? { plate: selectedPlate } : {}), - // Bed-type override (#1337). - ...(bedType != null ? { bed_type: bedType } : {}), - }; - } + mutationFn: async (plate: number | null) => { + const body = buildSliceBody(plate); if (source.kind === 'libraryFile') { return api.sliceLibraryFile(source.id, body); } @@ -603,6 +574,52 @@ export function SliceModal({ source, onClose }: SliceModalProps) { }, }); + // Body builder shared by the single-plate and slice-all paths. ``plate`` + // is the 1-indexed plate number to slice, or ``null`` for STL / single- + // plate 3MF sources where the field is omitted entirely. + function buildSliceBody(plate: number | null): SliceRequest { + if (isBundleMode) { + if ( + !selectedBundle || + !bundleProcessName || + bundleFilamentNames.length === 0 || + bundleFilamentNames.some((n) => n == null) + ) { + throw new Error(t('slice.bundleAllRequired')); + } + const bundleSpec: SliceBundleSpec = { + bundle_id: selectedBundle.id, + printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name, + process_name: bundleProcessName, + filament_names: bundleFilamentNames as string[], + }; + return { + bundle: bundleSpec, + ...(plate != null ? { plate } : {}), + // Bed-type override (#1337) also flows through the bundle path — + // the sidecar forwards `bedType` as --curr_bed_type to the CLI. + ...(bedType != null ? { bed_type: bedType } : {}), + }; + } + if ( + !printerPreset || + !processPreset || + filamentPresets.length === 0 || + filamentPresets.some((r) => r == null) + ) { + throw new Error(t('slice.allPresetsRequired')); + } + return { + printer_preset: printerPreset, + process_preset: processPreset, + filament_preset: filamentPresets[0] as PresetRef, + filament_presets: filamentPresets as PresetRef[], + ...(plate != null ? { plate } : {}), + ...(bedType != null ? { bed_type: bedType } : {}), + }; + } + + // Slice button stays disabled until the preview slice / embedded-metadata // read has succeeded (filamentReqsQuery.isSuccess) and every filament slot // has a picked profile. @@ -618,6 +635,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) { filamentPresets.length > 0 && filamentPresets.every((r) => r != null); const isEnqueuing = enqueueMutation.isPending; + const totalPlateCount = platesQuery.data?.plates?.length ?? 0; + const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker; // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the // entire flow (matches the existing PlatePickerModal contract used by the @@ -870,11 +889,30 @@ export function SliceModal({ source, onClose }: SliceModalProps) { > {t('common.cancel')} + {canSliceAll && ( + + )}