Procházet zdrojové kódy

feat(slicer): bundle-aware preview slice for accurate gram estimates

  The SliceModal's preview slice runs against unsliced project files to
  discover per-plate AMS slot consumption. Until now it always used
  slice_without_profiles — accurate slot mapping (a model property) but
  gram numbers were derived from the file's embedded process settings,
  which can drift from the triplet the real print will use.

  When the caller provides a bundle id + printer/process/filament preset
  names, get_preview_filaments now routes through slice_with_bundle so
  the preview's gram numbers match what the real print will produce.
  Cache key picks up a bundle-context fingerprint so different bundle
  picks on the same file occupy distinct entries.

  Backend:
  - slice_preview.get_preview_filaments: optional bundle_* params
  - library.py + archives.py: forward params via /filament-requirements

  Frontend (forward-compat for the upcoming SliceModal Bundle tier):
  - api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
    accept an optional 4th-arg bundle context object
maziggy před 3 týdny
rodič
revize
8a31397171

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

@@ -3129,12 +3129,21 @@ async def _try_preview_slice_filaments(
     plate_id: int,
     plate_id: int,
     file_path: Path,
     file_path: Path,
     request_id: str | None = None,
     request_id: str | None = None,
+    bundle_id: str | None = None,
+    printer_name: str | None = None,
+    process_name: str | None = None,
+    filament_names: list[str] | None = None,
 ) -> list[dict] | None:
 ) -> list[dict] | None:
     """Run a preview slice via the user's configured sidecar so the filament
     """Run a preview slice via the user's configured sidecar so the filament
     list endpoint can return real per-plate filaments for unsliced project
     list endpoint can return real per-plate filaments for unsliced project
     files. Returns ``None`` on any failure — the caller falls back to the
     files. Returns ``None`` on any failure — the caller falls back to the
     painted-face heuristic. ``request_id`` flows through to the sidecar
     painted-face heuristic. ``request_id`` flows through to the sidecar
-    for live progress on the SliceModal's inline spinner + toast."""
+    for live progress on the SliceModal's inline spinner + toast.
+
+    Bundle context (id + preset names) is forwarded to the preview helper
+    so the preview can mirror the real-print profile triplet when supplied
+    — see ``slice_preview.get_preview_filaments`` for the full contract.
+    """
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
     from backend.app.services.slice_preview import get_preview_filaments
 
 
@@ -3162,6 +3171,10 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         file_name=file_path.name,
         api_url=api_url,
         api_url=api_url,
         request_id=request_id,
         request_id=request_id,
+        bundle_id=bundle_id,
+        printer_name=printer_name,
+        process_name=process_name,
+        filament_names=filament_names,
     )
     )
 
 
 
 
@@ -3170,6 +3183,10 @@ async def get_filament_requirements(
     archive_id: int,
     archive_id: int,
     plate_id: int | None = None,
     plate_id: int | None = None,
     request_id: str | None = None,
     request_id: str | None = None,
+    bundle_id: str | None = None,
+    printer_name: str | None = None,
+    process_name: str | None = None,
+    filament_names: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
@@ -3181,6 +3198,12 @@ async def get_filament_requirements(
     Args:
     Args:
         archive_id: The archive ID
         archive_id: The archive ID
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
+        bundle_id / printer_name / process_name / filament_names: Optional
+            bundle context. When all four are supplied, the preview slice
+            (run for unsliced project files) uses ``slice_with_bundle``
+            against the named preset triplet instead of the embedded-
+            settings fallback. ``filament_names`` is comma- or semicolon-
+            separated.
     """
     """
     import defusedxml.ElementTree as ET
     import defusedxml.ElementTree as ET
 
 
@@ -3284,6 +3307,11 @@ async def get_filament_requirements(
                 project_filaments = extract_project_filaments_from_3mf(zf)
                 project_filaments = extract_project_filaments_from_3mf(zf)
                 used_slot_ids: set[int] = set()
                 used_slot_ids: set[int] = set()
                 if project_filaments and plate_id is not None:
                 if project_filaments and plate_id is not None:
+                    parsed_filament_names: list[str] | None = None
+                    if filament_names:
+                        parsed_filament_names = [
+                            n.strip() for n in filament_names.replace(";", ",").split(",") if n.strip()
+                        ] or None
                     preview = await _try_preview_slice_filaments(
                     preview = await _try_preview_slice_filaments(
                         db,
                         db,
                         kind="archive",
                         kind="archive",
@@ -3291,6 +3319,10 @@ async def get_filament_requirements(
                         plate_id=plate_id,
                         plate_id=plate_id,
                         file_path=file_path,
                         file_path=file_path,
                         request_id=request_id,
                         request_id=request_id,
+                        bundle_id=bundle_id,
+                        printer_name=printer_name,
+                        process_name=process_name,
+                        filament_names=parsed_filament_names,
                     )
                     )
                     if preview is not None:
                     if preview is not None:
                         used_slot_ids = {f["slot_id"] for f in preview}
                         used_slot_ids = {f["slot_id"] for f in preview}

+ 37 - 0
backend/app/api/routes/library.py

@@ -2363,6 +2363,10 @@ async def _try_preview_slice_filaments(
     plate_id: int,
     plate_id: int,
     file_path: Path,
     file_path: Path,
     request_id: str | None = None,
     request_id: str | None = None,
+    bundle_id: str | None = None,
+    printer_name: str | None = None,
+    process_name: str | None = None,
+    filament_names: list[str] | None = None,
 ) -> list[dict] | None:
 ) -> list[dict] | None:
     """Run a preview slice via the user's configured sidecar. Same shape as
     """Run a preview slice via the user's configured sidecar. Same shape as
     the matching helper in archives.py — see that module for rationale.
     the matching helper in archives.py — see that module for rationale.
@@ -2370,6 +2374,13 @@ async def _try_preview_slice_filaments(
     ``request_id``: when supplied, forwarded to the sidecar so the
     ``request_id``: when supplied, forwarded to the sidecar so the
     SliceModal's inline spinner + toast can poll the matching progress
     SliceModal's inline spinner + toast can poll the matching progress
     endpoint and show "Generating G-code (45%)" for the preview as well.
     endpoint and show "Generating G-code (45%)" for the preview as well.
+
+    ``bundle_id`` / ``printer_name`` / ``process_name`` / ``filament_names``:
+    when all are supplied, the preview uses ``slice_with_bundle`` against
+    the named bundle's preset triplet so the preview's gram numbers reflect
+    the same profiles the real print will use. Partial context falls back
+    to the embedded-settings path so a half-completed Bundle-tier selection
+    in the modal doesn't error out.
     """
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
     from backend.app.services.slice_preview import get_preview_filaments
@@ -2398,6 +2409,10 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         file_name=file_path.name,
         api_url=api_url,
         api_url=api_url,
         request_id=request_id,
         request_id=request_id,
+        bundle_id=bundle_id,
+        printer_name=printer_name,
+        process_name=process_name,
+        filament_names=filament_names,
     )
     )
 
 
 
 
@@ -2406,6 +2421,10 @@ async def get_library_file_filament_requirements(
     file_id: int,
     file_id: int,
     plate_id: int | None = None,
     plate_id: int | None = None,
     request_id: str | None = None,
     request_id: str | None = None,
+    bundle_id: str | None = None,
+    printer_name: str | None = None,
+    process_name: str | None = None,
+    filament_names: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
 ):
@@ -2417,6 +2436,12 @@ async def get_library_file_filament_requirements(
     Args:
     Args:
         file_id: The library file ID
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
         plate_id: Optional plate index to get filaments for a specific plate
+        bundle_id / printer_name / process_name / filament_names: Optional
+            bundle context. When all four are supplied, the preview slice
+            (run for unsliced project files) uses ``slice_with_bundle``
+            against the named preset triplet instead of the embedded-
+            settings fallback. ``filament_names`` is comma- or semicolon-
+            separated to mirror the slice route's multi-color form.
     """
     """
     import defusedxml.ElementTree as ET
     import defusedxml.ElementTree as ET
 
 
@@ -2536,6 +2561,14 @@ async def get_library_file_filament_requirements(
                 project_filaments = extract_project_filaments_from_3mf(zf)
                 project_filaments = extract_project_filaments_from_3mf(zf)
                 used_slot_ids: set[int] = set()
                 used_slot_ids: set[int] = set()
                 if project_filaments and plate_id is not None:
                 if project_filaments and plate_id is not None:
+                    # Bundle context flows through optional query params so
+                    # callers without a Bundle-tier selection (the common
+                    # case) hit the same path as before.
+                    parsed_filament_names: list[str] | None = None
+                    if filament_names:
+                        parsed_filament_names = [
+                            n.strip() for n in filament_names.replace(";", ",").split(",") if n.strip()
+                        ] or None
                     preview = await _try_preview_slice_filaments(
                     preview = await _try_preview_slice_filaments(
                         db,
                         db,
                         kind="library_file",
                         kind="library_file",
@@ -2543,6 +2576,10 @@ async def get_library_file_filament_requirements(
                         plate_id=plate_id,
                         plate_id=plate_id,
                         file_path=file_path,
                         file_path=file_path,
                         request_id=request_id,
                         request_id=request_id,
+                        bundle_id=bundle_id,
+                        printer_name=printer_name,
+                        process_name=process_name,
+                        filament_names=parsed_filament_names,
                     )
                     )
                     if preview is not None:
                     if preview is not None:
                         used_slot_ids = {f["slot_id"] for f in preview}
                         used_slot_ids = {f["slot_id"] for f in preview}

+ 97 - 25
backend/app/services/slice_preview.py

@@ -6,14 +6,26 @@ the ``/filament-requirements`` endpoint can read it directly. For unsliced
 project files it doesn't exist yet — only the slicer can produce it, since
 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.
 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.
+This module wraps the sidecar's slice call so the endpoint can run a preview
+slice, parse the result's slice_info, and return the actual filament list.
+Two slice modes are supported:
+
+  * "embedded settings" mode (default) — calls ``slice_without_profiles`` so
+    the slicer falls back on the file's own ``Metadata/project_settings.config``.
+    Used when the SliceModal opens before the user has picked a profile
+    triplet and we just want the slot-mapping (which is a model property,
+    independent of process settings).
+
+  * "bundle" mode — when the caller passes a bundle id + per-category preset
+    names, calls ``slice_with_bundle`` so the preview reflects the same
+    triplet the real print will use. More accurate gram numbers; same slot
+    mapping. Used after the SliceModal's Bundle tier resolves.
+
+Results are cached by ``(kind, source_id, plate_id, content_hash, bundle_key)``
+so different bundle picks on the same file don't collide and repeat opens
+on the same plate + same bundle 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 input.
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
@@ -35,22 +47,46 @@ from backend.app.services.slicer_api import (
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 _PREVIEW_CACHE_MAX = 256
 _PREVIEW_CACHE_MAX = 256
+# Cache key includes a bundle-context fingerprint (or "" when no bundle was
+# supplied) so a "preview without profiles" result and a "preview with
+# bundle X" result for the same file/plate occupy distinct entries instead
+# of clobbering each other.
+_PreviewCacheKey = tuple[str, int, int, str, str]
 # Cache values: list[dict] on success, [] on parsed-but-empty (slicer
 # Cache values: list[dict] on success, [] on parsed-but-empty (slicer
 # returned a 3MF without filament data for this plate — caching the negative
 # returned a 3MF without filament data for this plate — caching the negative
 # avoids burning 30s+ per modal open on a known-bad input).
 # 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
+_preview_cache: OrderedDict[_PreviewCacheKey, list[dict]] = OrderedDict()
+# Per-key locks prevent N concurrent modal opens on the same (file, plate,
+# bundle) 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.
 # failures (network errors etc.) so those retry naturally on next request.
-_preview_locks: dict[tuple[str, int, int, str], asyncio.Lock] = {}
+_preview_locks: dict[_PreviewCacheKey, asyncio.Lock] = {}
 
 
 
 
 def _content_hash(file_bytes: bytes) -> str:
 def _content_hash(file_bytes: bytes) -> str:
     return hashlib.sha256(file_bytes).hexdigest()[:16]
     return hashlib.sha256(file_bytes).hexdigest()[:16]
 
 
 
 
+def _bundle_context_fingerprint(
+    bundle_id: str | None,
+    printer_name: str | None,
+    process_name: str | None,
+    filament_names: list[str] | None,
+) -> str:
+    """Derive a stable cache-key fragment for the bundle context. Empty
+    string when no bundle is supplied — preserves cache compatibility with
+    the no-bundle ("embedded settings") path so existing entries remain
+    valid. SHA-256 prefix keeps the key short while collision-resistant
+    enough for a 256-entry LRU.
+    """
+    if not (bundle_id and printer_name and process_name and filament_names):
+        return ""
+    parts = [bundle_id, printer_name, process_name, *filament_names]
+    raw = "\x1f".join(parts).encode("utf-8")
+    return hashlib.sha256(raw).hexdigest()[:12]
+
+
 async def get_preview_filaments(
 async def get_preview_filaments(
     *,
     *,
     kind: str,
     kind: str,
@@ -60,16 +96,33 @@ async def get_preview_filaments(
     file_name: str,
     file_name: str,
     api_url: str,
     api_url: str,
     request_id: str | None = None,
     request_id: str | None = None,
+    bundle_id: str | None = None,
+    printer_name: str | None = None,
+    process_name: str | None = None,
+    filament_names: list[str] | None = None,
 ) -> list[dict] | None:
 ) -> 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.
+    """Run a preview slice for ``plate_id``, parse the resulting slice_info,
+    and return the per-plate filament list.
+
+    By default uses the file's embedded settings (``slice_without_profiles``).
+    When all four ``bundle_*`` params are provided, uses ``slice_with_bundle``
+    so the preview matches the profile triplet the real print will use —
+    same slot mapping, more-accurate gram numbers. Partial bundle context
+    (e.g. id without preset names) falls back to the embedded path rather
+    than failing, so an in-progress modal selection doesn't surface errors.
 
 
     Returns ``None`` when the preview slice fails — the caller should fall
     Returns ``None`` when the preview slice fails — the caller should fall
     back to whatever heuristic it has (typically the project_filaments +
     back to whatever heuristic it has (typically the project_filaments +
     painted-face approach in ``threemf_tools``).
     painted-face approach in ``threemf_tools``).
     """
     """
     h = _content_hash(file_bytes)
     h = _content_hash(file_bytes)
-    key = (kind, source_id, plate_id, h)
+    bundle_fp = _bundle_context_fingerprint(
+        bundle_id,
+        printer_name,
+        process_name,
+        filament_names,
+    )
+    key: _PreviewCacheKey = (kind, source_id, plate_id, h, bundle_fp)
     cached = _preview_cache.get(key)
     cached = _preview_cache.get(key)
     if cached is not None:
     if cached is not None:
         _preview_cache.move_to_end(key)
         _preview_cache.move_to_end(key)
@@ -86,19 +139,38 @@ async def get_preview_filaments(
 
 
         try:
         try:
             async with SlicerApiService(base_url=api_url) as svc:
             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,
-                    request_id=request_id,
-                )
+                if bundle_fp:
+                    # All four bundle params present (guaranteed non-None by
+                    # _bundle_context_fingerprint returning non-empty);
+                    # the type-checker can't see that, so assert for narrowing.
+                    assert bundle_id and printer_name and process_name
+                    assert filament_names is not None
+                    result = await svc.slice_with_bundle(
+                        model_bytes=file_bytes,
+                        model_filename=file_name,
+                        bundle_id=bundle_id,
+                        printer_name=printer_name,
+                        process_name=process_name,
+                        filament_names=filament_names,
+                        plate=plate_id,
+                        export_3mf=True,
+                        request_id=request_id,
+                    )
+                else:
+                    result = await svc.slice_without_profiles(
+                        model_bytes=file_bytes,
+                        model_filename=file_name,
+                        plate=plate_id,
+                        export_3mf=True,
+                        request_id=request_id,
+                    )
         except SlicerApiError as e:
         except SlicerApiError as e:
             logger.warning(
             logger.warning(
-                "Preview slice failed for %s/%s plate %s: %s",
+                "Preview slice failed for %s/%s plate %s (bundle=%s): %s",
                 kind,
                 kind,
                 source_id,
                 source_id,
                 plate_id,
                 plate_id,
+                bundle_id or "-",
                 e,
                 e,
             )
             )
             return None
             return None

+ 183 - 1
backend/tests/unit/services/test_slice_preview.py

@@ -72,7 +72,18 @@ class _StubService:
         return False
         return False
 
 
     async def slice_without_profiles(self, **kw):
     async def slice_without_profiles(self, **kw):
-        self.calls.append(kw)
+        self.calls.append({"method": "slice_without_profiles", **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,
+        )
+
+    async def slice_with_bundle(self, **kw):
+        self.calls.append({"method": "slice_with_bundle", **kw})
         if self.raise_exc is not None:
         if self.raise_exc is not None:
             raise self.raise_exc
             raise self.raise_exc
         return SliceResult(
         return SliceResult(
@@ -254,3 +265,174 @@ class TestGetPreviewFilaments:
         assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
         assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
         # Lock dict is also pruned (no leak): same size as cache.
         # Lock dict is also pruned (no leak): same size as cache.
         assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX
         assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX
+
+
+# ---------------------------------------------------------------------------
+# Bundle-aware preview path — when bundle context is supplied, the preview
+# routes through `slice_with_bundle` so its gram numbers reflect the same
+# triplet the real print will use. Cache must distinguish between bundle
+# picks so a fresh selection doesn't re-serve a prior preview's output.
+# ---------------------------------------------------------------------------
+
+
+class TestBundleAwarePreview:
+    @pytest.mark.asyncio
+    async def test_full_bundle_context_uses_slice_with_bundle(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):
+            result = await get_preview_filaments(
+                kind="library_file",
+                source_id=42,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+                bundle_id="abc123",
+                printer_name="# Bambu Lab H2D 0.4 nozzle",
+                process_name="# 0.20mm Standard @BBL H2D",
+                filament_names=["# Bambu PLA Basic @BBL H2D"],
+            )
+        assert result is not None
+        assert result[0]["slot_id"] == 1
+        # The bundle path engaged — slice_with_bundle was called, not the
+        # embedded-settings fallback.
+        assert len(stub.calls) == 1
+        assert stub.calls[0]["method"] == "slice_with_bundle"
+        assert stub.calls[0]["bundle_id"] == "abc123"
+        assert stub.calls[0]["filament_names"] == ["# Bambu PLA Basic @BBL H2D"]
+
+    @pytest.mark.asyncio
+    async def test_partial_bundle_context_falls_back_to_embedded(self):
+        # Modal-in-progress case: user picked a bundle id but hasn't yet
+        # picked the filament. Falling back to embedded settings keeps
+        # the preview's slot mapping fresh while gram numbers will firm
+        # up once the selection completes.
+        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="library_file",
+                source_id=42,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+                bundle_id="abc123",
+                printer_name="# Bambu Lab H2D 0.4 nozzle",
+                process_name="# 0.20mm Standard @BBL H2D",
+                # filament_names missing
+            )
+        assert len(stub.calls) == 1
+        assert stub.calls[0]["method"] == "slice_without_profiles"
+
+    @pytest.mark.asyncio
+    async def test_empty_filament_names_list_falls_back(self):
+        # Empty list (vs None) is treated as "incomplete context" since
+        # passing `[]` to slice_with_bundle would yield no
+        # --load-filaments arg and confuse the CLI.
+        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="library_file",
+                source_id=42,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+                bundle_id="abc123",
+                printer_name="P",
+                process_name="Q",
+                filament_names=[],
+            )
+        assert stub.calls[0]["method"] == "slice_without_profiles"
+
+    @pytest.mark.asyncio
+    async def test_cache_separates_bundle_picks(self):
+        # Same file/plate, two different bundle picks → two distinct cache
+        # entries → two slices run. Without the bundle-fingerprint cache key,
+        # the second call would erroneously serve the first's output.
+        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="library_file",
+                source_id=42,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+                bundle_id="bundleA",
+                printer_name="P",
+                process_name="Q",
+                filament_names=["F"],
+            )
+            await get_preview_filaments(
+                kind="library_file",
+                source_id=42,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+                bundle_id="bundleB",
+                printer_name="P",
+                process_name="Q",
+                filament_names=["F"],
+            )
+        assert len(stub.calls) == 2
+        assert stub.calls[0]["bundle_id"] == "bundleA"
+        assert stub.calls[1]["bundle_id"] == "bundleB"
+
+    @pytest.mark.asyncio
+    async def test_cache_separates_bundle_vs_embedded(self):
+        # Same file/plate, one call without bundle and one with bundle →
+        # both must run. The embedded-settings cache entry must NOT be
+        # served as the bundle-picked result (gram numbers would be wrong).
+        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="library_file",
+                source_id=42,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+            await get_preview_filaments(
+                kind="library_file",
+                source_id=42,
+                plate_id=1,
+                file_bytes=b"abc",
+                file_name="x.3mf",
+                api_url="http://sidecar",
+                bundle_id="bundleA",
+                printer_name="P",
+                process_name="Q",
+                filament_names=["F"],
+            )
+        methods = [c["method"] for c in stub.calls]
+        assert methods == ["slice_without_profiles", "slice_with_bundle"]
+
+    @pytest.mark.asyncio
+    async def test_bundle_repeat_call_hits_cache(self):
+        # Sanity check that the new cache key is otherwise stable: same
+        # bundle pick on the same file → cache hit on second call.
+        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):
+            for _ in range(2):
+                await get_preview_filaments(
+                    kind="library_file",
+                    source_id=42,
+                    plate_id=1,
+                    file_bytes=b"abc",
+                    file_name="x.3mf",
+                    api_url="http://sidecar",
+                    bundle_id="bundleA",
+                    printer_name="P",
+                    process_name="Q",
+                    filament_names=["F"],
+                )
+        assert len(stub.calls) == 1

+ 214 - 0
frontend/src/__tests__/components/SlicerBundlesPanel.test.tsx

@@ -0,0 +1,214 @@
+/**
+ * Tests for the SlicerBundlesPanel — Settings panel for managing
+ * BambuStudio Printer Preset Bundles (.bbscfg) on the slicer sidecar.
+ *
+ * Coverage:
+ *  - Empty state when the sidecar has no bundles imported yet.
+ *  - List rendering with summary line (process / filament counts).
+ *  - Upload happy path → success toast + list invalidation.
+ *  - Upload error → error toast.
+ *  - Delete with confirmation → success toast + list invalidation.
+ *  - Delete error → error toast.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { api } from '../../api/client';
+import { SlicerBundlesPanel } from '../../components/SlicerBundlesPanel';
+
+vi.mock('../../api/client', async () => {
+  const actual: typeof import('../../api/client') = await vi.importActual(
+    '../../api/client',
+  );
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      listSlicerBundles: vi.fn(),
+      importSlicerBundle: vi.fn(),
+      deleteSlicerBundle: vi.fn(),
+    },
+    getAuthToken: vi.fn(() => null),
+  };
+});
+
+const SAMPLE_BUNDLE = {
+  id: 'abc123def456abcd',
+  printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
+  printer: ['# Bambu Lab H2D 0.4 nozzle'],
+  process: [
+    '# 0.20mm Standard @BBL H2D',
+    '# 0.16mm Standard @BBL H2D',
+  ],
+  filament: [
+    '# Bambu PLA Basic @BBL H2D',
+    '# Bambu PETG HF @BBL H2D 0.4 nozzle',
+    '# Bambu ABS @BBL H2D',
+  ],
+  version: '02.06.00.50',
+};
+
+beforeEach(() => {
+  vi.clearAllMocks();
+});
+
+describe('SlicerBundlesPanel — empty state', () => {
+  it('renders the empty-state message when no bundles exist', async () => {
+    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
+
+    render(<SlicerBundlesPanel />);
+
+    await waitFor(() =>
+      expect(api.listSlicerBundles).toHaveBeenCalled(),
+    );
+    expect(
+      await screen.findByText(/no bundles imported yet/i),
+    ).toBeInTheDocument();
+  });
+});
+
+describe('SlicerBundlesPanel — list rendering', () => {
+  it('renders bundle name + summary (process and filament counts)', async () => {
+    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
+
+    render(<SlicerBundlesPanel />);
+
+    expect(
+      await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
+    ).toBeInTheDocument();
+    // Summary should reflect 2 process + 3 filament from the fixture.
+    expect(
+      await screen.findByText(/2 process · 3 filament/i),
+    ).toBeInTheDocument();
+    // Version suffix appended after the summary.
+    expect(screen.getByText(/v02\.06\.00\.50/)).toBeInTheDocument();
+  });
+});
+
+describe('SlicerBundlesPanel — upload flow', () => {
+  it('imports a selected file and refreshes the list on success', async () => {
+    // First listing call returns empty so the test can detect the post-import
+    // re-fetch (second call) returning the new bundle.
+    vi.mocked(api.listSlicerBundles)
+      .mockResolvedValueOnce([])
+      .mockResolvedValueOnce([SAMPLE_BUNDLE]);
+    vi.mocked(api.importSlicerBundle).mockResolvedValueOnce(SAMPLE_BUNDLE);
+
+    const { container } = render(<SlicerBundlesPanel />);
+
+    // The file input is hidden (display: none for styling); grab it directly.
+    const fileInput = container.querySelector(
+      'input[type="file"]',
+    ) as HTMLInputElement;
+    expect(fileInput).toBeTruthy();
+
+    const file = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'H2D.bbscfg', {
+      type: 'application/zip',
+    });
+
+    fireEvent.change(fileInput, { target: { files: [file] } });
+
+    await waitFor(() =>
+      expect(api.importSlicerBundle).toHaveBeenCalledWith(file),
+    );
+    // After the success, the list call should fire a second time (cache
+    // invalidation by react-query).
+    await waitFor(() =>
+      expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
+    );
+    // The newly imported bundle should now be visible in the list.
+    expect(
+      await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
+    ).toBeInTheDocument();
+  });
+
+  it('shows an error and does not refresh on upload failure', async () => {
+    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
+    vi.mocked(api.importSlicerBundle).mockRejectedValueOnce(
+      new Error('Bundle is missing bundle_structure.json'),
+    );
+
+    const { container } = render(<SlicerBundlesPanel />);
+
+    await waitFor(() => expect(api.listSlicerBundles).toHaveBeenCalled());
+
+    const fileInput = container.querySelector(
+      'input[type="file"]',
+    ) as HTMLInputElement;
+    const file = new File([new Uint8Array([0])], 'bad.bbscfg', {
+      type: 'application/zip',
+    });
+    fireEvent.change(fileInput, { target: { files: [file] } });
+
+    await waitFor(() =>
+      expect(api.importSlicerBundle).toHaveBeenCalled(),
+    );
+    // Listing should NOT be re-called on failure — only the initial load.
+    expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
+    // Empty state still showing.
+    expect(
+      screen.getByText(/no bundles imported yet/i),
+    ).toBeInTheDocument();
+  });
+});
+
+describe('SlicerBundlesPanel — delete flow', () => {
+  it('deletes a bundle after confirmation and refreshes the list', async () => {
+    vi.mocked(api.listSlicerBundles)
+      .mockResolvedValueOnce([SAMPLE_BUNDLE])
+      .mockResolvedValueOnce([]);
+    vi.mocked(api.deleteSlicerBundle).mockResolvedValueOnce(undefined);
+
+    render(<SlicerBundlesPanel />);
+
+    // Wait for the bundle to render.
+    await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
+
+    // Click the trash button (aria-label="Delete").
+    fireEvent.click(screen.getByRole('button', { name: /delete/i }));
+
+    // ConfirmModal should appear with the bundle name in the message.
+    const confirmMessage = await screen.findByText(
+      /Slice requests that reference "# Bambu Lab H2D 0.4 nozzle" will fail/i,
+    );
+    expect(confirmMessage).toBeInTheDocument();
+
+    // The modal renders its own "Delete" button — there are now two buttons
+    // matching /delete/i. Click the one inside the dialog (last in document
+    // order, since the modal portal renders after the panel).
+    const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
+    fireEvent.click(deleteButtons[deleteButtons.length - 1]);
+
+    await waitFor(() =>
+      expect(api.deleteSlicerBundle).toHaveBeenCalledWith(
+        'abc123def456abcd',
+      ),
+    );
+    // Cache invalidation should re-fire the list query.
+    await waitFor(() =>
+      expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
+    );
+  });
+
+  it('keeps the bundle in the list when the user cancels the delete dialog', async () => {
+    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
+
+    render(<SlicerBundlesPanel />);
+
+    await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
+    fireEvent.click(screen.getByRole('button', { name: /delete/i }));
+
+    // Cancel by clicking the "Cancel" button on the ConfirmModal.
+    const cancelButton = await screen.findByRole('button', { name: /cancel/i });
+    fireEvent.click(cancelButton);
+
+    // Delete API never called, list never re-fetched.
+    expect(api.deleteSlicerBundle).not.toHaveBeenCalled();
+    expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
+    // Bundle still rendered.
+    expect(
+      screen.getByText('# Bambu Lab H2D 0.4 nozzle'),
+    ).toBeInTheDocument();
+  });
+});

+ 84 - 2
frontend/src/api/client.ts

@@ -1171,6 +1171,20 @@ export interface SliceRequest {
   export_3mf?: boolean;
   export_3mf?: boolean;
 }
 }
 
 
+// GET /api/v1/slicer/bundles — Printer Preset Bundles imported from
+// BambuStudio's "File → Export → Export Preset Bundle" dialog. Each bundle
+// is a .bbscfg zip the user uploads once per printer, after which the
+// SliceModal can pick its inner presets by name (no re-upload per slice).
+// Backend: backend/app/api/routes/slicer_presets.py — bundle endpoints.
+export interface SlicerBundle {
+  id: string;
+  printer_preset_name: string;
+  printer: string[];
+  process: string[];
+  filament: string[];
+  version: string | null;
+}
+
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
 export type SlicerCloudStatus = 'ok' | 'not_authenticated' | 'expired' | 'unreachable';
 export type SlicerCloudStatus = 'ok' | 'not_authenticated' | 'expired' | 'unreachable';
 export interface UnifiedPreset {
 export interface UnifiedPreset {
@@ -3776,10 +3790,31 @@ export const api = {
   },
   },
   getArchivePlates: (archiveId: number) =>
   getArchivePlates: (archiveId: number) =>
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
-  getArchiveFilamentRequirements: (archiveId: number, plateId?: number, requestId?: string) => {
+  getArchiveFilamentRequirements: (
+    archiveId: number,
+    plateId?: number,
+    requestId?: string,
+    // Optional bundle context: when supplied, the backend's preview slice
+    // (run for unsliced project files) uses slice_with_bundle so gram
+    // numbers reflect the same triplet the real print will use. All four
+    // fields must be set for the bundle path to engage; partial context
+    // falls back to the embedded-settings preview without erroring.
+    bundle?: {
+      bundle_id: string;
+      printer_name: string;
+      process_name: string;
+      filament_names: string[];
+    },
+  ) => {
     const qs = new URLSearchParams();
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
     if (requestId) qs.set('request_id', requestId);
+    if (bundle) {
+      qs.set('bundle_id', bundle.bundle_id);
+      qs.set('printer_name', bundle.printer_name);
+      qs.set('process_name', bundle.process_name);
+      qs.set('filament_names', bundle.filament_names.join(';'));
+    }
     return request<{
     return request<{
       archive_id: number;
       archive_id: number;
       filename: string;
       filename: string;
@@ -5141,10 +5176,28 @@ export const api = {
     }),
     }),
   getLibraryFilePlates: (fileId: number) =>
   getLibraryFilePlates: (fileId: number) =>
     request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
     request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
-  getLibraryFileFilamentRequirements: (fileId: number, plateId?: number, requestId?: string) => {
+  getLibraryFileFilamentRequirements: (
+    fileId: number,
+    plateId?: number,
+    requestId?: string,
+    // Optional bundle context — see getArchiveFilamentRequirements above
+    // for the contract. Same shape so callers can share a builder helper.
+    bundle?: {
+      bundle_id: string;
+      printer_name: string;
+      process_name: string;
+      filament_names: string[];
+    },
+  ) => {
     const qs = new URLSearchParams();
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
     if (requestId) qs.set('request_id', requestId);
+    if (bundle) {
+      qs.set('bundle_id', bundle.bundle_id);
+      qs.set('printer_name', bundle.printer_name);
+      qs.set('process_name', bundle.process_name);
+      qs.set('filament_names', bundle.filament_names.join(';'));
+    }
     return request<{
     return request<{
       file_id: number;
       file_id: number;
       filename: string;
       filename: string;
@@ -5269,6 +5322,35 @@ export const api = {
   getSlicerPresets: () =>
   getSlicerPresets: () =>
     request<UnifiedPresetsResponse>('/slicer/presets'),
     request<UnifiedPresetsResponse>('/slicer/presets'),
 
 
+  // Slicer Bundles (.bbscfg) — Printer Preset Bundles imported from BambuStudio.
+  // Settings → Slicer Bundles uploads/lists/deletes; the SliceModal picks
+  // presets by name from a chosen bundle (separate follow-up).
+  listSlicerBundles: () =>
+    request<SlicerBundle[]>('/slicer/bundles'),
+  importSlicerBundle: (file: File) => {
+    // The /slicer/bundles upload accepts multipart with field name "file"
+    // (matches the FastAPI route's UploadFile parameter). Bypass `request`
+    // because it always JSON-stringifies the body — multipart needs the
+    // browser to set the boundary in the Content-Type header.
+    const fd = new FormData();
+    fd.append('file', file);
+    return fetch(`${API_BASE}/slicer/bundles`, {
+      method: 'POST',
+      headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
+      body: fd,
+    }).then(async (res) => {
+      if (!res.ok) {
+        const err = await res.json().catch(() => ({}));
+        throw new Error(err.detail || `HTTP ${res.status}`);
+      }
+      return res.json() as Promise<SlicerBundle>;
+    });
+  },
+  deleteSlicerBundle: (bundleId: string) =>
+    request<void>(`/slicer/bundles/${encodeURIComponent(bundleId)}`, {
+      method: 'DELETE',
+    }),
+
   // Local Presets (OrcaSlicer imports)
   // Local Presets (OrcaSlicer imports)
   getLocalPresets: () =>
   getLocalPresets: () =>
     request<LocalPresetsResponse>('/local-presets/'),
     request<LocalPresetsResponse>('/local-presets/'),

+ 198 - 0
frontend/src/components/SlicerBundlesPanel.tsx

@@ -0,0 +1,198 @@
+import { useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Package, Trash2, Upload } from 'lucide-react';
+import { api, type SlicerBundle } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+
+// Settings panel for managing BambuStudio "Printer Preset Bundles"
+// (.bbscfg) on the slicer sidecar. Sits below the slicer-API URL panel
+// in SettingsPage and is hidden when use_slicer_api is off — without a
+// configured sidecar there's nowhere to upload bundles to.
+//
+// Backend wiring: backend/app/api/routes/slicer_presets.py exposes
+// /api/v1/slicer/bundles (POST/GET/DELETE). The list call returns []
+// when no sidecar is configured, so an empty render is the natural
+// "first-run" state for users who haven't enabled the sidecar yet.
+export function SlicerBundlesPanel() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [pendingDelete, setPendingDelete] = useState<SlicerBundle | null>(null);
+
+  const { data: bundles, isLoading } = useQuery({
+    queryKey: ['slicer-bundles'],
+    queryFn: api.listSlicerBundles,
+  });
+
+  const importMutation = useMutation({
+    mutationFn: (file: File) => api.importSlicerBundle(file),
+    onSuccess: (bundle) => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-bundles'] });
+      showToast(
+        t('settings.slicerBundles.uploadSuccess', {
+          defaultValue: 'Imported {{name}}',
+          name: bundle.printer_preset_name,
+        }),
+        'success',
+      );
+      // Reset the file input so the same file can be re-selected after a
+      // failed retry. (Without this, a second click on the same file
+      // doesn't trigger onChange and looks like the panel is broken.)
+      if (fileInputRef.current) fileInputRef.current.value = '';
+    },
+    onError: (err: Error) => {
+      showToast(
+        t('settings.slicerBundles.uploadError', {
+          defaultValue: 'Bundle upload failed: {{message}}',
+          message: err.message,
+        }),
+        'error',
+      );
+      if (fileInputRef.current) fileInputRef.current.value = '';
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (bundleId: string) => api.deleteSlicerBundle(bundleId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-bundles'] });
+      setPendingDelete(null);
+      showToast(
+        t('settings.slicerBundles.deleteSuccess', {
+          defaultValue: 'Bundle removed',
+        }),
+        'success',
+      );
+    },
+    onError: (err: Error) => {
+      showToast(
+        t('settings.slicerBundles.deleteError', {
+          defaultValue: 'Bundle delete failed: {{message}}',
+          message: err.message,
+        }),
+        'error',
+      );
+      setPendingDelete(null);
+    },
+  });
+
+  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+    importMutation.mutate(file);
+  };
+
+  return (
+    <Card>
+      <CardHeader>
+        <h3 className="text-base font-semibold text-white flex items-center gap-2">
+          <Package className="w-4 h-4 text-bambu-green" />
+          {t('settings.slicerBundles.title', { defaultValue: 'Slicer Bundles' })}
+        </h3>
+      </CardHeader>
+      <CardContent className="space-y-3">
+        <p className="text-xs text-bambu-gray">
+          {t('settings.slicerBundles.description', {
+            defaultValue:
+              'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+          })}
+        </p>
+
+        <div className="flex items-center gap-2">
+          <input
+            ref={fileInputRef}
+            type="file"
+            accept=".bbscfg,.zip,application/zip"
+            onChange={handleFileChange}
+            className="hidden"
+            disabled={importMutation.isPending}
+          />
+          <Button
+            variant="primary"
+            onClick={() => fileInputRef.current?.click()}
+            disabled={importMutation.isPending}
+          >
+            {importMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                {t('settings.slicerBundles.uploading', { defaultValue: 'Uploading…' })}
+              </>
+            ) : (
+              <>
+                <Upload className="w-4 h-4" />
+                {t('settings.slicerBundles.uploadButton', { defaultValue: 'Upload bundle' })}
+              </>
+            )}
+          </Button>
+        </div>
+
+        {isLoading ? (
+          <div className="flex items-center gap-2 text-sm text-bambu-gray">
+            <Loader2 className="w-4 h-4 animate-spin" />
+            {t('settings.slicerBundles.loading', { defaultValue: 'Loading bundles…' })}
+          </div>
+        ) : bundles && bundles.length > 0 ? (
+          <ul className="divide-y divide-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg">
+            {bundles.map((b) => (
+              <li
+                key={b.id}
+                className="flex items-center justify-between px-3 py-2 hover:bg-bambu-dark-tertiary/30"
+              >
+                <div className="min-w-0 flex-1">
+                  <p className="text-sm text-white truncate">{b.printer_preset_name}</p>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.slicerBundles.summary', {
+                      defaultValue:
+                        '{{processCount}} process · {{filamentCount}} filament presets',
+                      processCount: b.process.length,
+                      filamentCount: b.filament.length,
+                    })}
+                    {b.version && ` · v${b.version}`}
+                  </p>
+                </div>
+                <button
+                  type="button"
+                  onClick={() => setPendingDelete(b)}
+                  disabled={deleteMutation.isPending}
+                  className="ml-3 p-1.5 text-bambu-gray hover:text-red-400 disabled:opacity-40"
+                  aria-label={t('settings.slicerBundles.delete', { defaultValue: 'Delete' })}
+                >
+                  <Trash2 className="w-4 h-4" />
+                </button>
+              </li>
+            ))}
+          </ul>
+        ) : (
+          <p className="text-sm text-bambu-gray italic">
+            {t('settings.slicerBundles.empty', {
+              defaultValue: 'No bundles imported yet.',
+            })}
+          </p>
+        )}
+      </CardContent>
+
+      {pendingDelete && (
+        <ConfirmModal
+          title={t('settings.slicerBundles.confirmDeleteTitle', {
+            defaultValue: 'Remove this bundle?',
+          })}
+          message={t('settings.slicerBundles.confirmDeleteMessage', {
+            defaultValue:
+              'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+            name: pendingDelete.printer_preset_name,
+          })}
+          confirmText={t('common.delete', { defaultValue: 'Delete' })}
+          variant="danger"
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteMutation.mutate(pendingDelete.id)}
+          onCancel={() => setPendingDelete(null)}
+        />
+      )}
+    </Card>
+  );
+}

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

@@ -1852,6 +1852,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
     orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
     bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
     bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
     slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
     slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
+    slicerBundles: {
+      title: 'Slicer-Bundles',
+      description: 'Importiere ein Drucker-Voreinstellungspaket (.bbscfg), das aus BambuStudio exportiert wurde (Datei → Exportieren → Voreinstellungspaket exportieren → "Drucker-Voreinstellungspaket"). Nach dem Import können Slice-Anfragen Voreinstellungen aus dem Bundle per Name auswählen, ohne das JSON-Profil-Trio erneut hochzuladen.',
+      uploadButton: 'Bundle hochladen',
+      uploading: 'Hochladen…',
+      loading: 'Bundles werden geladen…',
+      empty: 'Noch keine Bundles importiert.',
+      summary: '{{processCount}} Prozess · {{filamentCount}} Filament-Voreinstellungen',
+      delete: 'Löschen',
+      uploadSuccess: '{{name}} importiert',
+      uploadError: 'Bundle-Upload fehlgeschlagen: {{message}}',
+      deleteSuccess: 'Bundle entfernt',
+      deleteError: 'Löschen des Bundles fehlgeschlagen: {{message}}',
+      confirmDeleteTitle: 'Dieses Bundle entfernen?',
+      confirmDeleteMessage: 'Slice-Anfragen, die "{{name}}" referenzieren, schlagen fehl, bis das Bundle erneut importiert wird.',
+    },
     externalCameras: 'Externe Kameras',
     externalCameras: 'Externe Kameras',
     costTracking: 'Kostenverfolgung',
     costTracking: 'Kostenverfolgung',
     printsOnly: 'Nur Drucke',
     printsOnly: 'Nur Drucke',

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

@@ -1855,6 +1855,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerBundles: {
+      title: 'Slicer Bundles',
+      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+      uploadButton: 'Upload bundle',
+      uploading: 'Uploading…',
+      loading: 'Loading bundles…',
+      empty: 'No bundles imported yet.',
+      summary: '{{processCount}} process · {{filamentCount}} filament presets',
+      delete: 'Delete',
+      uploadSuccess: 'Imported {{name}}',
+      uploadError: 'Bundle upload failed: {{message}}',
+      deleteSuccess: 'Bundle removed',
+      deleteError: 'Bundle delete failed: {{message}}',
+      confirmDeleteTitle: 'Remove this bundle?',
+      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+    },
     externalCameras: 'External Cameras',
     externalCameras: 'External Cameras',
     costTracking: 'Cost Tracking',
     costTracking: 'Cost Tracking',
     printsOnly: 'Prints Only',
     printsOnly: 'Prints Only',

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

@@ -1809,6 +1809,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerBundles: {
+      title: 'Slicer Bundles',
+      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+      uploadButton: 'Upload bundle',
+      uploading: 'Uploading…',
+      loading: 'Loading bundles…',
+      empty: 'No bundles imported yet.',
+      summary: '{{processCount}} process · {{filamentCount}} filament presets',
+      delete: 'Delete',
+      uploadSuccess: 'Imported {{name}}',
+      uploadError: 'Bundle upload failed: {{message}}',
+      deleteSuccess: 'Bundle removed',
+      deleteError: 'Bundle delete failed: {{message}}',
+      confirmDeleteTitle: 'Remove this bundle?',
+      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+    },
     externalCameras: 'Caméras externes',
     externalCameras: 'Caméras externes',
     costTracking: 'Suivi des coûts',
     costTracking: 'Suivi des coûts',
     printsOnly: 'Impressions uniquement',
     printsOnly: 'Impressions uniquement',

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

@@ -1809,6 +1809,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerBundles: {
+      title: 'Slicer Bundles',
+      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+      uploadButton: 'Upload bundle',
+      uploading: 'Uploading…',
+      loading: 'Loading bundles…',
+      empty: 'No bundles imported yet.',
+      summary: '{{processCount}} process · {{filamentCount}} filament presets',
+      delete: 'Delete',
+      uploadSuccess: 'Imported {{name}}',
+      uploadError: 'Bundle upload failed: {{message}}',
+      deleteSuccess: 'Bundle removed',
+      deleteError: 'Bundle delete failed: {{message}}',
+      confirmDeleteTitle: 'Remove this bundle?',
+      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+    },
     externalCameras: 'Camere esterne',
     externalCameras: 'Camere esterne',
     costTracking: 'Tracciamento costi',
     costTracking: 'Tracciamento costi',
     printsOnly: 'Solo stampe',
     printsOnly: 'Solo stampe',

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

@@ -1851,6 +1851,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerBundles: {
+      title: 'Slicer Bundles',
+      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+      uploadButton: 'Upload bundle',
+      uploading: 'Uploading…',
+      loading: 'Loading bundles…',
+      empty: 'No bundles imported yet.',
+      summary: '{{processCount}} process · {{filamentCount}} filament presets',
+      delete: 'Delete',
+      uploadSuccess: 'Imported {{name}}',
+      uploadError: 'Bundle upload failed: {{message}}',
+      deleteSuccess: 'Bundle removed',
+      deleteError: 'Bundle delete failed: {{message}}',
+      confirmDeleteTitle: 'Remove this bundle?',
+      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+    },
     externalCameras: '外部カメラ',
     externalCameras: '外部カメラ',
     costTracking: 'コスト追跡',
     costTracking: 'コスト追跡',
     printsOnly: '印刷のみ',
     printsOnly: '印刷のみ',

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

@@ -1809,6 +1809,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerBundles: {
+      title: 'Slicer Bundles',
+      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+      uploadButton: 'Upload bundle',
+      uploading: 'Uploading…',
+      loading: 'Loading bundles…',
+      empty: 'No bundles imported yet.',
+      summary: '{{processCount}} process · {{filamentCount}} filament presets',
+      delete: 'Delete',
+      uploadSuccess: 'Imported {{name}}',
+      uploadError: 'Bundle upload failed: {{message}}',
+      deleteSuccess: 'Bundle removed',
+      deleteError: 'Bundle delete failed: {{message}}',
+      confirmDeleteTitle: 'Remove this bundle?',
+      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+    },
     externalCameras: 'Câmeras Externas',
     externalCameras: 'Câmeras Externas',
     costTracking: 'Rastreamento de Custos',
     costTracking: 'Rastreamento de Custos',
     printsOnly: 'Apenas Impressões',
     printsOnly: 'Apenas Impressões',

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

@@ -1853,6 +1853,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerBundles: {
+      title: 'Slicer Bundles',
+      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+      uploadButton: 'Upload bundle',
+      uploading: 'Uploading…',
+      loading: 'Loading bundles…',
+      empty: 'No bundles imported yet.',
+      summary: '{{processCount}} process · {{filamentCount}} filament presets',
+      delete: 'Delete',
+      uploadSuccess: 'Imported {{name}}',
+      uploadError: 'Bundle upload failed: {{message}}',
+      deleteSuccess: 'Bundle removed',
+      deleteError: 'Bundle delete failed: {{message}}',
+      confirmDeleteTitle: 'Remove this bundle?',
+      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+    },
     externalCameras: '外部摄像头',
     externalCameras: '外部摄像头',
     costTracking: '成本追踪',
     costTracking: '成本追踪',
     printsOnly: '仅打印',
     printsOnly: '仅打印',

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

@@ -1853,6 +1853,22 @@ export default {
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerBundles: {
+      title: 'Slicer Bundles',
+      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
+      uploadButton: 'Upload bundle',
+      uploading: 'Uploading…',
+      loading: 'Loading bundles…',
+      empty: 'No bundles imported yet.',
+      summary: '{{processCount}} process · {{filamentCount}} filament presets',
+      delete: 'Delete',
+      uploadSuccess: 'Imported {{name}}',
+      uploadError: 'Bundle upload failed: {{message}}',
+      deleteSuccess: 'Bundle removed',
+      deleteError: 'Bundle delete failed: {{message}}',
+      confirmDeleteTitle: 'Remove this bundle?',
+      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+    },
     externalCameras: '外部攝影機',
     externalCameras: '外部攝影機',
     costTracking: '成本追蹤',
     costTracking: '成本追蹤',
     printsOnly: '僅列印',
     printsOnly: '僅列印',

+ 7 - 0
frontend/src/pages/SettingsPage.tsx

@@ -8,6 +8,7 @@ import { formatDateOnly } from '../utils/date';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
+import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
 import { CameraTokensSection } from './CameraTokensPage';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -4262,6 +4263,12 @@ export function SettingsPage() {
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 
+          {/* Slicer Preset Bundles — only meaningful when the sidecar is in use,
+              since uploads / lists round-trip through it. Hide it entirely when
+              use_slicer_api is off so the Settings page doesn't show a panel that
+              can't do anything. */}
+          {(localSettings.use_slicer_api ?? false) && <SlicerBundlesPanel />}
+
           {/* Auto-Drying */}
           {/* Auto-Drying */}
           <Card>
           <Card>
             <CardHeader>
             <CardHeader>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-B2mJSviN.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CzFVl3Rw.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-N3WJn-iA.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- 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-DA6QDhrM.js"></script>
-    <link rel="stylesheet" crossorigin href="./assets/index-N3WJn-iA.css">
+    <script type="module" crossorigin src="./assets/index-B2mJSviN.js"></script>
+    <link rel="stylesheet" crossorigin href="./assets/index-CzFVl3Rw.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů