Browse Source

feat(slicer): multi-color slicing + per-plate filament discovery

  The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).

  Permissions: no new endpoint paths added. Preview-slice runs inside
  /filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
  dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
  widened.

  Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
  precedence; 9 unit tests for slice_preview cache behaviour (LRU
  eviction with lock cleanup, content-hash invalidation, concurrent
  thundering-herd guard, no-cache-poison on transient sidecar failure);
  15 unit tests for the two new threemf_tools helpers (5 + 10 cases
  including the 60/40 painted-threshold regression pin); a multi-filament
  wire-format test pinning the multipart part count + order; 22 frontend
  SliceModal tests covering plate picker, multi-color render,
  metadata-aware pre-pick, manual override, and the new tier order.
maziggy 4 weeks ago
parent
commit
988c00554e

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


+ 79 - 1
backend/app/api/routes/archives.py

@@ -27,7 +27,11 @@ from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
-from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
+from backend.app.utils.threemf_tools import (
+    extract_nozzle_mapping_from_3mf,
+    extract_plate_extruder_set_from_3mf,
+    extract_project_filaments_from_3mf,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -3109,6 +3113,47 @@ async def get_plate_thumbnail(
     raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
 
 
+async def _try_preview_slice_filaments(
+    db: AsyncSession,
+    *,
+    kind: str,
+    source_id: int,
+    plate_id: int,
+    file_path: Path,
+) -> list[dict] | None:
+    """Run a preview slice via the user's configured sidecar so the filament
+    list endpoint can return real per-plate filaments for unsliced project
+    files. Returns ``None`` on any failure — the caller falls back to the
+    painted-face heuristic."""
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.services.slice_preview import get_preview_filaments
+
+    preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
+    if preferred == "orcaslicer":
+        configured = await get_setting(db, "orcaslicer_api_url")
+        api_url = (configured or settings.slicer_api_url).strip()
+    elif preferred == "bambu_studio":
+        configured = await get_setting(db, "bambu_studio_api_url")
+        api_url = (configured or settings.bambu_studio_api_url).strip()
+    else:
+        return None
+    if not api_url:
+        return None
+
+    try:
+        file_bytes = file_path.read_bytes()
+    except OSError:
+        return None
+    return await get_preview_filaments(
+        kind=kind,
+        source_id=source_id,
+        plate_id=plate_id,
+        file_bytes=file_bytes,
+        file_name=file_path.name,
+        api_url=api_url,
+    )
+
+
 @router.get("/{archive_id}/filament-requirements")
 async def get_filament_requirements(
     archive_id: int,
@@ -3216,6 +3261,39 @@ async def get_filament_requirements(
                                 }
                             )
 
+            # Unsliced project files: slice_info has nothing usable. Try a
+            # preview-slice via the sidecar — the slicer's own logic
+            # determines which filaments the plate actually consumes
+            # (Bambu Studio prunes painted regions whose extruder isn't
+            # used, etc.) — and parse the resulting slice_info. Cached so
+            # the modal opens fast on the second visit. Falls back to the
+            # painted-face heuristic if the sidecar isn't configured or
+            # the preview slice errors out.
+            if not filaments and plate_id is not None:
+                preview = await _try_preview_slice_filaments(
+                    db,
+                    kind="archive",
+                    source_id=archive_id,
+                    plate_id=plate_id,
+                    file_path=file_path,
+                )
+                if preview is not None:
+                    filaments = preview
+
+            # Last-resort fallback for unsliced files when preview slicing
+            # also can't help (no sidecar, slicer errored, etc.). See
+            # library.py for the matching block.
+            if not filaments:
+                project_filaments = extract_project_filaments_from_3mf(zf)
+                if plate_id is not None and project_filaments:
+                    used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id)
+                    if used_slots:
+                        filaments = [f for f in project_filaments if f["slot_id"] in used_slots]
+                    else:
+                        filaments = project_filaments
+                elif project_filaments:
+                    filaments = project_filaments
+
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 

+ 84 - 3
backend/app/api/routes/library.py

@@ -61,7 +61,11 @@ from backend.app.schemas.library import (
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
-from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
+from backend.app.utils.threemf_tools import (
+    extract_nozzle_mapping_from_3mf,
+    extract_plate_extruder_set_from_3mf,
+    extract_project_filaments_from_3mf,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -2338,6 +2342,45 @@ async def get_library_file_plate_thumbnail(
     raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
 
 
+async def _try_preview_slice_filaments(
+    db: AsyncSession,
+    *,
+    kind: str,
+    source_id: int,
+    plate_id: int,
+    file_path: Path,
+) -> list[dict] | None:
+    """Run a preview slice via the user's configured sidecar. Same shape as
+    the matching helper in archives.py — see that module for rationale."""
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.services.slice_preview import get_preview_filaments
+
+    preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
+    if preferred == "orcaslicer":
+        configured = await get_setting(db, "orcaslicer_api_url")
+        api_url = (configured or app_settings.slicer_api_url).strip()
+    elif preferred == "bambu_studio":
+        configured = await get_setting(db, "bambu_studio_api_url")
+        api_url = (configured or app_settings.bambu_studio_api_url).strip()
+    else:
+        return None
+    if not api_url:
+        return None
+
+    try:
+        file_bytes = file_path.read_bytes()
+    except OSError:
+        return None
+    return await get_preview_filaments(
+        kind=kind,
+        source_id=source_id,
+        plate_id=plate_id,
+        file_bytes=file_bytes,
+        file_name=file_path.name,
+        api_url=api_url,
+    )
+
+
 @router.get("/files/{file_id}/filament-requirements")
 async def get_library_file_filament_requirements(
     file_id: int,
@@ -2451,6 +2494,38 @@ async def get_library_file_filament_requirements(
                                 }
                             )
 
+            # Unsliced project files: slice_info has nothing usable. Try a
+            # preview-slice via the sidecar — see archives.py for full
+            # rationale. Cached per (kind, id, plate, content_hash).
+            if not filaments and plate_id is not None:
+                preview = await _try_preview_slice_filaments(
+                    db,
+                    kind="library_file",
+                    source_id=file_id,
+                    plate_id=plate_id,
+                    file_path=file_path,
+                )
+                if preview is not None:
+                    filaments = preview
+
+            # Last-resort fallback when preview slicing also can't help (no
+            # sidecar configured, slicer errored, etc.). project_settings
+            # gives us the full AMS slot config; the plate-extruder filter
+            # narrows it to the slots the painted faces reference.
+            if not filaments:
+                project_filaments = extract_project_filaments_from_3mf(zf)
+                if plate_id is not None and project_filaments:
+                    used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id)
+                    if used_slots:
+                        filaments = [f for f in project_filaments if f["slot_id"] in used_slots]
+                    else:
+                        # No extruder metadata anywhere — return the full
+                        # project list rather than zero so the user still
+                        # gets to pick (over-rendering > under-rendering).
+                        filaments = project_filaments
+                elif project_filaments:
+                    filaments = project_filaments
+
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
@@ -2567,11 +2642,17 @@ async def _run_slicer_with_fallback(
     refs = {
         "printer": request.printer_preset,
         "process": request.process_preset,
-        "filament": request.filament_preset,
     }
     for slot, ref in refs.items():
         assert ref is not None, "schema validator guarantees PresetRef is set"
         presets[slot] = await resolve_preset_ref(db, user, ref, slot)
+    # Multi-color: resolve each filament slot in plate order. The schema
+    # validator backfilled `filament_presets` from the legacy `filament_preset`
+    # field for single-color callers, so this list is always non-empty.
+    filament_jsons: list[str] = []
+    for ref in request.filament_presets:
+        assert ref is not None, "schema validator guarantees filament list is non-None"
+        filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
 
     # Slicer routing — pick the sidecar URL by preferred_slicer.
     # The per-install URL setting (Settings UI → Slicer card) wins; an
@@ -2607,7 +2688,7 @@ async def _run_slicer_with_fallback(
                 model_filename=model_filename,
                 printer_profile_json=presets["printer"],
                 process_profile_json=presets["process"],
-                filament_profile_json=presets["filament"],
+                filament_profile_jsons=filament_jsons,
                 plate=request.plate,
                 export_3mf=request.export_3mf,
             )

+ 113 - 34
backend/app/api/routes/slicer_presets.py

@@ -12,6 +12,7 @@ without faking an "ok with empty list" response.
 from __future__ import annotations
 
 import hashlib
+import json
 import logging
 import time
 
@@ -105,41 +106,54 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
     cloud = BambuCloudService(region=region)
     cloud.set_token(token)
     try:
-        raw = await cloud.get_slicer_settings()
-    except BambuCloudAuthError:
-        # Don't clear the token here — the cloud-status endpoint owns that
-        # lifecycle. Just report expired so the UI can prompt re-auth.
-        return _empty_slots(), "expired"
-    except BambuCloudError as e:
-        logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
-        return _empty_slots(), "unreachable"
-    except Exception as e:  # noqa: BLE001 — defensive: never crash the modal
-        logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
-        return _empty_slots(), "unreachable"
+        try:
+            raw = await cloud.get_slicer_settings()
+        except BambuCloudAuthError:
+            # Don't clear the token here — the cloud-status endpoint owns that
+            # lifecycle. Just report expired so the UI can prompt re-auth.
+            return _empty_slots(), "expired"
+        except BambuCloudError as e:
+            logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
+            return _empty_slots(), "unreachable"
+        except Exception as e:  # noqa: BLE001 — defensive: never crash the modal
+            logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
+            return _empty_slots(), "unreachable"
+
+        slots = _empty_slots()
+        for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
+            type_data = raw.get(cloud_type, {})
+            # The cloud splits presets into "private" (the user's own) and "public"
+            # (Bambu's stock cloud presets). Both are valid choices — surface them
+            # in the natural order private → public so a user's customisations
+            # appear above the stock entries with the same names. Stock entries
+            # that share names with private ones get deduped out within the cloud
+            # tier itself.
+            seen_names: set[str] = set()
+            for entry in type_data.get("private", []) + type_data.get("public", []):
+                name = entry.get("name")
+                setting_id = entry.get("setting_id") or entry.get("id")
+                if not name or not setting_id or name in seen_names:
+                    continue
+                seen_names.add(name)
+                slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
+
+        # Cloud filament presets carry no metadata in this response on
+        # purpose: the per-preset detail endpoint
+        # (/v1/iot-service/api/slicer/setting/{id}) is rate-limited at roughly
+        # 10/sec per token, so fetching N filament presets to enrich them
+        # one-by-one trips Bambu's limiter and returns 429 on every request
+        # for users with large preset libraries (#1150 follow-up).
+        #
+        # The dedup pass (see _dedupe_by_name) compensates: when a cloud entry
+        # wins over a same-named local entry, the cloud entry inherits the
+        # local entry's filament_type / filament_colour. So cloud presets that
+        # also exist locally still get metadata-aware pre-pick in the
+        # SliceModal; cloud-only presets fall back to plain priority order.
+        _cloud_cache[cache_key] = (now, slots)
+        return slots, "ok"
     finally:
         await cloud.close()
 
-    slots = _empty_slots()
-    for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
-        type_data = raw.get(cloud_type, {})
-        # The cloud splits presets into "private" (the user's own) and "public"
-        # (Bambu's stock cloud presets). Both are valid choices — surface them
-        # in the natural order private → public so a user's customisations
-        # appear above the stock entries with the same names. Stock entries
-        # that share names with private ones get deduped out within the cloud
-        # tier itself.
-        seen_names: set[str] = set()
-        for entry in type_data.get("private", []) + type_data.get("public", []):
-            name = entry.get("name")
-            setting_id = entry.get("setting_id") or entry.get("id")
-            if not name or not setting_id or name in seen_names:
-                continue
-            seen_names.add(name)
-            slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
-
-    _cloud_cache[cache_key] = (now, slots)
-    return slots, "ok"
-
 
 async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
     """Local imports — no caching needed, single indexed DB read."""
@@ -151,10 +165,40 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
         slot = type_to_slot.get(p.preset_type)
         if slot is None:
             continue
-        slots[slot].append(UnifiedPreset(id=str(p.id), name=p.name, source="local"))
+        extra: dict[str, str | None] = {}
+        if slot == "filament":
+            extra["filament_type"], extra["filament_colour"] = _parse_filament_metadata(p.setting)
+        slots[slot].append(
+            UnifiedPreset(id=str(p.id), name=p.name, source="local", **extra),
+        )
     return slots
 
 
+def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
+    """Extract first-slot ``filament_type`` and ``filament_colour`` from a
+    stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we
+    take the first entry since pre-pick matching is one-slot-at-a-time.
+    Defensive parse: any error returns (None, None) so a corrupt row never
+    breaks the listing."""
+    if not setting_json:
+        return None, None
+    try:
+        data = json.loads(setting_json)
+    except (ValueError, TypeError):
+        return None, None
+    if not isinstance(data, dict):
+        return None, None
+    return _first_scalar(data.get("filament_type")), _first_scalar(data.get("filament_colour"))
+
+
+def _first_scalar(value: object) -> str | None:
+    if isinstance(value, list) and value:
+        return value[0] if isinstance(value[0], str) else None
+    if isinstance(value, str) and value:
+        return value
+    return None
+
+
 async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
     """Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
     global _bundled_cache
@@ -186,7 +230,13 @@ async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPres
                 continue
             # Bundled presets are addressed by name (the slicer resolves them
             # by name during the `inherits:` walk), so name doubles as id.
-            slots[slot].append(UnifiedPreset(id=name, name=name, source="standard"))
+            extra: dict[str, str | None] = {}
+            if slot == "filament":
+                extra["filament_type"] = entry.get("filament_type")
+                extra["filament_colour"] = entry.get("filament_colour")
+            slots[slot].append(
+                UnifiedPreset(id=name, name=name, source="standard", **extra),
+            )
 
     _bundled_cache = (now, slots)
     return slots
@@ -236,7 +286,36 @@ def _dedupe_by_name(
     Order within each tier is preserved as-is — only "lower-priority duplicates"
     are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud
     public AND standard bundled) only renders once, in the cloud tier.
+
+    Filament metadata is **merged across tiers** during dedup: when a cloud
+    entry wins over a same-named local entry, the cloud entry inherits the
+    local entry's ``filament_type`` and ``filament_colour`` (cloud entries
+    carry no metadata themselves because we deliberately don't fetch each
+    setting's content — see _fetch_cloud_presets). Without this merge, the
+    SliceModal's metadata-aware pre-pick would silently lose match data for
+    every preset the user has both cloud-synced and locally imported, and
+    fall back to plain priority selection.
     """
+    # Build a lookup: filament name → metadata from the highest-quality tier
+    # that has it. Local + standard both expose parsed metadata; cloud
+    # doesn't. Take whichever non-empty entry shows up first.
+    metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
+    for tier in (local, standard):
+        for p in tier["filament"]:
+            if p.name in metadata_by_name:
+                continue
+            if p.filament_type or p.filament_colour:
+                metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
+
+    # Backfill cloud entries that don't have their own metadata.
+    for p in cloud["filament"]:
+        if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
+            t, c = metadata_by_name[p.name]
+            if p.filament_type is None and t is not None:
+                p.filament_type = t
+            if p.filament_colour is None and c is not None:
+                p.filament_colour = c
+
     deduped_local = _empty_slots()
     deduped_standard = _empty_slots()
     for slot in ("printer", "process", "filament"):

+ 33 - 2
backend/app/schemas/slicer.py

@@ -53,6 +53,13 @@ class SliceRequest(BaseModel):
     process_preset: PresetRef | None = None
     filament_preset: PresetRef | None = None
 
+    # Multi-color: one PresetRef per AMS slot the source plate uses. Order is
+    # significant — the slicer matches index-by-index against the plate's
+    # filament slots. Always preferred over the legacy singular field; the
+    # validator promotes a singular field into ``[singular]`` when the list
+    # is empty so older clients keep working.
+    filament_presets: list[PresetRef] = Field(default_factory=list)
+
     plate: int | None = Field(
         default=None,
         ge=1,
@@ -67,11 +74,13 @@ class SliceRequest(BaseModel):
     def normalise_preset_refs(self) -> "SliceRequest":
         """Each slot must end up with a `PresetRef` set. Legacy integer ids
         become `(source='local', id=str(int))` so the route handler only
-        deals with the canonical shape."""
+        deals with the canonical shape. For filament: a non-empty
+        ``filament_presets`` list satisfies the requirement on its own; an
+        empty list falls back to the singular fields, which then promote
+        into a one-element list."""
         for slot, ref_attr, legacy_attr in (
             ("printer", "printer_preset", "printer_preset_id"),
             ("process", "process_preset", "process_preset_id"),
-            ("filament", "filament_preset", "filament_preset_id"),
         ):
             ref = getattr(self, ref_attr)
             legacy_id = getattr(self, legacy_attr)
@@ -81,6 +90,28 @@ class SliceRequest(BaseModel):
                 )
             if ref is None:
                 setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
+
+        # Filament accepts THREE shapes, in priority order:
+        #   1. filament_presets    — multi-color array (new clients)
+        #   2. filament_preset     — source-aware singular (single-color new clients)
+        #   3. filament_preset_id  — legacy bare integer (old clients)
+        # The first non-empty shape wins; missing all three raises.
+        if not self.filament_presets:
+            if self.filament_preset is not None:
+                self.filament_presets = [self.filament_preset]
+            elif self.filament_preset_id is not None:
+                fallback = PresetRef(source="local", id=str(self.filament_preset_id))
+                self.filament_preset = fallback
+                self.filament_presets = [fallback]
+            else:
+                raise ValueError(
+                    "filament preset is required: provide 'filament_presets' (preferred), "
+                    "'filament_preset', or legacy 'filament_preset_id'"
+                )
+        elif self.filament_preset is None:
+            # Multi-color caller: backfill the singular from the first slot
+            # so callers that still read the legacy field see a stable value.
+            self.filament_preset = self.filament_presets[0]
         return self
 
 

+ 8 - 0
backend/app/schemas/slicer_presets.py

@@ -25,11 +25,19 @@ class UnifiedPreset(BaseModel):
 
     The frontend treats ``id`` as opaque; the slice dispatch path uses
     ``(source, id)`` to fetch / pass the preset content to the sidecar.
+
+    ``filament_type`` and ``filament_colour`` are populated for the filament
+    slot only — they let the SliceModal pre-pick a preset per plate slot in
+    the multi-color flow by matching against the source 3MF's per-slot type
+    and color. Populated when the underlying preset JSON exposes them; left
+    as ``None`` on bundled profiles where colour is a runtime spool attribute.
     """
 
     id: str
     name: str
     source: Literal["cloud", "local", "standard"]
+    filament_type: str | None = None
+    filament_colour: str | None = None
 
 
 class UnifiedPresetsBySlot(BaseModel):

+ 180 - 0
backend/app/services/slice_preview.py

@@ -0,0 +1,180 @@
+"""Preview-slice cache for the SliceModal.
+
+The slice modal needs the per-plate filament list before the user picks
+profiles. For sliced files this lives in ``Metadata/slice_info.config`` and
+the ``/filament-requirements`` endpoint can read it directly. For unsliced
+project files it doesn't exist yet — only the slicer can produce it, since
+Bambu Studio applies its own pruning to painted-face data at slice time.
+
+This module wraps the sidecar's ``slice_without_profiles`` call so the
+endpoint can run a preview slice with the project's embedded settings,
+parse the result's slice_info, and return the actual filament list. Results
+are cached by ``(kind, source_id, plate_id, content_hash)`` so repeat
+opens of the modal on the same plate are instant; LRU eviction keeps the
+cache bounded. Hash invalidation handles in-place file replacement; no TTL
+is used because preview-slice output is deterministic for a given file
+content.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import logging
+import zipfile
+from collections import OrderedDict
+from io import BytesIO
+
+import defusedxml.ElementTree as ET
+
+from backend.app.services.slicer_api import (
+    SlicerApiError,
+    SlicerApiService,
+)
+
+logger = logging.getLogger(__name__)
+
+_PREVIEW_CACHE_MAX = 256
+# Cache values: list[dict] on success, [] on parsed-but-empty (slicer
+# returned a 3MF without filament data for this plate — caching the negative
+# avoids burning 30s+ per modal open on a known-bad input).
+_preview_cache: OrderedDict[tuple[str, int, int, str], list[dict]] = OrderedDict()
+# Per-key locks prevent N concurrent modal opens on the same (file, plate)
+# from launching N redundant preview slices — only the first one runs, the
+# rest wait and read from the cache. Locks are evicted alongside cache
+# entries to keep the dict bounded; we do NOT cache transient sidecar
+# failures (network errors etc.) so those retry naturally on next request.
+_preview_locks: dict[tuple[str, int, int, str], asyncio.Lock] = {}
+
+
+def _content_hash(file_bytes: bytes) -> str:
+    return hashlib.sha256(file_bytes).hexdigest()[:16]
+
+
+async def get_preview_filaments(
+    *,
+    kind: str,
+    source_id: int,
+    plate_id: int,
+    file_bytes: bytes,
+    file_name: str,
+    api_url: str,
+) -> list[dict] | None:
+    """Run a preview slice for ``plate_id`` using the file's embedded settings,
+    parse the resulting slice_info, and return the per-plate filament list.
+
+    Returns ``None`` when the preview slice fails — the caller should fall
+    back to whatever heuristic it has (typically the project_filaments +
+    painted-face approach in ``threemf_tools``).
+    """
+    h = _content_hash(file_bytes)
+    key = (kind, source_id, plate_id, h)
+    cached = _preview_cache.get(key)
+    if cached is not None:
+        _preview_cache.move_to_end(key)
+        return cached
+
+    lock = _preview_locks.setdefault(key, asyncio.Lock())
+    async with lock:
+        # Re-check after acquiring the lock — another coroutine may have
+        # populated the cache while we were waiting on it.
+        cached = _preview_cache.get(key)
+        if cached is not None:
+            _preview_cache.move_to_end(key)
+            return cached
+
+        try:
+            async with SlicerApiService(base_url=api_url) as svc:
+                result = await svc.slice_without_profiles(
+                    model_bytes=file_bytes,
+                    model_filename=file_name,
+                    plate=plate_id,
+                    export_3mf=True,
+                )
+        except SlicerApiError as e:
+            logger.warning(
+                "Preview slice failed for %s/%s plate %s: %s",
+                kind,
+                source_id,
+                plate_id,
+                e,
+            )
+            return None
+        except Exception as e:  # noqa: BLE001 — never break the modal on sidecar issues
+            logger.warning("Preview slice unexpected error: %s", e)
+            return None
+
+        filaments = _parse_filaments_from_sliced_3mf(result.content, plate_id)
+        # Negative-cache the parse failure: a slice that succeeds but yields
+        # no parsable filament data for this plate is a deterministic
+        # property of the input. Re-running the slice produces the same
+        # result, just N seconds slower. Empty list signals "preview was
+        # tried, no usable data" so the caller can fall through.
+        cache_value: list[dict] = filaments if filaments is not None else []
+        _preview_cache[key] = cache_value
+        if len(_preview_cache) > _PREVIEW_CACHE_MAX:
+            evicted_key, _ = _preview_cache.popitem(last=False)
+            # Drop the matching lock so the dict doesn't grow forever.
+            # Safe to discard: the lock isn't held here, and any later
+            # request for the same key will mint a fresh lock.
+            _preview_locks.pop(evicted_key, None)
+        return filaments
+
+
+def _parse_filaments_from_sliced_3mf(content: bytes, plate_id: int) -> list[dict] | None:
+    """Extract ``<filament>`` entries for ``plate_id`` from a sliced 3MF's
+    Metadata/slice_info.config. Returns ``None`` on any parse error so the
+    caller knows to fall back."""
+    try:
+        with zipfile.ZipFile(BytesIO(content)) as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+            data = zf.read("Metadata/slice_info.config").decode()
+    except (zipfile.BadZipFile, OSError):
+        return None
+
+    try:
+        root = ET.fromstring(data)
+    except ET.ParseError:
+        return None
+
+    for plate_elem in root.findall(".//plate"):
+        idx = None
+        for meta in plate_elem.findall("metadata"):
+            if meta.get("key") == "index":
+                try:
+                    idx = int(meta.get("value", ""))
+                except (ValueError, TypeError):
+                    pass
+                break
+        if idx != plate_id:
+            continue
+        out: list[dict] = []
+        for f in plate_elem.findall("filament"):
+            fid = f.get("id")
+            if not fid:
+                continue
+            try:
+                slot_id = int(fid)
+            except (ValueError, TypeError):
+                continue
+            try:
+                used_grams = float(f.get("used_g", "0"))
+            except (ValueError, TypeError):
+                used_grams = 0
+            try:
+                used_meters = float(f.get("used_m", "0"))
+            except (ValueError, TypeError):
+                used_meters = 0
+            out.append(
+                {
+                    "slot_id": slot_id,
+                    "type": f.get("type", ""),
+                    "color": f.get("color", ""),
+                    "used_grams": round(used_grams, 1),
+                    "used_meters": used_meters,
+                    "tray_info_idx": f.get("tray_info_idx", ""),
+                },
+            )
+        return sorted(out, key=lambda x: x["slot_id"])
+    return None

+ 25 - 8
backend/app/services/slicer_api.py

@@ -160,22 +160,39 @@ class SlicerApiService:
         model_filename: str,
         printer_profile_json: str,
         process_profile_json: str,
-        filament_profile_json: str,
+        filament_profile_jsons: list[str],
         plate: int | None = None,
         export_3mf: bool = False,
     ) -> SliceResult:
-        """POST /slice with model + printer/process/filament profile triplet.
+        """POST /slice with model + printer/process/filament profiles.
+
+        ``filament_profile_jsons`` is plate-slot-ordered: index 0 is the
+        profile for slot 1, etc. Single-color callers pass a one-element
+        list. Multiple ``filamentProfile`` parts are sent as a repeated form
+        field — the sidecar's route declares ``maxCount: 16`` and the
+        slicing service joins them as semicolon-separated
+        ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
 
         Raises:
             SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
             SlicerApiUnavailableError: connection error or 5xx from sidecar.
         """
-        files = {
-            "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
-            "printerProfile": ("printer.json", printer_profile_json.encode("utf-8"), "application/json"),
-            "presetProfile": ("preset.json", process_profile_json.encode("utf-8"), "application/json"),
-            "filamentProfile": ("filament.json", filament_profile_json.encode("utf-8"), "application/json"),
-        }
+        # httpx supports repeated multipart fields when files is a list of
+        # tuples — using the dict form would silently overwrite duplicate
+        # keys and ship only the last filament profile.
+        files: list[tuple[str, tuple[str, bytes, str]]] = [
+            ("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))),
+            ("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")),
+            ("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")),
+        ]
+        for idx, fjson in enumerate(filament_profile_jsons):
+            files.append(
+                (
+                    "filamentProfile",
+                    (f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"),
+                )
+            )
+
         data: dict[str, str] = {}
         if plate is not None:
             data["plate"] = str(plate)

+ 221 - 0
backend/app/utils/threemf_tools.py

@@ -607,3 +607,224 @@ def inject_gcode_into_3mf(
         if "tmp_path" in locals() and tmp_path.exists():
             tmp_path.unlink(missing_ok=True)
         return None
+
+
+def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
+    """Project-wide AMS slot config from ``Metadata/project_settings.config``.
+
+    Returns one dict per configured AMS slot in slot order (1-indexed), with
+    ``type`` and ``color`` populated from the project's ``filament_type`` and
+    ``filament_colour`` arrays. ``used_grams`` / ``used_meters`` are 0 because
+    project_settings carries the configuration, not per-print usage — the
+    fields exist for shape compatibility with the slice_info-derived list.
+
+    The SliceModal needs this on **unsliced** project files: slice_info.config
+    is empty until Bambu Studio has actually sliced the project, but the user
+    can still pick filament profiles for a slice we're about to perform.
+    """
+    if "Metadata/project_settings.config" not in zf.namelist():
+        return []
+    try:
+        proj = json.loads(zf.read("Metadata/project_settings.config").decode())
+    except (ValueError, OSError):
+        return []
+    if not isinstance(proj, dict):
+        return []
+    types_arr = proj.get("filament_type") or []
+    colors_arr = proj.get("filament_colour") or []
+    slot_count = max(
+        len(types_arr) if isinstance(types_arr, list) else 0, len(colors_arr) if isinstance(colors_arr, list) else 0
+    )
+    out: list[dict] = []
+    for i in range(slot_count):
+        out.append(
+            {
+                "slot_id": i + 1,
+                "type": types_arr[i] if i < len(types_arr) and isinstance(types_arr[i], str) else "",
+                "color": colors_arr[i] if i < len(colors_arr) and isinstance(colors_arr[i], str) else "",
+                "used_grams": 0,
+                "used_meters": 0,
+            }
+        )
+    return out
+
+
+_PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"')
+
+# Painted-face quadtree leaves include both real filament assignments and
+# tiny edit artifacts (single-leaf accidents from "tried a colour, undid,
+# repainted with a different one"). The threshold's only job is dropping
+# accidents — anything the user spent meaningful effort on must survive.
+# 5% of an object's painted triangles is well below any 60/40 / 70/30 /
+# 33/33/33 split a real two- or three-colour print would hit, so all
+# intentional colours are kept; one-off single-leaf paints (typically
+# 0.1-1.5% in observed projects) are filtered. Note that this fallback
+# path runs ONLY when the preview-slice path can't reach the sidecar; in
+# the normal flow the slicer's own pruning produces the canonical list and
+# this threshold isn't reached.
+_PAINT_NOISE_THRESHOLD = 0.05
+
+
+def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> set[int]:
+    """Extruder/AMS slot indices (1-indexed) used by objects on ``plate_id``.
+
+    Three sources are unioned because Bambu Studio splits per-object extruder
+    info across THREE places depending on how the user assigned colours:
+
+    1. ``model_settings.config`` — top-level ``<metadata key="extruder">``
+       on each ``<object>`` (the "default extruder" for the whole object).
+    2. ``model_settings.config`` — per-``<part>`` ``<metadata key="extruder">``
+       overrides (used when the user split an object into multiple parts
+       with distinct filaments).
+    3. ``3D/Objects/object_*.model`` — ``paint_color`` attributes on
+       individual ``<triangle>`` elements (used when the user "painted" a
+       face with a different filament). The encoding is a hex string where
+       each nibble is a TriangleSelector tree node: ``0`` = unpainted leaf,
+       ``F`` = branch (4 children follow), ``1``..``E`` = leaf painted with
+       extruder N. We don't decode the tree — every leaf-paint nibble in
+       the string IS the extruder number, so a flat scan over hex chars
+       yields the correct set without recursive parsing.
+
+    Without (3) the painted-face data is invisible: model_settings says
+    every object on a multi-color plate uses extruder 1 by default but the
+    actual print uses 3, 4, 12 etc. via face paint, so the SliceModal would
+    render only one filament dropdown for what's clearly a multi-colour
+    print (#1150 follow-up).
+    """
+    if "Metadata/model_settings.config" not in zf.namelist():
+        return set()
+    try:
+        root = ET.fromstring(zf.read("Metadata/model_settings.config").decode())
+    except (ET.ParseError, OSError):
+        return set()
+
+    # Pass 1: object → set of extruders from XML metadata (sources 1 + 2)
+    # plus the per-object .model file path so we can later scan source 3.
+    object_extruders: dict[str, set[int]] = {}
+    object_model_paths: dict[str, list[str]] = {}
+    for obj_elem in root.findall(".//object"):
+        obj_id = obj_elem.get("id")
+        if not obj_id:
+            continue
+        extruders: set[int] = set()
+        top = obj_elem.find("metadata[@key='extruder']")
+        if top is not None:
+            try:
+                v = int(top.get("value", "0"))
+                if v > 0:
+                    extruders.add(v)
+            except (ValueError, TypeError):
+                pass
+        for part_elem in obj_elem.findall(".//part"):
+            part_ext = part_elem.find("metadata[@key='extruder']")
+            if part_ext is None:
+                continue
+            try:
+                v = int(part_ext.get("value", "0"))
+                if v > 0:
+                    extruders.add(v)
+            except (ValueError, TypeError):
+                pass
+        object_extruders[obj_id] = extruders
+
+    # Pass 2: 3dmodel.model maps each <object id="N"> to its component
+    # .model file path(s). Bambu wraps object IDs that match
+    # model_settings.config IDs around <components><component
+    # path="/3D/Objects/object_K.model" objectid="..." /></components>.
+    # Strip xmlns prefixes on attributes so ElementTree can find them
+    # without namespace gymnastics — `p:path` becomes `path` etc.
+    if "3D/3dmodel.model" in zf.namelist():
+        try:
+            raw = zf.read("3D/3dmodel.model").decode()
+            stripped = re.sub(r'xmlns:?\w*="[^"]*"', "", raw)
+            stripped = re.sub(r"<(/?)\w+:", r"<\1", stripped)
+            stripped = re.sub(r" \w+:(\w+=)", r" \1", stripped)
+            model_root = ET.fromstring(stripped)
+            for obj_elem in model_root.findall(".//object"):
+                oid = obj_elem.get("id")
+                if not oid:
+                    continue
+                comps = obj_elem.find("components")
+                if comps is None:
+                    continue
+                paths = []
+                for c in comps.findall("component"):
+                    p = c.get("path")
+                    if p:
+                        paths.append(p.lstrip("/"))
+                if paths:
+                    object_model_paths[oid] = paths
+        except (ET.ParseError, OSError):
+            pass  # No 3dmodel — paint scan just won't apply
+
+    # Pass 3: scan paint_color attrs in each per-object .model file. Cache
+    # by file path because two objects often share the same component tree.
+    paint_cache: dict[str, set[int]] = {}
+
+    def _scan_paint(path: str) -> set[int]:
+        if path in paint_cache:
+            return paint_cache[path]
+        out: set[int] = set()
+        if path not in zf.namelist():
+            paint_cache[path] = out
+            return out
+        try:
+            data = zf.read(path)
+        except OSError:
+            paint_cache[path] = out
+            return out
+        # Per-extruder triangle coverage. Each painted triangle may have
+        # multiple leaf nibbles (the quadtree subdivides the face into
+        # painted regions); we count one triangle per unique extruder per
+        # match so the resulting fraction is "what share of painted
+        # triangles include at least one leaf with extruder N". Noise from
+        # one-off edit artifacts is filtered out at the threshold below.
+        extruder_triangles: dict[int, int] = {}
+        total_painted = 0
+        for match in _PAINT_COLOR_ATTR_RE.finditer(data):
+            total_painted += 1
+            seen: set[int] = set()
+            for ch in match.group(1):
+                # Hex digit → 4-bit value. 0 = unpainted leaf, F = branch
+                # (decoded recursively but children are encoded inline, so
+                # we'll see them on later iterations). 1-E = leaf painted
+                # with extruder N.
+                if ch in b"123456789":
+                    seen.add(ch - 0x30)
+                elif ch in b"ABCDEabcde":
+                    seen.add((ch & 0x4F) - 0x37)
+            for e in seen:
+                extruder_triangles[e] = extruder_triangles.get(e, 0) + 1
+        if total_painted > 0:
+            cutoff = max(1, int(total_painted * _PAINT_NOISE_THRESHOLD))
+            for ext, count in extruder_triangles.items():
+                if count >= cutoff:
+                    out.add(ext)
+        paint_cache[path] = out
+        return out
+
+    # Walk plates — collect extruders for objects on the requested plate.
+    used: set[int] = set()
+    for plate_elem in root.findall(".//plate"):
+        plater_id = None
+        for meta in plate_elem.findall("metadata"):
+            if meta.get("key") == "plater_id":
+                try:
+                    plater_id = int(meta.get("value", ""))
+                except (ValueError, TypeError):
+                    pass
+                break
+        if plater_id != plate_id:
+            continue
+        for inst in plate_elem.findall("model_instance"):
+            for inst_meta in inst.findall("metadata"):
+                if inst_meta.get("key") != "object_id":
+                    continue
+                obj_id = inst_meta.get("value")
+                if not obj_id:
+                    continue
+                used.update(object_extruders.get(obj_id, set()))
+                for path in object_model_paths.get(obj_id, []):
+                    used.update(_scan_paint(path))
+        break
+    return used

+ 256 - 0
backend/tests/unit/services/test_slice_preview.py

@@ -0,0 +1,256 @@
+"""Unit tests for the preview-slice cache.
+
+The preview-slice runs the sidecar's `slice_without_profiles` on an unsliced
+project file to extract the per-plate filament list. Results are cached by
+``(kind, source_id, plate_id, content_hash)`` with LRU eviction so repeat
+modal opens on the same plate are instant.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import io
+import zipfile
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services import slice_preview
+from backend.app.services.slice_preview import (
+    _PREVIEW_CACHE_MAX,
+    _parse_filaments_from_sliced_3mf,
+    get_preview_filaments,
+)
+from backend.app.services.slicer_api import (
+    SlicerApiUnavailableError,
+    SliceResult,
+)
+
+
+def _make_sliced_3mf(plate_id: int, filaments: list[dict[str, str]]) -> bytes:
+    """Build a fake sliced-3MF zip whose Metadata/slice_info.config has one
+    plate matching ``plate_id`` with the given filament rows."""
+    fil_xml = "".join(
+        f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}"'
+        f' used_g="{f.get("used_g", "0")}" used_m="{f.get("used_m", "0")}"'
+        f' tray_info_idx="{f.get("tray_info_idx", "")}"/>'
+        for f in filaments
+    )
+    slice_info = (
+        f'<?xml version="1.0"?><config><plate><metadata key="index" value="{plate_id}"/>{fil_xml}</plate></config>'
+    )
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", slice_info)
+    return buf.getvalue()
+
+
+@pytest.fixture(autouse=True)
+def _reset_cache():
+    """Each test gets an empty cache + lock dict to keep them independent."""
+    slice_preview._preview_cache.clear()
+    slice_preview._preview_locks.clear()
+    yield
+    slice_preview._preview_cache.clear()
+    slice_preview._preview_locks.clear()
+
+
+class _StubService:
+    """Mimics SlicerApiService just enough for these tests. Records every
+    `slice_without_profiles` call so we can assert call counts."""
+
+    def __init__(self, response_bytes: bytes | None = None, raise_exc: BaseException | None = None) -> None:
+        self.response_bytes = response_bytes
+        self.raise_exc = raise_exc
+        self.calls: list[dict[str, Any]] = []
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, *exc):
+        return False
+
+    async def slice_without_profiles(self, **kw):
+        self.calls.append(kw)
+        if self.raise_exc is not None:
+            raise self.raise_exc
+        return SliceResult(
+            content=self.response_bytes or b"",
+            print_time_seconds=0,
+            filament_used_g=0.0,
+            filament_used_mm=0.0,
+        )
+
+
+# ---------------------------------------------------------------------------
+# _parse_filaments_from_sliced_3mf — pure-function parsing tests.
+# ---------------------------------------------------------------------------
+
+
+class TestParseFilamentsFromSliced3mf:
+    def test_happy_path(self):
+        body = _make_sliced_3mf(
+            plate_id=22,
+            filaments=[
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "33.9"},
+                {"id": "6", "type": "PLA", "color": "#FF0000", "used_g": "37.7"},
+            ],
+        )
+        result = _parse_filaments_from_sliced_3mf(body, 22)
+        assert result is not None
+        assert [(f["slot_id"], f["color"]) for f in result] == [(1, "#FFFFFF"), (6, "#FF0000")]
+        assert result[0]["used_grams"] == 33.9
+
+    def test_missing_slice_info_returns_none(self):
+        empty_zip = io.BytesIO()
+        with zipfile.ZipFile(empty_zip, "w") as zf:
+            zf.writestr("placeholder.txt", "x")
+        assert _parse_filaments_from_sliced_3mf(empty_zip.getvalue(), 1) is None
+
+    def test_plate_not_in_slice_info_returns_none(self):
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        assert _parse_filaments_from_sliced_3mf(body, plate_id=99) is None
+
+    def test_corrupt_zip_returns_none(self):
+        assert _parse_filaments_from_sliced_3mf(b"not a zip file", 1) is None
+
+
+# ---------------------------------------------------------------------------
+# get_preview_filaments — cache + concurrency behaviour.
+# ---------------------------------------------------------------------------
+
+
+class TestGetPreviewFilaments:
+    @pytest.mark.asyncio
+    async def test_happy_path_caches_result(self):
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        stub = _StubService(response_bytes=body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            first = await get_preview_filaments(
+                kind="archive",
+                source_id=1,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+            second = await get_preview_filaments(
+                kind="archive",
+                source_id=1,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+        assert first is not None
+        assert first[0]["slot_id"] == 1
+        assert second == first
+        # Cache hit — only one slice was actually run.
+        assert len(stub.calls) == 1
+
+    @pytest.mark.asyncio
+    async def test_different_content_hash_misses_cache(self):
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        stub = _StubService(response_bytes=body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            await get_preview_filaments(
+                kind="archive",
+                source_id=1,
+                plate_id=1,
+                file_bytes=b"v1",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+            await get_preview_filaments(
+                kind="archive",
+                source_id=1,
+                plate_id=1,
+                file_bytes=b"v2",  # Same archive, but content changed
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+        # Hash differs → cache miss → fresh slice.
+        assert len(stub.calls) == 2
+
+    @pytest.mark.asyncio
+    async def test_sidecar_unavailable_returns_none_no_cache(self):
+        # Transient sidecar failure must NOT poison the cache — the next
+        # request retries cleanly.
+        stub = _StubService(raise_exc=SlicerApiUnavailableError("boom"))
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            first = await get_preview_filaments(
+                kind="archive",
+                source_id=1,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+            assert first is None
+            # Second call hits the sidecar again (no cached failure).
+            await get_preview_filaments(
+                kind="archive",
+                source_id=1,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+        assert len(stub.calls) == 2
+
+    @pytest.mark.asyncio
+    async def test_concurrent_calls_share_one_slice(self):
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+
+        # Slow stub so we can observe N coroutines piling up on the lock.
+        class _SlowStub(_StubService):
+            async def slice_without_profiles(self, **kw):
+                self.calls.append(kw)
+                await asyncio.sleep(0.05)
+                return SliceResult(
+                    content=self.response_bytes or b"",
+                    print_time_seconds=0,
+                    filament_used_g=0.0,
+                    filament_used_mm=0.0,
+                )
+
+        stub = _SlowStub(response_bytes=body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            results = await asyncio.gather(
+                *(
+                    get_preview_filaments(
+                        kind="archive",
+                        source_id=1,
+                        plate_id=1,
+                        file_bytes=b"abc",
+                        file_name="x.3mf",
+                        api_url="http://sidecar",
+                    )
+                    for _ in range(8)
+                ),
+            )
+        # All 8 callers got the same result, but only ONE slice ran.
+        assert all(r == results[0] for r in results)
+        assert len(stub.calls) == 1
+
+    @pytest.mark.asyncio
+    async def test_lru_eviction_drops_lock(self):
+        # Fill cache past the bound; oldest should evict, including its lock.
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        stub = _StubService(response_bytes=body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            # Each call has a unique source_id → unique cache key.
+            for i in range(_PREVIEW_CACHE_MAX + 5):
+                await get_preview_filaments(
+                    kind="archive",
+                    source_id=i,
+                    plate_id=1,
+                    file_bytes=b"abc",
+                    file_name="x.3mf",
+                    api_url="http://sidecar",
+                )
+        # Cache is bounded — older entries fell off.
+        assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
+        # Lock dict is also pruned (no leak): same size as cache.
+        assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX

+ 44 - 9
backend/tests/unit/services/test_slicer_api.py

@@ -74,7 +74,7 @@ class TestSliceWithProfiles:
             model_filename="Cube.stl",
             printer_profile_json='{"name": "p"}',
             process_profile_json='{"name": "pr"}',
-            filament_profile_json='{"name": "f"}',
+            filament_profile_jsons=['{"name": "f"}'],
         )
 
         assert isinstance(result, SliceResult)
@@ -103,7 +103,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
         assert "Invalid file type" in str(exc_info.value)
 
@@ -125,7 +125,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
         assert "Failed to slice the model" in str(exc_info.value)
 
@@ -153,7 +153,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
         msg = str(exc_info.value)
         assert "Failed to slice the model" in msg
@@ -178,7 +178,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
         assert "SIGSEGV" in str(exc_info.value)
 
@@ -198,7 +198,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
         assert "Bad Gateway" in str(exc_info.value)
 
@@ -214,7 +214,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
         assert "unreachable" in str(exc_info.value).lower()
 
@@ -236,7 +236,7 @@ class TestSliceWithProfiles:
             model_filename="Cube.stl",
             printer_profile_json="{}",
             process_profile_json="{}",
-            filament_profile_json="{}",
+            filament_profile_jsons=["{}"],
             plate=2,
             export_3mf=True,
         )
@@ -249,6 +249,41 @@ class TestSliceWithProfiles:
         assert b'name="exportType"' in body
         assert b"3mf" in 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
+        # order, sent as N repeated multipart `filamentProfile` parts (NOT a
+        # single concatenated value). The CLI joins their resulting paths
+        # with `;` for --load-filaments. A future regression to a dict-shaped
+        # `files=` would silently keep prior tests green but ship only the
+        # last filament — pin the wire shape.
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF-BYTES",
+                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=['{"a":1}', '{"b":2}', '{"c":3}'],
+        )
+
+        body = captured["body"]
+        # Three repeated `filamentProfile` parts, in submission order.
+        assert body.count(b'name="filamentProfile"') == 3
+        assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body
+        # Parts present in plate order — the 'a' bytes appear before 'b'
+        # which appear before 'c'. (httpx preserves the list order.)
+        assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}')
+
     @pytest.mark.asyncio
     async def test_missing_metadata_headers_default_to_zero(self):
         # The /slice endpoint always sets these on success, but be defensive
@@ -262,7 +297,7 @@ class TestSliceWithProfiles:
             model_filename="Cube.stl",
             printer_profile_json="{}",
             process_profile_json="{}",
-            filament_profile_json="{}",
+            filament_profile_jsons=["{}"],
         )
         assert result.print_time_seconds == 0
         assert result.filament_used_g == 0.0

+ 58 - 0
backend/tests/unit/test_slice_request_schema.py

@@ -84,3 +84,61 @@ class TestPriorityWhenBothSet:
         # Validator only fills the ref when it's None — the explicit cloud
         # ref stays untouched.
         assert req.printer_preset == PresetRef(source="cloud", id="PFU")
+
+
+class TestFilamentPresetsList:
+    """Multi-color: the new array shape carries one filament profile per
+    plate slot in plate order. Backwards-compat: legacy clients still
+    submit a singular `filament_preset` and the validator promotes it into
+    a one-element list so the route handler only deals with one shape."""
+
+    def test_explicit_list_passes_through(self):
+        refs = [
+            PresetRef(source="cloud", id="A"),
+            PresetRef(source="local", id="2"),
+            PresetRef(source="standard", id="Bambu PLA Basic"),
+        ]
+        req = SliceRequest(
+            printer_preset_id=1,
+            process_preset_id=2,
+            filament_preset_id=99,  # explicit legacy id — should be ignored
+            filament_presets=refs,
+        )
+        assert req.filament_presets == refs
+        # Precedence pin: when caller sends both shapes, the array wins and
+        # the singular gets backfilled from the array's first entry — NOT
+        # from the legacy id 99. Documents the migration ordering for a
+        # future change that might quietly mix them.
+        assert req.filament_preset == refs[0]
+
+    def test_empty_list_is_backfilled_from_singular(self):
+        req = SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3)
+        # Legacy single-color path: validator promotes the singular into a
+        # one-element list so route handlers can iterate uniformly.
+        assert req.filament_presets == [PresetRef(source="local", id="3")]
+
+    def test_explicit_empty_list_with_singular_set_uses_singular(self):
+        # User of the new schema can leave `filament_presets` as the empty
+        # default and rely on the legacy `filament_preset_id` — same path
+        # as `test_empty_list_is_backfilled_from_singular`.
+        req = SliceRequest(
+            printer_preset_id=1,
+            process_preset_id=2,
+            filament_preset=PresetRef(source="cloud", id="PFU"),
+            filament_presets=[],
+        )
+        assert req.filament_presets == [PresetRef(source="cloud", id="PFU")]
+
+    def test_list_preserves_order(self):
+        refs = [
+            PresetRef(source="cloud", id="slot1"),
+            PresetRef(source="cloud", id="slot2"),
+            PresetRef(source="cloud", id="slot3"),
+        ]
+        req = SliceRequest(
+            printer_preset_id=1,
+            process_preset_id=2,
+            filament_preset_id=3,
+            filament_presets=refs,
+        )
+        assert [r.id for r in req.filament_presets] == ["slot1", "slot2", "slot3"]

+ 237 - 0
backend/tests/unit/test_threemf_tools.py

@@ -5,11 +5,14 @@ and cumulative layer usage lookup.
 """
 
 import io
+import json
 import math
 import zipfile
 
 from backend.app.utils.threemf_tools import (
     extract_filament_usage_from_3mf,
+    extract_plate_extruder_set_from_3mf,
+    extract_project_filaments_from_3mf,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
@@ -408,3 +411,237 @@ class TestExtractFilamentUsageFrom3mf:
         assert len(result) == 1
         assert result[0]["type"] == ""
         assert result[0]["color"] == ""
+
+
+# ---------------------------------------------------------------------------
+# Tests for extract_project_filaments_from_3mf — used by the slice modal as
+# fallback when the sidecar can't run a preview slice.
+# ---------------------------------------------------------------------------
+
+
+def _make_3mf_with(files: dict[str, bytes | str]) -> zipfile.ZipFile:
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        for name, content in files.items():
+            zf.writestr(name, content if isinstance(content, (bytes, str)) else str(content))
+    buf.seek(0)
+    return zipfile.ZipFile(buf, "r")
+
+
+class TestExtractProjectFilamentsFrom3mf:
+    """The helper backfills the slice modal when slice_info.config is empty
+    (raw project files) and the sidecar is unreachable."""
+
+    def test_returns_empty_when_project_settings_missing(self):
+        with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
+            assert extract_project_filaments_from_3mf(zf) == []
+
+    def test_happy_path_returns_one_entry_per_slot(self):
+        proj = {
+            "filament_type": ["PLA", "PETG"],
+            "filament_colour": ["#000000", "#FFFFFF"],
+        }
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
+            out = extract_project_filaments_from_3mf(zf)
+        assert [(f["slot_id"], f["type"], f["color"]) for f in out] == [
+            (1, "PLA", "#000000"),
+            (2, "PETG", "#FFFFFF"),
+        ]
+
+    def test_mismatched_array_lengths_use_max_with_blanks(self):
+        proj = {
+            "filament_type": ["PLA", "PETG", "ABS"],
+            "filament_colour": ["#000000"],
+        }
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
+            out = extract_project_filaments_from_3mf(zf)
+        assert len(out) == 3
+        assert out[0]["color"] == "#000000"
+        assert out[1]["color"] == ""
+        assert out[2]["color"] == ""
+
+    def test_corrupt_json_returns_empty_no_exception(self):
+        with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
+            assert extract_project_filaments_from_3mf(zf) == []
+
+    def test_root_is_list_returns_empty(self):
+        # Defensive: spec says it's a dict, but a file shipping a top-level
+        # list (or anything non-dict) shouldn't crash the modal.
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
+            assert extract_project_filaments_from_3mf(zf) == []
+
+    def test_empty_arrays_returns_empty(self):
+        proj = {"filament_type": [], "filament_colour": []}
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
+            assert extract_project_filaments_from_3mf(zf) == []
+
+
+# ---------------------------------------------------------------------------
+# Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
+# object top-level extruder, per-part extruder, painted-face quadtree leaves.
+# ---------------------------------------------------------------------------
+
+
+def _model_settings(plate_id: int, objects: list[dict]) -> str:
+    """Build a minimal model_settings.config XML for tests. Each object dict
+    can have: id, extruder (top-level), parts (list of {extruder}).
+    The plate references all object ids."""
+    parts_xml = []
+    for obj in objects:
+        oid = obj["id"]
+        ext = obj.get("extruder")
+        parts = obj.get("parts", [])
+        ext_meta = f'<metadata key="extruder" value="{ext}"/>' if ext is not None else ""
+        part_blocks = "".join(
+            f'<part id="{i}" subtype="normal_part"><metadata key="extruder" value="{p["extruder"]}"/></part>'
+            for i, p in enumerate(parts)
+            if p.get("extruder") is not None
+        )
+        parts_xml.append(f'<object id="{oid}"><metadata key="name" value="o{oid}"/>{ext_meta}{part_blocks}</object>')
+    instances = "".join(
+        f'<model_instance><metadata key="object_id" value="{o["id"]}"/></model_instance>' for o in objects
+    )
+    plate = f'<plate><metadata key="plater_id" value="{plate_id}"/>{instances}</plate>'
+    return f'<?xml version="1.0"?><config>{"".join(parts_xml)}{plate}</config>'
+
+
+class TestExtractPlateExtruderSetFrom3mf:
+    def test_returns_empty_set_when_model_settings_missing(self):
+        with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
+
+    def test_object_top_level_extruder_only(self):
+        xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
+        with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
+
+    def test_per_part_extruder_unions_with_top_level(self):
+        # Object's default is 1; one of its parts overrides to 3 (multi-color
+        # via a sub-mesh). Union both — the slicer needs profiles for both.
+        xml = _model_settings(
+            plate_id=1,
+            objects=[{"id": "10", "extruder": 1, "parts": [{"extruder": 3}]}],
+        )
+        with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 3}
+
+    def test_unknown_plate_id_returns_empty_set(self):
+        xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
+        with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=99) == set()
+
+    def test_corrupt_xml_returns_empty_set_no_exception(self):
+        with _make_3mf_with({"Metadata/model_settings.config": "<not valid xml"}) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
+
+    def test_zero_extruder_value_ignored(self):
+        # Bambu's 0 means "use object default" — not a real slot.
+        xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 0}])
+        with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
+
+    def test_painted_face_above_threshold_kept(self):
+        # 60/40 split: 60 triangles painted with extruder 1, 40 with ext 2.
+        # Threshold is 5%; both above. The dominant ones are real colours.
+        triangles = []
+        for _ in range(60):
+            triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
+        for _ in range(40):
+            triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="2"/>')
+        per_obj = (
+            '<?xml version="1.0"?>'
+            '<model><resources><object id="100" type="model"><mesh>'
+            "<triangles>" + "".join(triangles) + "</triangles>"
+            "</mesh></object></resources><build/></model>"
+        )
+        ms = (
+            '<?xml version="1.0"?><config>'
+            '<object id="10"><metadata key="name" value="o"/></object>'
+            '<plate><metadata key="plater_id" value="1"/>'
+            '<model_instance><metadata key="object_id" value="10"/></model_instance>'
+            "</plate></config>"
+        )
+        threed = (
+            '<?xml version="1.0"?>'
+            '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
+            ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
+            "<resources>"
+            '<object id="10" type="model"><components>'
+            '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
+            "</components></object>"
+            "</resources><build/></model>"
+        )
+        with _make_3mf_with(
+            {
+                "Metadata/model_settings.config": ms,
+                "3D/3dmodel.model": threed,
+                "3D/Objects/o100.model": per_obj,
+            }
+        ) as zf:
+            result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
+        # Both real colours kept (60/40 well above 5% threshold); the dropped
+        # threshold case is the regression that motivates this test.
+        assert result == {1, 2}
+
+    def test_painted_face_below_threshold_dropped_as_noise(self):
+        # 99 triangles at ext 1, 1 triangle at ext 9 (1% — below 5%
+        # threshold). The 1% leaf is a single-leaf accident.
+        triangles = []
+        for _ in range(99):
+            triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
+        triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="9"/>')
+        per_obj = (
+            '<?xml version="1.0"?>'
+            '<model><resources><object id="100" type="model"><mesh>'
+            "<triangles>" + "".join(triangles) + "</triangles>"
+            "</mesh></object></resources><build/></model>"
+        )
+        ms = (
+            '<?xml version="1.0"?><config>'
+            '<object id="10"><metadata key="name" value="o"/></object>'
+            '<plate><metadata key="plater_id" value="1"/>'
+            '<model_instance><metadata key="object_id" value="10"/></model_instance>'
+            "</plate></config>"
+        )
+        threed = (
+            '<?xml version="1.0"?>'
+            '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
+            ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
+            '<resources><object id="10" type="model"><components>'
+            '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
+            "</components></object></resources><build/></model>"
+        )
+        with _make_3mf_with(
+            {
+                "Metadata/model_settings.config": ms,
+                "3D/3dmodel.model": threed,
+                "3D/Objects/o100.model": per_obj,
+            }
+        ) as zf:
+            result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
+        # Single-leaf accident at 1% filtered as noise; only the dominant
+        # extruder survives.
+        assert result == {1}
+
+    def test_missing_per_object_model_file_silently_skipped(self):
+        ms = (
+            '<?xml version="1.0"?><config>'
+            '<object id="10"><metadata key="extruder" value="2"/></object>'
+            '<plate><metadata key="plater_id" value="1"/>'
+            '<model_instance><metadata key="object_id" value="10"/></model_instance>'
+            "</plate></config>"
+        )
+        threed = (
+            '<?xml version="1.0"?>'
+            '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
+            ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
+            '<resources><object id="10" type="model"><components>'
+            '<component p:path="/3D/Objects/missing.model" objectid="999"/>'
+            "</components></object></resources><build/></model>"
+        )
+        with _make_3mf_with(
+            {"Metadata/model_settings.config": ms, "3D/3dmodel.model": threed},
+        ) as zf:
+            # Top-level metadata still works; missing component model file
+            # is silently skipped without crashing.
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}

+ 383 - 16
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -22,6 +22,10 @@ vi.mock('../../api/client', () => ({
     sliceLibraryFile: vi.fn(),
     sliceArchive: vi.fn(),
     getSliceJob: vi.fn(),
+    getLibraryFilePlates: vi.fn(),
+    getArchivePlates: vi.fn(),
+    getLibraryFileFilamentRequirements: vi.fn(),
+    getArchiveFilamentRequirements: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
   },
@@ -32,6 +36,10 @@ const mockApi = api as unknown as {
   sliceLibraryFile: ReturnType<typeof vi.fn>;
   sliceArchive: ReturnType<typeof vi.fn>;
   getSliceJob: ReturnType<typeof vi.fn>;
+  getLibraryFilePlates: ReturnType<typeof vi.fn>;
+  getArchivePlates: ReturnType<typeof vi.fn>;
+  getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
+  getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
 };
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -84,6 +92,33 @@ describe('SliceModal', () => {
       started_at: null,
       completed_at: null,
     });
+    // Default: single-plate (or non-3MF). Multi-plate tests override this.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plates: [],
+      is_multi_plate: false,
+    });
+    mockApi.getArchivePlates.mockResolvedValue({
+      archive_id: 100,
+      filename: 'Cube.3mf',
+      plates: [],
+      is_multi_plate: false,
+    });
+    // Default: no per-plate filament metadata available (mirrors STL or
+    // unsliced source). Multi-color tests override this.
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plate_id: 1,
+      filaments: [],
+    });
+    mockApi.getArchiveFilamentRequirements.mockResolvedValue({
+      archive_id: 100,
+      filename: 'Cube.3mf',
+      plate_id: 1,
+      filaments: [],
+    });
   });
 
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -92,44 +127,45 @@ describe('SliceModal', () => {
       onClose: vi.fn(),
     });
 
-    // The cloud tier wins — printer dropdown should land on the cloud entry.
+    // SliceModal-specific tier priority: imported (local) wins over cloud
+    // and standard so the user's curated picks come first.
     await waitFor(() => {
       expect(screen.getByText('My Custom X1C')).toBeDefined();
     });
     const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
     expect(selects).toHaveLength(3);
-    expect(selects[0].value).toBe('cloud:PFUcloud-printer');
-    expect(selects[1].value).toBe('cloud:PFUcloud-process');
-    expect(selects[2].value).toBe('cloud:PFUcloud-filament');
+    expect(selects[0].value).toBe('local:1');
+    expect(selects[1].value).toBe('local:2');
+    expect(selects[2].value).toBe('local:3');
 
     // Slice button is enabled because all three slots auto-defaulted.
     const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
     expect((sliceBtn as HTMLButtonElement).disabled).toBe(false);
   });
 
-  it('renders Cloud / Imported / Standard sections via <optgroup>', async () => {
+  it('renders Imported / Cloud / Standard sections via <optgroup>', async () => {
     renderWithTracker({
       source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
       onClose: vi.fn(),
     });
 
-    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+    await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
 
     const printerSelect = screen.getAllByRole('combobox')[0];
     const groups = printerSelect.querySelectorAll('optgroup');
     expect(Array.from(groups).map((g) => g.label)).toEqual([
-      'Cloud',
       'Imported',
+      'Cloud',
       'Standard',
     ]);
 
-    // The cloud entry sits inside the Cloud group, the local entry inside
-    // Imported, the standard entry inside Standard — pin the assignment so
-    // a future render-shape change can't quietly mix them.
-    const cloudGroup = groups[0];
-    expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined();
-    const localGroup = groups[1];
+    // Each entry sits inside its own tier's group — pin the assignment so
+    // a future render-shape change can't quietly mix them. Order matches
+    // SLICE_MODAL_TIER_ORDER (local → cloud → standard).
+    const localGroup = groups[0];
     expect(within(localGroup as HTMLElement).getByText('Imported X1C 0.4')).toBeDefined();
+    const cloudGroup = groups[1];
+    expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined();
     const standardGroup = groups[2];
     expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined();
   });
@@ -184,10 +220,14 @@ describe('SliceModal', () => {
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
 
     await waitFor(() => {
+      // SliceModal-specific tier priority puts imported (local) above cloud,
+      // so the auto-pick lands on the local entries even when a cloud entry
+      // with the same slot is also available in the listing.
       expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, {
-        printer_preset: { source: 'cloud', id: 'PFUcloud-printer' },
-        process_preset: { source: 'cloud', id: 'PFUcloud-process' },
-        filament_preset: { source: 'cloud', id: 'PFUcloud-filament' },
+        printer_preset: { source: 'local', id: '1' },
+        process_preset: { source: 'local', id: '2' },
+        filament_preset: { source: 'local', id: '3' },
+        filament_presets: [{ source: 'local', id: '3' }],
       });
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -324,4 +364,331 @@ describe('SliceModal', () => {
     // No status-role banner should be rendered on the happy path.
     expect(screen.queryByRole('status')).toBeNull();
   });
+
+  // ----- Multi-plate flow -----------------------------------------------
+
+  function makeMultiPlateLibraryResponse() {
+    return {
+      file_id: 100,
+      filename: 'Multi.3mf',
+      is_multi_plate: true,
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: ['Cube'],
+          object_count: 1,
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: 600,
+          filament_used_grams: 10,
+          filaments: [],
+        },
+        {
+          index: 2,
+          name: 'Plate 2',
+          objects: ['Pyramid'],
+          object_count: 1,
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: 800,
+          filament_used_grams: 12,
+          filaments: [],
+        },
+      ],
+    };
+  }
+
+  it('shows the plate picker first for multi-plate library files', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
+      onClose: vi.fn(),
+    });
+
+    // Plate picker renders one button per plate — the accessible name
+    // joins the heading ("Plate N — name") with the object summary line.
+    await screen.findByRole('button', { name: /Plate 1.*Cube/ });
+    expect(screen.getByRole('button', { name: /Plate 2.*Pyramid/ })).toBeDefined();
+    // Profile dropdowns must NOT be visible yet — the user has to pick a
+    // plate first.
+    expect(screen.queryByRole('combobox')).toBeNull();
+  });
+
+  it('skips the plate picker 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(),
+    });
+
+    // Should jump straight to the profile dropdowns.
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+  });
+
+  it('passes the picked plate to the slice request', 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();
+    // Step 1: pick Plate 2.
+    const plate2Button = await screen.findByRole('button', { name: /Plate 2.*Pyramid/ });
+    await user.click(plate2Button);
+
+    // Step 2: profile dropdowns are now visible.
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+    // Step 3: submit and verify the plate index made it into the body.
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+        100,
+        expect.objectContaining({ plate: 2 }),
+      );
+    });
+  });
+
+  it('routes the plate fetch through getArchivePlates for archive sources', async () => {
+    mockApi.getArchivePlates.mockResolvedValue({
+      ...makeMultiPlateLibraryResponse(),
+      archive_id: 100,
+      filename: 'Multi.3mf',
+    });
+    renderWithTracker({
+      source: { kind: 'archive', id: 100, filename: 'Multi.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await screen.findByRole('button', { name: /Plate 1.*Cube/ });
+    expect(mockApi.getArchivePlates).toHaveBeenCalledWith(100);
+    expect(mockApi.getLibraryFilePlates).not.toHaveBeenCalled();
+  });
+
+  it('cancelling the plate picker closes the entire slice flow', async () => {
+    const onClose = vi.fn();
+    mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
+      onClose,
+    });
+
+    await screen.findByRole('button', { name: /Plate 1.*Cube/ });
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Close$/i }));
+
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('omits the plate field when the source is single-plate', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body).not.toHaveProperty('plate');
+    });
+  });
+
+  // ----- Multi-color flow ------------------------------------------------
+
+  function makeMultiColorPlateResponse() {
+    // Single-plate 3MF that uses two filament slots — mirrors the realistic
+    // "I have a multi-color file with one plate" case. Multi-plate is a
+    // separate axis that's already covered above.
+    return {
+      file_id: 100,
+      filename: 'TwoColor.3mf',
+      is_multi_plate: false,
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: ['Logo'],
+          object_count: 1,
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: 600,
+          filament_used_grams: 20,
+          filaments: [],
+        },
+      ],
+    };
+  }
+
+  function makeMultiColorRequirementsResponse() {
+    return {
+      file_id: 100,
+      filename: 'TwoColor.3mf',
+      plate_id: 1,
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3 },
+        { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 10, used_meters: 3 },
+      ],
+    };
+  }
+
+  function makeColorAwarePresets(): UnifiedPresetsResponse {
+    // Two filament presets in cloud: one black PLA, one white PLA. Pre-pick
+    // should match each plate slot to the same-colour preset so the user
+    // doesn't have to manually align them.
+    return {
+      cloud: {
+        printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
+        process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
+        filament: [
+          { id: 'F-BLACK', name: 'Cloud PLA Black', source: 'cloud', filament_type: 'PLA', filament_colour: '#000000' },
+          { id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+        ],
+      },
+      local: { printer: [], process: [], filament: [] },
+      standard: { printer: [], process: [], filament: [] },
+      cloud_status: 'ok',
+    };
+  }
+
+  it('renders one filament dropdown per plate slot when the source is multi-color', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
+    mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+    // 1 printer + 1 process + 2 filament = 4 dropdowns.
+    expect(screen.getAllByRole('combobox')).toHaveLength(4);
+  });
+
+  it('pre-picks each filament slot by matching colour metadata', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
+    mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      // Slot 1 was black plate → cloud black preset; slot 2 was white →
+      // cloud white preset. Pre-pick aligns them by metadata so the user
+      // doesn't have to swap them manually.
+      expect(body.filament_presets).toEqual([
+        { source: 'cloud', id: 'F-BLACK' },
+        { source: 'cloud', id: 'F-WHITE' },
+      ]);
+    });
+  });
+
+  it('still sends the legacy filament_preset for single-color flows', async () => {
+    // Backwards-compat with backends / proxies that read the singular field.
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      // Single-color path mirrors the array's first entry into the legacy
+      // singular so older backend clients that only know about
+      // `filament_preset` still work.
+      expect(body.filament_preset).toEqual(body.filament_presets[0]);
+      expect(body.filament_presets).toHaveLength(1);
+    });
+  });
+
+  it('lets the user override a pre-picked filament slot', async () => {
+    mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
+    mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+
+    const user = userEvent.setup();
+    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    // Slots 0 (printer) and 1 (process) are auto-picked. Slots 2 and 3 are
+    // the two filament dropdowns. Swap slot-2 (was black) to white.
+    await user.selectOptions(selects[2], 'cloud:F-WHITE');
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' });
+      // Slot 1 stayed at the auto-picked white.
+      expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-WHITE' });
+    });
+  });
 });

+ 12 - 0
frontend/src/api/client.ts

@@ -1129,6 +1129,11 @@ export interface SliceRequest {
   printer_preset?: PresetRef;
   process_preset?: PresetRef;
   filament_preset?: PresetRef;
+  // Multi-color: one PresetRef per plate slot, in plate order. Always
+  // preferred over the singular `filament_preset` when both are sent; the
+  // backend validator promotes a singular into a one-element list when this
+  // is omitted, so legacy single-color clients keep working unchanged.
+  filament_presets?: PresetRef[];
   plate?: number;
   export_3mf?: boolean;
 }
@@ -1139,6 +1144,13 @@ export interface UnifiedPreset {
   id: string;
   name: string;
   source: PresetSource;
+  // Populated for the filament slot only — used by the SliceModal multi-color
+  // pre-pick to score presets against each plate slot's required (type,
+  // colour). Optional because the bundled / standard tier rarely carries a
+  // colour (colour is a runtime spool attribute on Bambu) and older API
+  // responses pre-date these fields entirely.
+  filament_type?: string | null;
+  filament_colour?: string | null;
 }
 export interface UnifiedPresetsBySlot {
   printer: UnifiedPreset[];

+ 221 - 25
frontend/src/components/SliceModal.tsx

@@ -12,6 +12,9 @@ import {
   type UnifiedPresetsResponse,
 } from '../api/client';
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
+import { PlatePickerModal } from './PlatePickerModal';
+import type { PlateFilament } from '../types/plates';
+import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
 
 export type SliceSource =
   | { kind: 'libraryFile'; id: number; filename: string }
@@ -24,10 +27,16 @@ interface SliceModalProps {
 
 type Slot = 'printer' | 'process' | 'filament';
 
+// SliceModal-specific tier priority: local (imported) → cloud → standard.
+// Imported profiles are surfaced first because they're the user's curated
+// picks (often colour/type-tagged), cloud is second since names alone can't
+// drive metadata-aware match, standard is the bundled fallback. This is
+// distinct from the listing endpoint's dedup order and only affects what
+// the SliceModal renders / pre-picks.
+const SLICE_MODAL_TIER_ORDER = ['local', 'cloud', 'standard'] as const;
+
 function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
-  // Cloud > local > standard. The endpoint already deduplicates by name, so
-  // no name-collision handling needed here — first non-empty tier wins.
-  for (const tier of ['cloud', 'local', 'standard'] as const) {
+  for (const tier of SLICE_MODAL_TIER_ORDER) {
     const list = by[tier][slot];
     if (list.length > 0) {
       return { source: list[0].source, id: list[0].id };
@@ -36,6 +45,48 @@ function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
   return null;
 }
 
+const TIER_BONUS: Record<PresetSource, number> = {
+  local: 1.5,
+  cloud: 1.0,
+  standard: 0.5,
+};
+
+function pickFilamentForSlot(
+  by: UnifiedPresetsResponse,
+  required: { type: string; color: string },
+): PresetRef | null {
+  // Score every filament preset against the plate slot's required (type,
+  // colour) and pick the highest. Mirrors the AMS slot-mapping match in the
+  // print/schedule modal: type match dominates, exact-colour-match bumps over
+  // similar-colour-match, and a small per-tier bonus breaks ties so cloud
+  // user customisations win over standard bundled fallbacks of equal merit.
+  const reqType = required.type.trim().toUpperCase();
+  const reqColor = normalizeColorForCompare(required.color);
+
+  let best: { ref: PresetRef; score: number } | null = null;
+  for (const tier of SLICE_MODAL_TIER_ORDER) {
+    for (const p of by[tier].filament) {
+      let score = 0;
+      const presetType = (p.filament_type ?? '').trim().toUpperCase();
+      const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
+      if (reqType && presetType && reqType === presetType) score += 10;
+      if (reqColor && presetColor) {
+        if (presetColor === reqColor) score += 5;
+        else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
+      }
+      score += TIER_BONUS[tier];
+      if (best == null || score > best.score) {
+        best = { ref: { source: p.source, id: p.id }, score };
+      }
+    }
+  }
+  // Fall back to plain priority pick if every preset scored 0+tier (i.e. no
+  // metadata matched). The fallback is exactly the single-color default —
+  // first preset in the highest-priority non-empty tier.
+  if (best == null) return pickDefault(by, 'filament');
+  return best.ref;
+}
+
 function toRefValue(ref: PresetRef | null): string {
   // The HTML `<select>` value space is flat strings; encode source + id so
   // the same preset name can live in multiple tiers without collision.
@@ -58,36 +109,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
 
   const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
   const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
-  const [filamentPreset, setFilamentPreset] = useState<PresetRef | null>(null);
+  // One filament ref per plate slot, in plate order. For STL / single-plate /
+  // single-color sources this is a one-element array; multi-color 3MFs get one
+  // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
+  // each slot from the source plate's required (type, colour).
+  const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
   const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
+  // 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);
+
+  const platesQuery = useQuery({
+    queryKey: ['slicePlates', source.kind, source.id],
+    queryFn: async () => {
+      if (source.kind === 'libraryFile') {
+        return api.getLibraryFilePlates(source.id);
+      }
+      return api.getArchivePlates(source.id);
+    },
+    staleTime: 60_000,
+  });
+
+  const isMultiPlate =
+    !!platesQuery.data?.is_multi_plate && (platesQuery.data?.plates?.length ?? 0) > 1;
+  // Single-plate / non-3MF / fetch failure: skip the picker, default to plate 1
+  // at submit time so the backend's existing default behaviour is preserved.
+  const needsPlatePicker = isMultiPlate && selectedPlate == null;
+
+  // Per-plate filament requirements via the same endpoint the print/schedule
+  // modal uses. Reusing it here keeps the SliceModal honest with whatever
+  // logic that endpoint applies (slice_info parsing, future enhancements for
+  // unsliced project files, dual-nozzle fields, etc.) instead of duplicating
+  // extraction. plate_id is always sent: single-plate falls through to plate
+  // 1 server-side; multi-plate uses the user's pick.
+  const effectivePlateId = selectedPlate ?? 1;
+  const filamentReqsQuery = useQuery({
+    queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
+    queryFn: async () => {
+      if (source.kind === 'libraryFile') {
+        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId);
+      }
+      return api.getArchiveFilamentRequirements(source.id, effectivePlateId);
+    },
+    enabled: !needsPlatePicker,
+    staleTime: 60_000,
+  });
+
+  // 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).
+  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 presetsQuery = useQuery({
     queryKey: ['slicerPresets'],
     queryFn: () => api.getSlicerPresets(),
     staleTime: 60_000,
+    // Don't fetch presets while the plate picker is on screen — saves a
+    // round-trip if the user cancels out of the plate step.
+    enabled: !platesQuery.isLoading && !needsPlatePicker,
   });
 
-  // Default selection: cloud > local > standard. Runs only on the first
-  // successful load; subsequent re-renders preserve the user's manual choice.
+  // Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
+  // presets first arrive; subsequent re-renders preserve any manual choice.
   useEffect(() => {
     if (!presetsQuery.data) return;
     if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
     if (processPreset == null) setProcessPreset(pickDefault(presetsQuery.data, 'process'));
-    if (filamentPreset == null) setFilamentPreset(pickDefault(presetsQuery.data, 'filament'));
-    // Intentionally exclude state-setters and current selections from deps —
-    // we only want the auto-pick to fire once when data first arrives.
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [presetsQuery.data]);
 
+  // Filament pre-pick: re-runs whenever the active filament-slot count
+  // changes (plate selection, single-plate metadata arriving). For each slot
+  // we score every available filament preset against the slot's required
+  // (type, colour) and keep the highest match. Slot count mismatch → reset
+  // and re-pick everything; same length → preserve any user override.
+  useEffect(() => {
+    if (!presetsQuery.data) return;
+    const data = presetsQuery.data;
+    setFilamentPresets((current) => {
+      if (current.length === filamentSlots.length && current.every((r) => r != null)) {
+        return current;
+      }
+      return filamentSlots.map((slot) =>
+        pickFilamentForSlot(data, { type: slot.type, color: slot.color }),
+      );
+    });
+  }, [presetsQuery.data, filamentSlots]);
+
   const enqueueMutation = useMutation({
     mutationFn: async () => {
-      if (!printerPreset || !processPreset || !filamentPreset) {
-        throw new Error('All three presets must be selected');
+      if (
+        !printerPreset ||
+        !processPreset ||
+        filamentPresets.length === 0 ||
+        filamentPresets.some((r) => r == null)
+      ) {
+        throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
       }
       const body = {
         printer_preset: printerPreset,
         process_preset: processPreset,
-        filament_preset: filamentPreset,
+        // 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 } : {}),
       };
       if (source.kind === 'libraryFile') {
         return api.sliceLibraryFile(source.id, body);
@@ -104,9 +241,29 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     },
   });
 
-  const isReady = printerPreset != null && processPreset != null && filamentPreset != null;
+  const isReady =
+    printerPreset != null &&
+    processPreset != null &&
+    filamentPresets.length > 0 &&
+    filamentPresets.every((r) => r != null);
   const isEnqueuing = enqueueMutation.isPending;
 
+  // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
+  // entire flow (matches the existing PlatePickerModal contract used by the
+  // archive g-code-viewer entry point).
+  if (needsPlatePicker && platesQuery.data) {
+    return (
+      <PlatePickerModal
+        plates={platesQuery.data.plates}
+        onSelect={(plateIndex) => setSelectedPlate(plateIndex)}
+        onClose={onClose}
+      />
+    );
+  }
+
+  // Step 2 (or only step for single-plate / non-3MF / load-failure): preset
+  // picker. While the plates query is in-flight we still render the shell
+  // because the presets query is gated on it; the loader covers both.
   return (
     <div
       className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@@ -127,6 +284,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
             </h3>
             <p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
               {source.filename}
+              {selectedPlate != null
+                ? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
+                : ''}
             </p>
           </div>
           <button
@@ -141,7 +301,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
 
         {/* Body */}
         <div className="flex-1 overflow-y-auto p-4 space-y-4">
-          {presetsQuery.isLoading && (
+          {(platesQuery.isLoading || presetsQuery.isLoading || filamentReqsQuery.isLoading) && (
             <div className="flex items-center gap-2 text-bambu-gray text-sm">
               <Loader2 className="w-4 h-4 animate-spin" />
               {t('slice.loadingPresets', 'Loading presets…')}
@@ -176,14 +336,34 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 onChange={setProcessPreset}
                 disabled={isEnqueuing}
               />
-              <PresetDropdown
-                label={t('slice.filament', 'Filament profile')}
-                slot="filament"
-                data={presetsQuery.data}
-                value={filamentPreset}
-                onChange={setFilamentPreset}
-                disabled={isEnqueuing}
-              />
+              {filamentSlots.map((slot, idx) => (
+                <PresetDropdown
+                  key={`filament-${idx}`}
+                  label={
+                    filamentSlots.length > 1
+                      ? t('slice.filamentSlot', {
+                          index: idx + 1,
+                          type: slot.type,
+                          defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
+                        })
+                      : t('slice.filament', 'Filament profile')
+                  }
+                  slot="filament"
+                  data={presetsQuery.data}
+                  value={filamentPresets[idx] ?? null}
+                  onChange={(ref) =>
+                    setFilamentPresets((current) => {
+                      const next = current.length === filamentSlots.length
+                        ? [...current]
+                        : filamentSlots.map((_, i) => current[i] ?? null);
+                      next[idx] = ref;
+                      return next;
+                    })
+                  }
+                  disabled={isEnqueuing}
+                  swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
+                />
+              ))}
             </>
           )}
 
@@ -271,15 +451,22 @@ interface PresetDropdownProps {
   value: PresetRef | null;
   onChange: (ref: PresetRef | null) => void;
   disabled?: boolean;
+  // Optional colour swatch shown next to the label — used for multi-color
+  // filament slots so the user can see at a glance which slot they're
+  // configuring against the source 3MF's per-slot colour.
+  swatchColor?: string;
 }
 
-function PresetDropdown({ label, slot, data, value, onChange, disabled }: PresetDropdownProps) {
+function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor }: PresetDropdownProps) {
   const { t } = useTranslation();
 
   const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
+    // Order matches SLICE_MODAL_TIER_ORDER: imported first, then cloud, then
+    // standard fallback. Sections with no entries collapse out so a user
+    // without cloud / local presets only sees the tiers they actually have.
     const tiers: { key: keyof UnifiedPresetsResponse; tier: 'cloud' | 'local' | 'standard'; label: string; fallback: string }[] = [
-      { key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
       { key: 'local', tier: 'local', label: 'slice.tier.local', fallback: 'Imported' },
+      { key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
       { key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
     ];
     return tiers
@@ -294,7 +481,16 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled }: Preset
 
   return (
     <label className="block">
-      <span className="block text-xs text-bambu-gray mb-1">{label}</span>
+      <span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
+        {swatchColor && (
+          <span
+            className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
+            style={{ backgroundColor: swatchColor || 'transparent' }}
+            aria-hidden
+          />
+        )}
+        <span>{label}</span>
+      </span>
       <select
         value={toRefValue(value)}
         onChange={(e) => onChange(fromRefValue(e.target.value))}

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

@@ -3250,15 +3250,28 @@ export default {
     printer: 'Drucker-Profil',
     process: 'Prozess-Profil',
     filament: 'Filament-Profil',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Profil auswählen —',
     loadingPresets: 'Profile werden geladen…',
+    noPresetsForSlot: 'Keine Profile verfügbar',
     presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
+    allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
     startedToast: '{{name}} wird im Hintergrund gesliced…',
     completedToast: '{{name}} wurde gesliced',
     failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
+    tier: {
+      local: 'Importiert',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'In Bambu Cloud anmelden (Einstellungen → Profile → Cloud), um deine Cloud-Profile zu sehen.',
+      expired: 'Bambu-Cloud-Sitzung abgelaufen — erneut anmelden, um die Cloud-Profile zu aktualisieren.',
+      unreachable: 'Bambu Cloud ist gerade nicht erreichbar. Lokale und Standard-Profile funktionieren weiterhin.',
+    },
   },
 
   // Spoolman

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

@@ -3253,15 +3253,28 @@ export default {
     printer: 'Printer profile',
     process: 'Process profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
+    tier: {
+      local: 'Imported',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
+      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
+      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+    },
   },
 
   // Spoolman

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

@@ -3172,15 +3172,28 @@ export default {
     printer: 'Printer profile',
     process: 'Process profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
+    tier: {
+      local: 'Imported',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
+      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
+      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+    },
   },
 
   // Spoolman

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

@@ -3171,15 +3171,28 @@ export default {
     printer: 'Printer profile',
     process: 'Process profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
+    tier: {
+      local: 'Imported',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
+      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
+      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+    },
   },
 
   // Spoolman

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

@@ -3210,15 +3210,28 @@ export default {
     printer: 'Printer profile',
     process: 'Process profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
+    tier: {
+      local: 'Imported',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
+      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
+      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+    },
   },
 
   // Spoolman

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

@@ -3185,15 +3185,28 @@ export default {
     printer: 'Printer profile',
     process: 'Process profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
+    tier: {
+      local: 'Imported',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
+      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
+      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+    },
   },
 
   // Spoolman

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

@@ -3237,15 +3237,28 @@ export default {
     printer: 'Printer profile',
     process: 'Process profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
+    tier: {
+      local: 'Imported',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
+      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
+      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+    },
   },
 
   // Spoolman

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

@@ -3237,15 +3237,28 @@ export default {
     printer: 'Printer profile',
     process: 'Process profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
+    tier: {
+      local: 'Imported',
+      cloud: 'Cloud',
+      standard: 'Standard',
+    },
+    cloud: {
+      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
+      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
+      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+    },
   },
 
   // Spoolman

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-ByGZ61Vo.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-BGNGgkMm.js"></script>
+    <script type="module" crossorigin src="/assets/index-ByGZ61Vo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Bbpbjxtl.css">
   </head>
   <body>

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