Browse Source

feat(slicer): bundle dispatch path for library slice route

  When SliceRequest.bundle is set, the dispatch picks the per-category
  JSON triplet from a sidecar-stored .bbscfg by name instead of
  resolving cloud/local/standard PresetRefs. Mirrors the bundle-aware
  preview slice (committed earlier) so live slices match the same
  profile triplet the modal previewed against.

  Schema:
  - SliceBundleSpec: bundle_id + printer_name + process_name +
    filament_names (min-length-1 list, plate-slot order)
  - SliceRequest.bundle: optional, validator skips preset-required
    check when set so bundle-only requests validate

  Dispatch:
  - _run_slicer_with_fallback branches on request.bundle
  - Skips resolve_preset_ref, calls slice_with_bundle
  - 3MF + bundle CLI 5xx still falls back to embedded-settings slice
    (used_embedded_settings=True surfaces in the response)
  - Sidecar 404 (unknown bundle / preset name) maps to 400
maziggy 3 weeks ago
parent
commit
7e1105dcb6

+ 62 - 32
backend/app/api/routes/library.py

@@ -2782,29 +2782,37 @@ async def _run_slicer_with_fallback(
         SlicerInputError,
     )
 
-    # Resolve each slot via the source-aware resolver. The schema validator
-    # has already normalised legacy `*_preset_id: int` fields into
-    # `PresetRef(source='local', id=str(int))`, so all three are guaranteed
-    # non-None here.
-    user: User | None = None
-    if current_user_id is not None:
-        user = await db.get(User, current_user_id)
+    # Bundle dispatch path: when SliceRequest.bundle is set, the schema
+    # validator short-circuited the presets-required check, so the
+    # PresetRef fields may all be None. Skip resolve_preset_ref entirely
+    # — the sidecar will materialise the per-category JSONs from the
+    # bundle's extracted directory at slice time.
+    use_bundle = request.bundle is not None
 
+    user: User | None = None
     presets: dict[str, str] = {}
-    refs = {
-        "printer": request.printer_preset,
-        "process": request.process_preset,
-    }
-    for slot, ref in refs.items():
-        assert ref is not None, "schema validator guarantees PresetRef is set"
-        presets[slot] = await resolve_preset_ref(db, user, ref, slot)
-    # Multi-color: resolve each filament slot in plate order. The schema
-    # validator backfilled `filament_presets` from the legacy `filament_preset`
-    # field for single-color callers, so this list is always non-empty.
     filament_jsons: list[str] = []
-    for ref in request.filament_presets:
-        assert ref is not None, "schema validator guarantees filament list is non-None"
-        filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
+    if not use_bundle:
+        # Resolve each slot via the source-aware resolver. The schema
+        # validator has already normalised legacy `*_preset_id: int`
+        # fields into `PresetRef(source='local', id=str(int))`, so all
+        # three are guaranteed non-None here.
+        if current_user_id is not None:
+            user = await db.get(User, current_user_id)
+
+        refs = {
+            "printer": request.printer_preset,
+            "process": request.process_preset,
+        }
+        for slot, ref in refs.items():
+            assert ref is not None, "schema validator guarantees PresetRef is set"
+            presets[slot] = await resolve_preset_ref(db, user, ref, slot)
+        # Multi-color: resolve each filament slot in plate order. The schema
+        # validator backfilled `filament_presets` from the legacy `filament_preset`
+        # field for single-color callers, so this list is always non-empty.
+        for ref in request.filament_presets:
+            assert ref is not None, "schema validator guarantees filament list is non-None"
+            filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
 
     # Slicer routing — pick the sidecar URL by preferred_slicer.
     # The per-install URL setting (Settings UI → Slicer card) wins; an
@@ -2871,17 +2879,35 @@ async def _run_slicer_with_fallback(
         progress_callback = _on_progress
     try:
         try:
-            result = await service.slice_with_profiles(
-                model_bytes=primary_bytes,
-                model_filename=model_filename,
-                printer_profile_json=presets["printer"],
-                process_profile_json=presets["process"],
-                filament_profile_jsons=filament_jsons,
-                plate=request.plate,
-                export_3mf=request.export_3mf,
-                request_id=progress_request_id,
-                on_progress=progress_callback,
-            )
+            if use_bundle:
+                # Bundle dispatch: sidecar materialises the JSON triplet
+                # from the stored .bbscfg by name. ``request.bundle`` is
+                # guaranteed non-None here by the use_bundle branch above.
+                assert request.bundle is not None
+                result = await service.slice_with_bundle(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    bundle_id=request.bundle.bundle_id,
+                    printer_name=request.bundle.printer_name,
+                    process_name=request.bundle.process_name,
+                    filament_names=request.bundle.filament_names,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+            else:
+                result = await service.slice_with_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    printer_profile_json=presets["printer"],
+                    process_profile_json=presets["process"],
+                    filament_profile_jsons=filament_jsons,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
         except SlicerApiServerError as exc:
             if not is_3mf:
                 raise
@@ -2896,7 +2922,11 @@ async def _run_slicer_with_fallback(
             # bytes — the embedded-settings path also reads the same
             # project_settings.config and the same range validator runs
             # there too, so without sanitisation the fallback would die
-            # on the same sentinel error (#1201).
+            # on the same sentinel error (#1201). Same fallback applies
+            # to the bundle path: if the resolved triplet crashes the CLI,
+            # embedded settings give the user *something* rather than a
+            # hard failure (the SliceModal flags the difference via
+            # used_embedded_settings).
             result = await service.slice_without_profiles(
                 model_bytes=primary_bytes,
                 model_filename=model_filename,

+ 48 - 1
backend/app/schemas/slicer.py

@@ -17,6 +17,37 @@ class PresetRef(BaseModel):
     id: str = Field(..., description=("Cloud setting_id, local DB row id (stringified), or standard preset name."))
 
 
+class SliceBundleSpec(BaseModel):
+    """Per-request reference to a Printer Preset Bundle stored on the slicer
+    sidecar. When SliceRequest.bundle is set, the dispatch skips PresetRef
+    resolution entirely and asks the sidecar to pick its inner JSON triplet
+    by name from the bundle's extracted directory — much faster than
+    re-uploading three profile JSONs every slice and matches the preset
+    triplet the user actually slices with in BambuStudio.
+    """
+
+    bundle_id: str = Field(
+        ...,
+        min_length=1,
+        description="Sidecar-side bundle id from POST /api/v1/slicer/bundles.",
+    )
+    printer_name: str = Field(
+        ...,
+        min_length=1,
+        description="Preset name within the bundle's printer/ directory (with or without the BambuStudio '# ' prefix).",
+    )
+    process_name: str = Field(
+        ...,
+        min_length=1,
+        description="Preset name within the bundle's process/ directory.",
+    )
+    filament_names: list[str] = Field(
+        ...,
+        min_length=1,
+        description="Per-slot filament preset names within the bundle's filament/ directory. Index 0 = slot 1.",
+    )
+
+
 class SliceRequest(BaseModel):
     """Body for `POST /library/files/{file_id}/slice`.
 
@@ -60,6 +91,15 @@ class SliceRequest(BaseModel):
     # is empty so older clients keep working.
     filament_presets: list[PresetRef] = Field(default_factory=list)
 
+    # Bundle dispatch alternative — when set, presets above are ignored and
+    # the slicer dispatch picks per-category JSONs from a previously-imported
+    # .bbscfg on the sidecar. Validator below short-circuits the
+    # presets-required check when this is non-None.
+    bundle: SliceBundleSpec | None = Field(
+        default=None,
+        description="When set, slice via a sidecar-side bundle instead of resolved preset refs.",
+    )
+
     plate: int | None = Field(
         default=None,
         ge=1,
@@ -77,7 +117,14 @@ class SliceRequest(BaseModel):
         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."""
+        into a one-element list.
+
+        When ``bundle`` is set, the dispatch picks the JSON triplet from
+        the sidecar bundle directly so PresetRef resolution is skipped —
+        return early before the presets-required checks below.
+        """
+        if self.bundle is not None:
+            return self
         for slot, ref_attr, legacy_attr in (
             ("printer", "printer_preset", "printer_preset_id"),
             ("process", "process_preset", "process_preset_id"),

+ 150 - 0
backend/tests/integration/test_library_slice_api.py

@@ -454,6 +454,156 @@ class TestSliceLibraryFile:
         assert "3D/3dmodel.model" in names
 
 
+class TestSliceWithBundle:
+    """Bundle dispatch path: when SliceRequest.bundle is set, the dispatch
+    forwards bundle id + per-category preset names to the sidecar instead
+    of resolving cloud/local/standard PresetRefs. Same fallback semantics
+    apply for 3MF inputs whose CLI run fails."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bundle_dispatch_forwards_form_fields(self, async_client: AsyncClient, slice_test_setup):
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "200",
+                    "x-filament-used-g": "1.5",
+                    "x-filament-used-mm": "150",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "bundle": {
+                    "bundle_id": "abc123def456abcd",
+                    "printer_name": "# Bambu Lab H2D 0.4 nozzle",
+                    "process_name": "# 0.20mm Standard @BBL H2D",
+                    "filament_names": [
+                        "# Bambu PLA Basic @BBL H2D",
+                        "# Bambu PETG HF @BBL H2D 0.4 nozzle",
+                    ],
+                },
+            },
+        )
+        assert response.status_code == 202, response.text
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        # Multipart form body should carry the bundle selectors instead of
+        # the JSON profile attachments. Quick string-level check is enough
+        # to confirm the dispatch picked the bundle branch.
+        body = captured["body"]
+        assert b'name="bundle"' in body
+        assert b"abc123def456abcd" in body
+        assert b'name="printerName"' in body
+        assert b'name="processName"' in body
+        assert b'name="filamentNames"' in body
+        # Multi-color filament list joined with ';' on the wire.
+        assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D 0.4 nozzle" in body
+        # Profile attachments must NOT be present — bundle dispatch skips
+        # PresetRef resolution entirely.
+        assert b'name="printerProfile"' not in body
+        assert b'name="presetProfile"' not in body
+        assert b'name="filamentProfile"' not in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bundle_dispatch_3mf_falls_back_to_embedded_on_5xx(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        # Same fallback as the preset-based path: if the resolved bundle
+        # triplet crashes the CLI on a 3MF, retry with embedded settings
+        # so the user gets *something* rather than a hard failure.
+        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex_bundle.3mf"
+        src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
+        threemf = LibraryFile(
+            filename="complex_bundle.3mf",
+            file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="3mf",
+            file_size=src_3mf_path.stat().st_size,
+        )
+        db_session.add(threemf)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+
+        call_count = {"n": 0}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            call_count["n"] += 1
+            # First call: bundle path → simulate CLI 5xx
+            if call_count["n"] == 1:
+                return httpx.Response(
+                    status_code=500,
+                    json={"message": "Failed to slice the model"},
+                )
+            # Retry: no profiles / no bundle → succeed with embedded settings
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "bundle": {
+                    "bundle_id": "abc",
+                    "printer_name": "P",
+                    "process_name": "Q",
+                    "filament_names": ["F"],
+                },
+            },
+        )
+        assert response.status_code == 202
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is True
+        assert call_count["n"] == 2  # bundle attempt + embedded fallback
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bundle_dispatch_404_surfaces_as_400(self, async_client: AsyncClient, slice_test_setup):
+        # Sidecar returns 404 when the bundle / preset name isn't found —
+        # the slicer client classifies this as user-correctable input
+        # error so the dispatch returns 400 to the caller, not 502.
+        def handler(_: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=404,
+                json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "bundle": {
+                    "bundle_id": "abc",
+                    "printer_name": "P",
+                    "process_name": "Imaginary",
+                    "filament_names": ["F"],
+                },
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "failed"
+        assert final["error_status"] == 400
+        assert "imaginary" in (final["error_detail"] or "").lower()
+
+
 # ---------------------------------------------------------------------------
 # GET /slice-jobs/{id}
 # ---------------------------------------------------------------------------

+ 79 - 1
backend/tests/unit/test_slice_request_schema.py

@@ -6,7 +6,7 @@ normalisation that lets the route handler ignore the difference.
 import pytest
 from pydantic import ValidationError
 
-from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.schemas.slicer import PresetRef, SliceBundleSpec, SliceRequest
 
 
 class TestLegacyBareIntegerShape:
@@ -142,3 +142,81 @@ class TestFilamentPresetsList:
             filament_presets=refs,
         )
         assert [r.id for r in req.filament_presets] == ["slot1", "slot2", "slot3"]
+
+
+class TestBundleDispatchShape:
+    """When SliceRequest.bundle is set, the dispatcher picks the JSON
+    triplet from a sidecar-side bundle by name and PresetRef resolution
+    is skipped entirely. Validator must accept "bundle alone" without
+    flagging missing presets."""
+
+    def test_bundle_alone_validates(self):
+        req = SliceRequest(
+            bundle=SliceBundleSpec(
+                bundle_id="abc123def456abcd",
+                printer_name="# Bambu Lab H2D 0.4 nozzle",
+                process_name="# 0.20mm Standard @BBL H2D",
+                filament_names=["# Bambu PLA Basic @BBL H2D"],
+            ),
+        )
+        # PresetRef fields are absent; that's fine in bundle mode.
+        assert req.bundle is not None
+        assert req.printer_preset is None
+        assert req.process_preset is None
+        assert req.filament_presets == []
+
+    def test_bundle_with_filament_list_preserves_order(self):
+        req = SliceRequest(
+            bundle=SliceBundleSpec(
+                bundle_id="abc",
+                printer_name="P",
+                process_name="Q",
+                filament_names=["red", "blue", "green"],
+            ),
+        )
+        assert req.bundle.filament_names == ["red", "blue", "green"]
+
+    def test_bundle_rejects_empty_filament_list(self):
+        with pytest.raises(ValidationError):
+            SliceBundleSpec(
+                bundle_id="abc",
+                printer_name="P",
+                process_name="Q",
+                filament_names=[],
+            )
+
+    def test_bundle_rejects_empty_id(self):
+        with pytest.raises(ValidationError):
+            SliceBundleSpec(
+                bundle_id="",
+                printer_name="P",
+                process_name="Q",
+                filament_names=["F"],
+            )
+
+    def test_no_bundle_no_presets_still_rejected(self):
+        # Dropping the bundle escape-hatch must not bypass the existing
+        # presets-required check.
+        with pytest.raises(ValidationError):
+            SliceRequest()
+
+    def test_bundle_with_presets_keeps_both_fields(self):
+        # Sending both is allowed (validator accepts the bundle and skips
+        # preset normalisation) — the dispatch picks bundle on the route
+        # side. Confirms the validator doesn't reject overlapping intent
+        # so a future client that wants to record the legacy presets
+        # alongside doesn't fail validation.
+        req = SliceRequest(
+            printer_preset=PresetRef(source="standard", id="X1C"),
+            process_preset=PresetRef(source="standard", id="0.20"),
+            filament_presets=[PresetRef(source="standard", id="PLA")],
+            bundle=SliceBundleSpec(
+                bundle_id="abc",
+                printer_name="P",
+                process_name="Q",
+                filament_names=["F"],
+            ),
+        )
+        assert req.bundle is not None
+        # Presets stay populated; dispatch ignores them when bundle is set.
+        assert req.printer_preset is not None