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 1 month 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.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -3109,6 +3113,47 @@ async def get_plate_thumbnail(
     raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
     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")
 @router.get("/{archive_id}/filament-requirements")
 async def get_filament_requirements(
 async def get_filament_requirements(
     archive_id: int,
     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
             # Sort by slot ID
             filaments.sort(key=lambda x: x["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.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
 from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 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__)
 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")
     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")
 @router.get("/files/{file_id}/filament-requirements")
 async def get_library_file_filament_requirements(
 async def get_library_file_filament_requirements(
     file_id: int,
     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
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
             filaments.sort(key=lambda x: x["slot_id"])
 
 
@@ -2567,11 +2642,17 @@ async def _run_slicer_with_fallback(
     refs = {
     refs = {
         "printer": request.printer_preset,
         "printer": request.printer_preset,
         "process": request.process_preset,
         "process": request.process_preset,
-        "filament": request.filament_preset,
     }
     }
     for slot, ref in refs.items():
     for slot, ref in refs.items():
         assert ref is not None, "schema validator guarantees PresetRef is set"
         assert ref is not None, "schema validator guarantees PresetRef is set"
         presets[slot] = await resolve_preset_ref(db, user, ref, slot)
         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.
     # Slicer routing — pick the sidecar URL by preferred_slicer.
     # The per-install URL setting (Settings UI → Slicer card) wins; an
     # 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,
                 model_filename=model_filename,
                 printer_profile_json=presets["printer"],
                 printer_profile_json=presets["printer"],
                 process_profile_json=presets["process"],
                 process_profile_json=presets["process"],
-                filament_profile_json=presets["filament"],
+                filament_profile_jsons=filament_jsons,
                 plate=request.plate,
                 plate=request.plate,
                 export_3mf=request.export_3mf,
                 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
 from __future__ import annotations
 
 
 import hashlib
 import hashlib
+import json
 import logging
 import logging
 import time
 import time
 
 
@@ -105,41 +106,54 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
     cloud = BambuCloudService(region=region)
     cloud = BambuCloudService(region=region)
     cloud.set_token(token)
     cloud.set_token(token)
     try:
     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:
     finally:
         await cloud.close()
         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]]:
 async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
     """Local imports — no caching needed, single indexed DB read."""
     """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)
         slot = type_to_slot.get(p.preset_type)
         if slot is None:
         if slot is None:
             continue
             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
     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]]:
 async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
     """Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
     """Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
     global _bundled_cache
     global _bundled_cache
@@ -186,7 +230,13 @@ async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPres
                 continue
                 continue
             # Bundled presets are addressed by name (the slicer resolves them
             # Bundled presets are addressed by name (the slicer resolves them
             # by name during the `inherits:` walk), so name doubles as id.
             # 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)
     _bundled_cache = (now, slots)
     return slots
     return slots
@@ -236,7 +286,36 @@ def _dedupe_by_name(
     Order within each tier is preserved as-is — only "lower-priority duplicates"
     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
     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.
     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_local = _empty_slots()
     deduped_standard = _empty_slots()
     deduped_standard = _empty_slots()
     for slot in ("printer", "process", "filament"):
     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
     process_preset: PresetRef | None = None
     filament_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(
     plate: int | None = Field(
         default=None,
         default=None,
         ge=1,
         ge=1,
@@ -67,11 +74,13 @@ class SliceRequest(BaseModel):
     def normalise_preset_refs(self) -> "SliceRequest":
     def normalise_preset_refs(self) -> "SliceRequest":
         """Each slot must end up with a `PresetRef` set. Legacy integer ids
         """Each slot must end up with a `PresetRef` set. Legacy integer ids
         become `(source='local', id=str(int))` so the route handler only
         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 (
         for slot, ref_attr, legacy_attr in (
             ("printer", "printer_preset", "printer_preset_id"),
             ("printer", "printer_preset", "printer_preset_id"),
             ("process", "process_preset", "process_preset_id"),
             ("process", "process_preset", "process_preset_id"),
-            ("filament", "filament_preset", "filament_preset_id"),
         ):
         ):
             ref = getattr(self, ref_attr)
             ref = getattr(self, ref_attr)
             legacy_id = getattr(self, legacy_attr)
             legacy_id = getattr(self, legacy_attr)
@@ -81,6 +90,28 @@ class SliceRequest(BaseModel):
                 )
                 )
             if ref is None:
             if ref is None:
                 setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
                 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
         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
     The frontend treats ``id`` as opaque; the slice dispatch path uses
     ``(source, id)`` to fetch / pass the preset content to the sidecar.
     ``(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
     id: str
     name: str
     name: str
     source: Literal["cloud", "local", "standard"]
     source: Literal["cloud", "local", "standard"]
+    filament_type: str | None = None
+    filament_colour: str | None = None
 
 
 
 
 class UnifiedPresetsBySlot(BaseModel):
 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,
         model_filename: str,
         printer_profile_json: str,
         printer_profile_json: str,
         process_profile_json: str,
         process_profile_json: str,
-        filament_profile_json: str,
+        filament_profile_jsons: list[str],
         plate: int | None = None,
         plate: int | None = None,
         export_3mf: bool = False,
         export_3mf: bool = False,
     ) -> SliceResult:
     ) -> 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:
         Raises:
             SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
             SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
             SlicerApiUnavailableError: connection error or 5xx from sidecar.
             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] = {}
         data: dict[str, str] = {}
         if plate is not None:
         if plate is not None:
             data["plate"] = str(plate)
             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():
         if "tmp_path" in locals() and tmp_path.exists():
             tmp_path.unlink(missing_ok=True)
             tmp_path.unlink(missing_ok=True)
         return None
         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",
             model_filename="Cube.stl",
             printer_profile_json='{"name": "p"}',
             printer_profile_json='{"name": "p"}',
             process_profile_json='{"name": "pr"}',
             process_profile_json='{"name": "pr"}',
-            filament_profile_json='{"name": "f"}',
+            filament_profile_jsons=['{"name": "f"}'],
         )
         )
 
 
         assert isinstance(result, SliceResult)
         assert isinstance(result, SliceResult)
@@ -103,7 +103,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 printer_profile_json="{}",
                 process_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
             )
         assert "Invalid file type" in str(exc_info.value)
         assert "Invalid file type" in str(exc_info.value)
 
 
@@ -125,7 +125,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 printer_profile_json="{}",
                 process_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
             )
         assert "Failed to slice the model" in str(exc_info.value)
         assert "Failed to slice the model" in str(exc_info.value)
 
 
@@ -153,7 +153,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 printer_profile_json="{}",
                 process_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
             )
         msg = str(exc_info.value)
         msg = str(exc_info.value)
         assert "Failed to slice the model" in msg
         assert "Failed to slice the model" in msg
@@ -178,7 +178,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 printer_profile_json="{}",
                 process_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
             )
         assert "SIGSEGV" in str(exc_info.value)
         assert "SIGSEGV" in str(exc_info.value)
 
 
@@ -198,7 +198,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 printer_profile_json="{}",
                 process_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
             )
         assert "Bad Gateway" in str(exc_info.value)
         assert "Bad Gateway" in str(exc_info.value)
 
 
@@ -214,7 +214,7 @@ class TestSliceWithProfiles:
                 model_filename="Cube.stl",
                 model_filename="Cube.stl",
                 printer_profile_json="{}",
                 printer_profile_json="{}",
                 process_profile_json="{}",
                 process_profile_json="{}",
-                filament_profile_json="{}",
+                filament_profile_jsons=["{}"],
             )
             )
         assert "unreachable" in str(exc_info.value).lower()
         assert "unreachable" in str(exc_info.value).lower()
 
 
@@ -236,7 +236,7 @@ class TestSliceWithProfiles:
             model_filename="Cube.stl",
             model_filename="Cube.stl",
             printer_profile_json="{}",
             printer_profile_json="{}",
             process_profile_json="{}",
             process_profile_json="{}",
-            filament_profile_json="{}",
+            filament_profile_jsons=["{}"],
             plate=2,
             plate=2,
             export_3mf=True,
             export_3mf=True,
         )
         )
@@ -249,6 +249,41 @@ class TestSliceWithProfiles:
         assert b'name="exportType"' in body
         assert b'name="exportType"' in body
         assert b"3mf" 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
     @pytest.mark.asyncio
     async def test_missing_metadata_headers_default_to_zero(self):
     async def test_missing_metadata_headers_default_to_zero(self):
         # The /slice endpoint always sets these on success, but be defensive
         # The /slice endpoint always sets these on success, but be defensive
@@ -262,7 +297,7 @@ class TestSliceWithProfiles:
             model_filename="Cube.stl",
             model_filename="Cube.stl",
             printer_profile_json="{}",
             printer_profile_json="{}",
             process_profile_json="{}",
             process_profile_json="{}",
-            filament_profile_json="{}",
+            filament_profile_jsons=["{}"],
         )
         )
         assert result.print_time_seconds == 0
         assert result.print_time_seconds == 0
         assert result.filament_used_g == 0.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
         # Validator only fills the ref when it's None — the explicit cloud
         # ref stays untouched.
         # ref stays untouched.
         assert req.printer_preset == PresetRef(source="cloud", id="PFU")
         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 io
+import json
 import math
 import math
 import zipfile
 import zipfile
 
 
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
     extract_filament_usage_from_3mf,
     extract_filament_usage_from_3mf,
+    extract_plate_extruder_set_from_3mf,
+    extract_project_filaments_from_3mf,
     get_cumulative_usage_at_layer,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
     parse_gcode_layer_filament_usage,
@@ -408,3 +411,237 @@ class TestExtractFilamentUsageFrom3mf:
         assert len(result) == 1
         assert len(result) == 1
         assert result[0]["type"] == ""
         assert result[0]["type"] == ""
         assert result[0]["color"] == ""
         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(),
     sliceLibraryFile: vi.fn(),
     sliceArchive: vi.fn(),
     sliceArchive: vi.fn(),
     getSliceJob: vi.fn(),
     getSliceJob: vi.fn(),
+    getLibraryFilePlates: vi.fn(),
+    getArchivePlates: vi.fn(),
+    getLibraryFileFilamentRequirements: vi.fn(),
+    getArchiveFilamentRequirements: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
   },
   },
@@ -32,6 +36,10 @@ const mockApi = api as unknown as {
   sliceLibraryFile: ReturnType<typeof vi.fn>;
   sliceLibraryFile: ReturnType<typeof vi.fn>;
   sliceArchive: ReturnType<typeof vi.fn>;
   sliceArchive: ReturnType<typeof vi.fn>;
   getSliceJob: 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 {
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -84,6 +92,33 @@ describe('SliceModal', () => {
       started_at: null,
       started_at: null,
       completed_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 () => {
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -92,44 +127,45 @@ describe('SliceModal', () => {
       onClose: vi.fn(),
       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(() => {
     await waitFor(() => {
       expect(screen.getByText('My Custom X1C')).toBeDefined();
       expect(screen.getByText('My Custom X1C')).toBeDefined();
     });
     });
     const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
     const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
     expect(selects).toHaveLength(3);
     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.
     // Slice button is enabled because all three slots auto-defaulted.
     const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
     const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
     expect((sliceBtn as HTMLButtonElement).disabled).toBe(false);
     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({
     renderWithTracker({
       source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
       source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
       onClose: vi.fn(),
       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 printerSelect = screen.getAllByRole('combobox')[0];
     const groups = printerSelect.querySelectorAll('optgroup');
     const groups = printerSelect.querySelectorAll('optgroup');
     expect(Array.from(groups).map((g) => g.label)).toEqual([
     expect(Array.from(groups).map((g) => g.label)).toEqual([
-      'Cloud',
       'Imported',
       'Imported',
+      'Cloud',
       'Standard',
       '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();
     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];
     const standardGroup = groups[2];
     expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined();
     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 user.click(screen.getByRole('button', { name: /^Slice$/ }));
 
 
     await waitFor(() => {
     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, {
       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());
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -324,4 +364,331 @@ describe('SliceModal', () => {
     // No status-role banner should be rendered on the happy path.
     // No status-role banner should be rendered on the happy path.
     expect(screen.queryByRole('status')).toBeNull();
     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;
   printer_preset?: PresetRef;
   process_preset?: PresetRef;
   process_preset?: PresetRef;
   filament_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;
   plate?: number;
   export_3mf?: boolean;
   export_3mf?: boolean;
 }
 }
@@ -1139,6 +1144,13 @@ export interface UnifiedPreset {
   id: string;
   id: string;
   name: string;
   name: string;
   source: PresetSource;
   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 {
 export interface UnifiedPresetsBySlot {
   printer: UnifiedPreset[];
   printer: UnifiedPreset[];

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

@@ -12,6 +12,9 @@ import {
   type UnifiedPresetsResponse,
   type UnifiedPresetsResponse,
 } from '../api/client';
 } from '../api/client';
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
+import { PlatePickerModal } from './PlatePickerModal';
+import type { PlateFilament } from '../types/plates';
+import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
 
 
 export type SliceSource =
 export type SliceSource =
   | { kind: 'libraryFile'; id: number; filename: string }
   | { kind: 'libraryFile'; id: number; filename: string }
@@ -24,10 +27,16 @@ interface SliceModalProps {
 
 
 type Slot = 'printer' | 'process' | 'filament';
 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 {
 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];
     const list = by[tier][slot];
     if (list.length > 0) {
     if (list.length > 0) {
       return { source: list[0].source, id: list[0].id };
       return { source: list[0].source, id: list[0].id };
@@ -36,6 +45,48 @@ function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
   return 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 {
 function toRefValue(ref: PresetRef | null): string {
   // The HTML `<select>` value space is flat strings; encode source + id so
   // The HTML `<select>` value space is flat strings; encode source + id so
   // the same preset name can live in multiple tiers without collision.
   // 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 [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
   const [processPreset, setProcessPreset] = 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);
   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({
   const presetsQuery = useQuery({
     queryKey: ['slicerPresets'],
     queryKey: ['slicerPresets'],
     queryFn: () => api.getSlicerPresets(),
     queryFn: () => api.getSlicerPresets(),
     staleTime: 60_000,
     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(() => {
   useEffect(() => {
     if (!presetsQuery.data) return;
     if (!presetsQuery.data) return;
     if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
     if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
     if (processPreset == null) setProcessPreset(pickDefault(presetsQuery.data, 'process'));
     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
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [presetsQuery.data]);
   }, [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({
   const enqueueMutation = useMutation({
     mutationFn: async () => {
     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 = {
       const body = {
         printer_preset: printerPreset,
         printer_preset: printerPreset,
         process_preset: processPreset,
         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') {
       if (source.kind === 'libraryFile') {
         return api.sliceLibraryFile(source.id, body);
         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;
   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 (
   return (
     <div
     <div
       className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
       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>
             </h3>
             <p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
             <p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
               {source.filename}
               {source.filename}
+              {selectedPlate != null
+                ? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
+                : ''}
             </p>
             </p>
           </div>
           </div>
           <button
           <button
@@ -141,7 +301,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
 
 
         {/* Body */}
         {/* Body */}
         <div className="flex-1 overflow-y-auto p-4 space-y-4">
         <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">
             <div className="flex items-center gap-2 text-bambu-gray text-sm">
               <Loader2 className="w-4 h-4 animate-spin" />
               <Loader2 className="w-4 h-4 animate-spin" />
               {t('slice.loadingPresets', 'Loading presets…')}
               {t('slice.loadingPresets', 'Loading presets…')}
@@ -176,14 +336,34 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 onChange={setProcessPreset}
                 onChange={setProcessPreset}
                 disabled={isEnqueuing}
                 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;
   value: PresetRef | null;
   onChange: (ref: PresetRef | null) => void;
   onChange: (ref: PresetRef | null) => void;
   disabled?: boolean;
   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 { t } = useTranslation();
 
 
   const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
   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 }[] = [
     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: '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' },
       { key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
     ];
     ];
     return tiers
     return tiers
@@ -294,7 +481,16 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled }: Preset
 
 
   return (
   return (
     <label className="block">
     <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
       <select
         value={toRefValue(value)}
         value={toRefValue(value)}
         onChange={(e) => onChange(fromRefValue(e.target.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',
     printer: 'Drucker-Profil',
     process: 'Prozess-Profil',
     process: 'Prozess-Profil',
     filament: 'Filament-Profil',
     filament: 'Filament-Profil',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Profil auswählen —',
     selectPreset: '— Profil auswählen —',
     loadingPresets: 'Profile werden geladen…',
     loadingPresets: 'Profile werden geladen…',
+    noPresetsForSlot: 'Keine Profile verfügbar',
     presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
     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…',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     queued: 'In Warteschlange…',
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
     startedToast: '{{name}} wird im Hintergrund gesliced…',
     startedToast: '{{name}} wird im Hintergrund gesliced…',
     completedToast: '{{name}} wurde gesliced',
     completedToast: '{{name}} wurde gesliced',
     failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
     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
   // Spoolman

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

@@ -3253,15 +3253,28 @@ export default {
     printer: 'Printer profile',
     printer: 'Printer profile',
     process: 'Process profile',
     process: 'Process profile',
     filament: 'Filament profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     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
   // Spoolman

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

@@ -3172,15 +3172,28 @@ export default {
     printer: 'Printer profile',
     printer: 'Printer profile',
     process: 'Process profile',
     process: 'Process profile',
     filament: 'Filament profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     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
   // Spoolman

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

@@ -3171,15 +3171,28 @@ export default {
     printer: 'Printer profile',
     printer: 'Printer profile',
     process: 'Process profile',
     process: 'Process profile',
     filament: 'Filament profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     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
   // Spoolman

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

@@ -3210,15 +3210,28 @@ export default {
     printer: 'Printer profile',
     printer: 'Printer profile',
     process: 'Process profile',
     process: 'Process profile',
     filament: 'Filament profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     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
   // Spoolman

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

@@ -3185,15 +3185,28 @@ export default {
     printer: 'Printer profile',
     printer: 'Printer profile',
     process: 'Process profile',
     process: 'Process profile',
     filament: 'Filament profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     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
   // Spoolman

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

@@ -3237,15 +3237,28 @@ export default {
     printer: 'Printer profile',
     printer: 'Printer profile',
     process: 'Process profile',
     process: 'Process profile',
     filament: 'Filament profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     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
   // Spoolman

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

@@ -3237,15 +3237,28 @@ export default {
     printer: 'Printer profile',
     printer: 'Printer profile',
     process: 'Process profile',
     process: 'Process profile',
     filament: 'Filament profile',
     filament: 'Filament profile',
+    filamentSlot: 'Filament {{index}} ({{type}})',
     selectPreset: '— Select a preset —',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     loadingPresets: 'Loading presets…',
+    noPresetsForSlot: 'No presets available',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
+    allPresetsRequired: 'All presets must be selected',
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     startedToast: 'Slicing {{name}} in the background…',
     startedToast: 'Slicing {{name}} in the background…',
     completedToast: 'Sliced {{name}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     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
   // 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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-Bbpbjxtl.css">
   </head>
   </head>
   <body>
   <body>

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