diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 9d2306cb3..c4a720fbd 100644 --- a/backend/app/api/routes/library.py +++ b/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, diff --git a/backend/app/schemas/slicer.py b/backend/app/schemas/slicer.py index 15450dd0d..65e3a2090 100644 --- a/backend/app/schemas/slicer.py +++ b/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"), diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index dbc852cfa..e2c666566 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/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} # --------------------------------------------------------------------------- diff --git a/backend/tests/unit/test_slice_request_schema.py b/backend/tests/unit/test_slice_request_schema.py index 918eab970..dcf5b129d 100644 --- a/backend/tests/unit/test_slice_request_schema.py +++ b/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