Browse Source

feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all

  Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
  onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
  failed with "G-code in unprintable area of multi-extruder printers" (the
  source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
  or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
  polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
  it and actually does the conversion by forwarding the sidecar's existing
  --arrange flag when the source and target nozzle classes differ. BS
  itself reconciles the embedded project_settings.config against the new
  printer that way, the same way the GUI's "Switch Printer" operation
  does. The guard becomes a kept-for-compat no-op.

  Slice-all-plates added to the SliceModal: a checkbox for multi-plate
  sources sends plate=0 to the backend, which forwards --slice 0 to the
  BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
  single sidecar call. Cross-class slice-all loops per plate (BS's
  --arrange is project-wide and would otherwise consolidate every plate's
  objects onto one bed) and merges the per-plate outputs into one
  multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
  shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.

  Three side fixes surfaced during testing:
  - substitute_unused_plate_filaments overwrites unused-slot filaments
    with the slot-1 selection before slicing so BS's loaded-filament
    temperature validator doesn't reject a PLA print whose unused slot 2
    defaulted to ABS in the dropdown
  - re-sliced archive thumbnail now prefers the source's per-plate
    render (Metadata/plate_N.png) over the project-wide MakerWorld cover
    art, because BS CLI with --arrange skips writing a fresh per-plate
    preview
  - re-sliced archive bed_type now lifts from the sliced output's
    curr_bed_type onto the PrintArchive column the card actually reads

  Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
  the "all plates" sentinel; SlicerApiService.slice_with_profiles /
  slice_with_bundle take an `arrange` parameter.

  Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
  extract), 3 in test_slicer_api (arrange wire format), 9 in
  test_library_slice_api (guard no-op, bed_type lift, thumbnail
  fallback, new cross-class slice-all loop integration test), 2 in
  test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
  (plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
  prefix). 659 backend + 42 frontend green; backend ruff clean,
  frontend build clean, i18n parity green at 4984 keys × 9 locales.
maziggy 4 days ago
parent
commit
4686d108ef

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 2 - 0
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.

+ 247 - 41
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.
-
-    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.
-
-    No-op when the source isn't a sliced file, the target can't be resolved,
-    or both printers are the same nozzle class.
+    """No-op guard, retained for call-site compatibility.
+
+    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.
+
+    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

+ 8 - 2
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,

+ 12 - 0
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",
             ]
         )
 

+ 296 - 0
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 ``<metadata key="plater_id" value="..."/>`` entries — each
+    # ``<plate>`` 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'<metadata key="plater_id" value="(\d+)"', xml))
+
+
+def extract_source_printer_model(zip_bytes: bytes) -> 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"<plate>.*?</plate>", 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
+      ``<plate>`` 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 <plate>...</plate> 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 = (
+        '<?xml version="1.0" encoding="UTF-8"?>\n'
+        "<config>\n"
+        "  <header>\n"
+        '    <header_item key="X-BBL-Client-Type" value="slicer"/>\n'
+        '    <header_item key="X-BBL-Client-Version" value="02.06.00.51"/>\n'
+        "  </header>\n" + "\n".join(f"  {block}" for block in plate_blocks) + "\n</config>\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

+ 20 - 0
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

+ 485 - 24
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"<metadata key='curr_bed_type' value='{bed_type}'/>" if bed_type else ""
     buf = io.BytesIO()
     with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
         zf.writestr("3D/3dmodel.model", "<model/>")
         zf.writestr(
             "Metadata/slice_info.config",
-            f"<config><plate><metadata key='printer_model_id' value='{printer_model_id}'/></plate></config>",
+            (
+                "<config><plate>"
+                f"<metadata key='printer_model_id' value='{printer_model_id}'/>"
+                f"{extra_meta}"
+                "</plate></config>"
+            ),
         )
     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'<plate><metadata key="plater_id" value="{i}"/></plate>' 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", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"printer_model": "Bambu Lab X1 Carbon"}),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                f"<?xml version='1.0'?>\n<config>\n{plate_blocks}\n</config>\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 <plate> 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", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"printer_model": "Bambu Lab H2D"}),
+            )
+            zf.writestr("Metadata/model_settings.config", "<config/>")
+            zf.writestr(
+                "Metadata/slice_info.config",
+                f"<config><plate><metadata key='index' value='{plate_num}'/>"
+                f"<metadata key='printer_model_id' value='O1D'/></plate></config>",
+            )
+            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", "<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", "<model/>")
+                zf.writestr("Metadata/slice_info.config", "<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", "<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", "<model/>")
+                zf.writestr("Metadata/slice_info.config", "<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}"

+ 42 - 0
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", "<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", "<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."""

+ 328 - 0
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'<?xml version="1.0"?>\n<config>\n'
+            b'<plate><metadata key="plater_id" value="1"/></plate>\n'
+            b'<plate><metadata key="plater_id" value="2"/></plate>\n'
+            b'<plate><metadata key="plater_id" value="3"/></plate>\n'
+            b"</config>\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"<model/>"})
+        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"<config/>"})
+        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 = (
+            '<?xml version="1.0" encoding="UTF-8"?>\n<config>\n'
+            '<header><header_item key="X-BBL-Client-Type" value="slicer"/></header>\n'
+            + (slice_info_block or f'<plate><metadata key="index" value="{plate_num}"/></plate>')
+            + "\n</config>\n"
+        ).encode("utf-8")
+        return _make_3mf(
+            {
+                "3D/3dmodel.model": f"<model plate={plate_num}/>".encode(),
+                "Metadata/project_settings.config": b'{"printer_model": "Bambu Lab H2D"}',
+                "Metadata/model_settings.config": b"<config/>",
+                "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='<plate><metadata key="index" value="1"/></plate>')
+        p2 = self._single_plate_3mf(2, b"G2", slice_info_block='<plate><metadata key="index" value="2"/></plate>')
+        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 <plate> blocks total (we don't include the source's stale
+        # one from before slicing).
+        assert info.count("<plate>") == 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"<model/>",
+                    "Metadata/project_settings.config": b"{}",
+                    "Metadata/model_settings.config": b"<config/>",
+                    "Metadata/slice_info.config": (
+                        '<?xml version="1.0"?>\n<config>'
+                        f'<plate><metadata key="index" value="{plate_num}"/></plate>'
+                        "</config>"
+                    ).encode(),
+                    f"Metadata/plate_{plate_num}.gcode": f"G{plate_num}".encode(),
+                }
+            )
+
+        source = _make_3mf(
+            {
+                "3D/3dmodel.model": b"<model/>",
+                "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 ``<object id=N>`` with ``<metadata key="extruder" .../>``
+        - per-plate ``<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'<object id="{oid}"><metadata key="extruder" value="{ext}"/></object>')
+                plate_obj_refs.append(
+                    f'<model_instance><metadata key="object_id" value="{oid}"/>'
+                    f'<metadata key="instance_id" value="0"/>'
+                    f'<metadata key="identify_id" value="{oid}"/></model_instance>'
+                )
+                oid += 1
+            plates.append(
+                f'<plate><metadata key="plater_id" value="{plate_id}"/>' + "".join(plate_obj_refs) + "</plate>"
+            )
+        xml = (
+            '<?xml version="1.0" encoding="UTF-8"?>\n'
+            "<config>\n" + "\n".join(objects) + "\n" + "\n".join(plates) + "\n" + "</config>"
+        )
+        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"<model/>"})
+        items = ["a.json", "b.json", "c.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == items

+ 84 - 0
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.

+ 62 - 0
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(),

+ 99 - 0
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(
+      <Wrapper>
+        <TrackTrigger id={9} name="Mewtwo.3mf" />
+      </Wrapper>,
+    );
+
+    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(
+      <Wrapper>
+        <TrackTrigger id={10} name="Single.3mf" />
+      </Wrapper>,
+    );
+
+    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.

+ 6 - 0
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 {

+ 98 - 58
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<number | null>(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<PlateFilament[]>(() => {
     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')}
           </button>
+          {canSliceAll && (
+            <label
+              className="flex items-center gap-2 mr-auto text-sm text-bambu-gray cursor-pointer select-none"
+              title={t('slice.actionAllTitle', { count: totalPlateCount })}
+            >
+              <input
+                type="checkbox"
+                checked={sliceAllPlates}
+                onChange={(e) => setSliceAllPlates(e.target.checked)}
+                disabled={isEnqueuing}
+                className="cursor-pointer"
+              />
+              {t('slice.allPlatesToggle', { count: totalPlateCount })}
+            </label>
+          )}
           <button
             type="button"
             onClick={() => {
               setErrorMessage(null);
-              enqueueMutation.mutate();
+              // ``plate=0`` is the sidecar's "all plates" sentinel — passes
+              // ``--slice 0`` to the BS CLI which produces a single 3MF
+              // with one ``Metadata/plate_N.gcode`` entry per plate.
+              const platePayload = sliceAllPlates ? 0 : selectedPlate;
+              enqueueMutation.mutate(platePayload);
             }}
             disabled={!isReady || isEnqueuing}
             className="px-3 py-1.5 text-sm rounded-md bg-bambu-green hover:bg-bambu-green/90 text-bambu-dark font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
@@ -884,6 +922,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 <Loader2 className="w-4 h-4 animate-spin" />
                 {t('slice.enqueuing')}
               </>
+            ) : sliceAllPlates ? (
+              t('slice.actionAll', { count: totalPlateCount })
             ) : (
               t('slice.action')
             )}

+ 30 - 14
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -102,20 +102,36 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
       // --pipe support.
       const hasUseful = progress && progress.stage && progress.total_percent > 0;
       if (phase === 'running' && hasUseful) {
-        showPersistentToast(
-          toastIdFor(job.id),
-          t(
-            'slice.runningWithProgress',
-            '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-            {
-              name: prettifyFilename(job.sourceName),
-              stage: progress.stage,
-              percent: Math.min(100, Math.max(0, Math.round(progress.total_percent))),
-              elapsed: elapsedStr,
-            },
-          ),
-          'loading',
-        );
+        const name = prettifyFilename(job.sourceName);
+        const stage = progress.stage;
+        const percent = Math.min(100, Math.max(0, Math.round(progress.total_percent)));
+        // Cross-class slice-all (#1493) feeds the same toast through N
+        // sequential per-plate slices; the augmented snapshot tells us
+        // which plate is currently running so the user sees the loop
+        // progress, not just a single repeating bar.
+        const isMultiPlateLoop =
+          typeof progress.multi_plate_index === 'number' &&
+          typeof progress.multi_plate_count === 'number' &&
+          progress.multi_plate_count > 1;
+        const message = isMultiPlateLoop
+          ? t(
+              'slice.runningWithProgressMultiPlate',
+              'Plate {{plateIndex}} of {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+              {
+                plateIndex: progress.multi_plate_index,
+                plateCount: progress.multi_plate_count,
+                name,
+                stage,
+                percent,
+                elapsed: elapsedStr,
+              },
+            )
+          : t(
+              'slice.runningWithProgress',
+              '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+              { name, stage, percent, elapsed: elapsedStr },
+            );
+        showPersistentToast(toastIdFor(job.id), message, 'loading');
         return;
       }
       const messageKey = phase === 'pending' ? 'slice.queuedToast' : 'slice.runningToast';

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -3449,6 +3449,9 @@ export default {
   slice: {
     title: 'Modell slicen',
     action: 'Slicen',
+    actionAll: 'Alle {{count}} Plates slicen',
+    actionAllTitle: 'Alle Plates in eine einzelne Multi-Plate-Ausgabe slicen (ein Archiv). Die Filamentauswahl gilt für jeden Slot des Projekts.',
+    allPlatesToggle: 'Alle {{count}} Plates slicen',
     slicing: 'Slicen…',
     printer: 'Drucker-Profil',
     process: 'Prozess-Profil',
@@ -3475,6 +3478,7 @@ export default {
     queuedToast: 'Warteschlange: {{name}} — {{elapsed}}',
     runningToast: '{{name}} wird gesliced — {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
+    runningWithProgressMultiPlate: 'Plate {{plateIndex}} von {{plateCount}} • {{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
     completedToast: '{{name}} wurde gesliced',
     failedTitle: 'Slicen fehlgeschlagen',
     failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -3452,6 +3452,9 @@ export default {
   slice: {
     title: 'Slice model',
     action: 'Slice',
+    actionAll: 'Slice all {{count}} plates',
+    actionAllTitle: 'Slice every plate into one multi-plate output (single archive). Filament selection covers every slot the project defines.',
+    allPlatesToggle: 'Slice all {{count}} plates',
     slicing: 'Slicing…',
     printer: 'Printer profile',
     process: 'Process profile',
@@ -3478,6 +3481,7 @@ export default {
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
     runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+    runningWithProgressMultiPlate: 'Plate {{plateIndex}} of {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedTitle: 'Slicing failed',
     failedToast: 'Slicing {{name}} failed: {{detail}}',

+ 4 - 0
frontend/src/i18n/locales/es.ts

@@ -3452,6 +3452,9 @@ export default {
   slice: {
     title: 'Laminar modelo',
     action: 'Laminar',
+    actionAll: 'Laminar las {{count}} bandejas',
+    actionAllTitle: 'Laminar todas las bandejas en una salida única de varias bandejas (un solo archivo). La selección de filamento cubre cada hueco que defina el proyecto.',
+    allPlatesToggle: 'Laminar las {{count}} bandejas',
     slicing: 'Laminando…',
     printer: 'Perfil de impresora',
     process: 'Perfil de proceso',
@@ -3478,6 +3481,7 @@ export default {
     queuedToast: 'En cola: {{name}} — {{elapsed}}',
     runningToast: 'Laminando {{name}} — {{elapsed}}',
     runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+    runningWithProgressMultiPlate: 'Bandeja {{plateIndex}} de {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: '{{name}} laminado',
     failedTitle: 'Error al laminar',
     failedToast: 'Error al laminar {{name}}: {{detail}}',

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -3438,6 +3438,9 @@ export default {
   slice: {
     title: 'Slicer le modèle',
     action: 'Slicer',
+    actionAll: 'Slicer les {{count}} plateaux',
+    actionAllTitle: "Slicer tous les plateaux dans une sortie multi-plateaux (une seule archive). La sélection de filament couvre chaque emplacement défini par le projet.",
+    allPlatesToggle: 'Slicer les {{count}} plateaux',
     slicing: 'Découpage…',
     printer: 'Profil d\'imprimante',
     process: 'Profil de processus',
@@ -3464,6 +3467,7 @@ export default {
     queuedToast: 'En attente : {{name}} – {{elapsed}}',
     runningToast: 'Découpage {{name}} – {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
+    runningWithProgressMultiPlate: 'Plateau {{plateIndex}} sur {{plateCount}} • {{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
     completedToast: '{{name}} découpé',
     failedTitle: 'Échec du découpage',
     failedToast: 'Échec du découpage de {{name}} : {{detail}}',

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -3437,6 +3437,9 @@ export default {
   slice: {
     title: 'Slicing modello',
     action: 'Slice',
+    actionAll: 'Slicia tutti i {{count}} piatti',
+    actionAllTitle: 'Slicia tutti i piatti in un unico output multi-piatto (un solo archivio). La selezione del filamento copre ogni slot definito dal progetto.',
+    allPlatesToggle: 'Slicia tutti i {{count}} piatti',
     slicing: 'Slicing…',
     printer: 'Profilo stampante',
     process: 'Profilo processo',
@@ -3463,6 +3466,7 @@ export default {
     queuedToast: 'In coda: {{name}} – {{elapsed}}',
     runningToast: 'Slicing {{name}} – {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    runningWithProgressMultiPlate: 'Piatto {{plateIndex}} di {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     completedToast: '{{name}} sezionato',
     failedTitle: 'Slicing fallito',
     failedToast: 'Slicing di {{name}} fallito: {{detail}}',

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -3449,6 +3449,9 @@ export default {
   slice: {
     title: 'モデルをスライス',
     action: 'スライス',
+    actionAll: '{{count}} プレートすべてをスライス',
+    actionAllTitle: 'すべてのプレートを 1 つのマルチプレート出力としてスライスします(アーカイブは 1 件)。フィラメントの選択はプロジェクトで定義されたすべてのスロットを対象とします。',
+    allPlatesToggle: '{{count}} プレートすべてをスライス',
     slicing: 'スライス中…',
     printer: 'プリンタープロファイル',
     process: 'プロセスプロファイル',
@@ -3475,6 +3478,7 @@ export default {
     queuedToast: '待機中: {{name}} – {{elapsed}}',
     runningToast: '{{name}}をスライス中 – {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    runningWithProgressMultiPlate: 'プレート {{plateIndex}} / {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     completedToast: '{{name}}をスライス済み',
     failedTitle: 'スライスに失敗しました',
     failedToast: '{{name}}のスライスに失敗: {{detail}}',

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3437,6 +3437,9 @@ export default {
   slice: {
     title: 'Fatiar modelo',
     action: 'Fatiar',
+    actionAll: 'Fatiar todas as {{count}} bandejas',
+    actionAllTitle: 'Fatiar todas as bandejas em uma saída multi-bandeja única (um único arquivo). A seleção de filamento cobre cada slot definido pelo projeto.',
+    allPlatesToggle: 'Fatiar todas as {{count}} bandejas',
     slicing: 'Fatiando…',
     printer: 'Perfil de impressora',
     process: 'Perfil de processo',
@@ -3463,6 +3466,7 @@ export default {
     queuedToast: 'Na fila: {{name}} – {{elapsed}}',
     runningToast: 'Fatiando {{name}} – {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    runningWithProgressMultiPlate: 'Bandeja {{plateIndex}} de {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     completedToast: '{{name}} fatiado',
     failedTitle: 'Falha ao fatiar',
     failedToast: 'Falha ao fatiar {{name}}: {{detail}}',

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3437,6 +3437,9 @@ export default {
   slice: {
     title: '切片模型',
     action: '切片',
+    actionAll: '切片全部 {{count}} 个盘面',
+    actionAllTitle: '将所有盘面切片为单个多盘面输出(一个归档)。耗材选择覆盖项目定义的每个槽位。',
+    allPlatesToggle: '切片全部 {{count}} 个盘面',
     slicing: '切片中…',
     printer: '打印机配置',
     process: '工艺配置',
@@ -3463,6 +3466,7 @@ export default {
     queuedToast: '已排队:{{name}} — {{elapsed}}',
     runningToast: '切片 {{name}} — {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    runningWithProgressMultiPlate: '盘面 {{plateIndex}} / {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     completedToast: '已切片 {{name}}',
     failedTitle: '切片失败',
     failedToast: '切片 {{name}} 失败:{{detail}}',

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3437,6 +3437,9 @@ export default {
   slice: {
     title: '切片模型',
     action: '切片',
+    actionAll: '切片全部 {{count}} 個盤面',
+    actionAllTitle: '將所有盤面切片為單一的多盤面輸出(單一封存)。線材選擇涵蓋專案定義的每個槽位。',
+    allPlatesToggle: '切片全部 {{count}} 個盤面',
     slicing: '切片中…',
     printer: '印表機設定檔',
     process: '製程設定檔',
@@ -3463,6 +3466,7 @@ export default {
     queuedToast: '已排隊:{{name}} — {{elapsed}}',
     runningToast: '切片 {{name}} — {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    runningWithProgressMultiPlate: '盤面 {{plateIndex}} / {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     completedToast: '已切片 {{name}}',
     failedTitle: '切片失敗',
     failedToast: '切片 {{name}} 失敗:{{detail}}',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CD9cEnnh.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bn4REDBt.js"></script>
+    <script type="module" crossorigin src="/assets/index-CD9cEnnh.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BzucE4G0.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff