mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user