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
This commit is contained in:
maziggy
2026-05-06 09:42:15 +02:00
parent a64cfbbd36
commit 7e1105dcb6
4 changed files with 339 additions and 34 deletions
+62 -32
View File
@@ -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
View File
@@ -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"),
@@ -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}
# ---------------------------------------------------------------------------
@@ -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