feat(slicing): build-plate override in the SliceModal (#1337)

Slicing an STL via the integrated slicer always defaulted to whatever
  curr_bed_type lived in the chosen process preset (typically "Cool
  Plate"), which the slicer CLI rejected for high-temp filaments with
  "Plate 1: Cool Plate does not support filament 1". The user had no
  way to switch plates without cloning the preset in BambuStudio.

  The Slice modal now exposes a Build plate dropdown with the six
  canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
  SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
  Smooth PEI Plate) plus an "Auto (use process preset)" option that
  preserves the previous behavior. Positioned between Process profile
  and Filament rows so a long filament list never pushes it off the
  modal's scrolled viewport, and always enabled regardless of whether
  the user picked a Printer Preset Bundle.

  A new bed_type field on SliceRequest flows through both dispatch
  paths:
  - Resolved-preset path: _patch_process_bed_type overwrites
    curr_bed_type on the process JSON before forwarding to the sidecar.
    Works end-to-end today, no sidecar change needed.
  - Bundle dispatch path: slice_with_bundle adds a bedType form field
    to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
    fork) needs a matching change to honor it as --curr_bed_type on
    the CLI invocation; until then the field is silently ignored and
    the slice runs with the bundle's default plate.
This commit is contained in:
maziggy
2026-05-14 15:28:41 +02:00
parent b51ef33472
commit ccf985abfc
19 changed files with 643 additions and 119 deletions
+35
View File
@@ -2806,6 +2806,29 @@ def _sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
return zip_bytes
def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
"""Overwrite ``curr_bed_type`` in a process-profile JSON before forwarding
to the slicer sidecar.
The slicer CLI reads the build-plate type from the process profile's
``curr_bed_type`` field. When the user picks a non-default plate in the
SliceModal (#1337), we patch the resolved JSON in place rather than
asking them to clone the preset just to switch a plate. Returns the
original string unchanged when the JSON can't be parsed or isn't a
dict — the slicer will then run with whatever the preset originally
specified, which is the safe fall-back path.
"""
try:
profile = json.loads(process_json)
except json.JSONDecodeError:
logger.warning("Bed-type override skipped: process profile is not valid JSON")
return process_json
if not isinstance(profile, dict):
return process_json
profile["curr_bed_type"] = bed_type
return json.dumps(profile)
async def _run_slicer_with_fallback(
db: AsyncSession,
*,
@@ -2872,6 +2895,17 @@ async def _run_slicer_with_fallback(
assert ref is not None, "schema validator guarantees filament list is non-None"
filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
# Bed-type override (#1337): patch curr_bed_type onto the resolved
# process JSON so the slicer's StaticPrintConfig pass picks up the
# user's pick instead of whatever the process preset defaults to.
# Without this, slicing an STL of ABS onto a process preset whose
# default is "Cool Plate" fails with "Plate 1: Cool Plate does not
# support filament 1" — the reporter's exact scenario. Only applies
# to the resolved-preset path; bundle mode would need a sidecar-side
# mechanism to patch presets it materialises from disk.
if request.bed_type:
presets["process"] = _patch_process_bed_type(presets["process"], request.bed_type)
# Slicer routing — pick the sidecar URL by preferred_slicer.
# The per-install URL setting (Settings UI → Slicer card) wins; an
# empty value falls back to the SLICER_API_URL / BAMBU_STUDIO_API_URL
@@ -2951,6 +2985,7 @@ async def _run_slicer_with_fallback(
filament_names=request.bundle.filament_names,
plate=request.plate,
export_3mf=request.export_3mf,
bed_type=request.bed_type,
request_id=progress_request_id,
on_progress=progress_callback,
)
+11
View File
@@ -109,6 +109,17 @@ class SliceRequest(BaseModel):
default=False,
description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
)
bed_type: str | None = Field(
default=None,
max_length=64,
description=(
"Override the process preset's curr_bed_type for this slice. Canonical "
"BambuStudio / OrcaSlicer values: 'Cool Plate', 'Engineering Plate', "
"'High Temp Plate', 'Textured PEI Plate', 'Smooth PEI Plate', "
"'Cool Plate (SuperTack)', 'Supertack Plate'. None ⇒ inherit from the "
"process preset unchanged (#1337)."
),
)
@model_validator(mode="after")
def normalise_preset_refs(self) -> "SliceRequest":
+11
View File
@@ -435,6 +435,7 @@ class SlicerApiService:
filament_names: list[str],
plate: int | None = None,
export_3mf: bool = False,
bed_type: str | None = None,
request_id: str | None = None,
on_progress: Callable[[dict], None] | None = None,
) -> SliceResult:
@@ -476,6 +477,16 @@ class SlicerApiService:
data["plate"] = str(plate)
if export_3mf:
data["exportType"] = "3mf"
if bed_type is not None:
# #1337: bed-plate override flows through to the sidecar as a
# standalone field. The sidecar wraps this as --curr_bed_type on
# the CLI invocation, overriding whatever the bundle's process
# JSON specifies. Bambuddy can't patch the bundle's JSON locally
# (the sidecar materialises it from disk), so this round-trip is
# the only path. Silently no-ops on sidecar versions that don't
# yet recognise the field — the user's slice still runs with the
# bundle's default plate, no crash.
data["bedType"] = bed_type
if request_id is not None:
data["requestId"] = request_id
@@ -234,6 +234,86 @@ class TestSliceLibraryFile:
assert final["result"]["print_time_seconds"] == 656
assert captured["url"].endswith("/slice")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bed_type_override_patches_process_profile(self, async_client: AsyncClient, slice_test_setup):
"""#1337: when SliceRequest.bed_type is set, the process JSON sent to
the sidecar must carry curr_bed_type with that exact value. Without
the patch, slicing high-temp filaments on a "Cool Plate" process
preset fails inside the slicer CLI with "does not support filament 1"
and the user has no way to switch plates from the SliceModal."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = bytes(request.content)
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake",
headers={
"x-print-time-seconds": "10",
"x-filament-used-g": "0.1",
"x-filament-used-mm": "1.0",
},
)
_install_mock_sidecar(handler)
response = await async_client.post(
f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
json={
"printer_preset_id": slice_test_setup["printer_id"],
"process_preset_id": slice_test_setup["process_id"],
"filament_preset_id": slice_test_setup["filament_id"],
"bed_type": "Textured PEI Plate",
},
)
assert response.status_code == 202
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
# The presetProfile part of the multipart upload now carries the
# override. Searching the raw body avoids parsing the multipart by
# hand — the substring is unique enough since we control the JSON
# being patched.
assert b'"curr_bed_type": "Textured PEI Plate"' in captured["body"], (
"bed_type override must appear in the process JSON sent to the sidecar"
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bed_type_omitted_leaves_process_profile_untouched(self, async_client: AsyncClient, slice_test_setup):
"""Companion to the override test: the patch must NOT fire when the
client omits bed_type, so the process preset's own curr_bed_type
(or absence thereof) is forwarded to the sidecar unchanged."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = bytes(request.content)
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake",
headers={
"x-print-time-seconds": "10",
"x-filament-used-g": "0.1",
"x-filament-used-mm": "1.0",
},
)
_install_mock_sidecar(handler)
response = await async_client.post(
f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
json={
"printer_preset_id": slice_test_setup["printer_id"],
"process_preset_id": slice_test_setup["process_id"],
"filament_preset_id": slice_test_setup["filament_id"],
},
)
assert response.status_code == 202
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
assert b"curr_bed_type" not in captured["body"], (
"bed_type must stay out of the process JSON when no override is set"
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
@@ -513,6 +593,84 @@ class TestSliceWithBundle:
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_forwards_bed_type_when_set(self, async_client: AsyncClient, slice_test_setup):
"""#1337 follow-up: bed-type override flows through the bundle path
as a `bedType` form field so the sidecar can pass
`--curr_bed_type` to the CLI. Bambuddy can't patch the bundle's
process JSON locally — the sidecar materialises it from the stored
.bbscfg — so the form field is the only handle."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = bytes(request.content)
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake",
headers={
"x-print-time-seconds": "10",
"x-filament-used-g": "0.1",
"x-filament-used-mm": "1.0",
},
)
_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": "# X1C",
"process_name": "# 0.20mm",
"filament_names": ["# Bambu PLA"],
},
"bed_type": "Engineering Plate",
},
)
assert response.status_code == 202
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
body = captured["body"]
assert b'name="bedType"' in body
assert b"Engineering Plate" in body
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bundle_dispatch_omits_bed_type_when_unset(self, async_client: AsyncClient, slice_test_setup):
"""Companion test: no bed_type ⇒ no bedType form field, so the
bundle's own curr_bed_type is preserved end-to-end."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = bytes(request.content)
return httpx.Response(
status_code=200,
content=b"PK\x03\x04 fake",
headers={
"x-print-time-seconds": "10",
"x-filament-used-g": "0.1",
"x-filament-used-mm": "1.0",
},
)
_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": "# X1C",
"process_name": "# 0.20mm",
"filament_names": ["# Bambu PLA"],
},
},
)
assert response.status_code == 202
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
assert b'name="bedType"' not in captured["body"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bundle_dispatch_3mf_falls_back_to_embedded_on_5xx(
@@ -0,0 +1,80 @@
"""Regression tests for the #1337 bed-type override on slice requests.
The SliceRequest schema accepts an optional `bed_type` string that the
slice route patches onto the resolved process-profile JSON as
`curr_bed_type` before forwarding to the sidecar. Without this hook,
slicing high-temp filaments (ABS, ASA, PC) onto a process preset whose
default plate is "Cool Plate" fails with the slicer-CLI error
"Plate 1: Cool Plate does not support filament 1" and the user has no
way to switch plates short of cloning the preset.
"""
import json
import pytest
from pydantic import ValidationError
from backend.app.api.routes.library import _patch_process_bed_type
from backend.app.schemas.slicer import PresetRef, SliceRequest
class TestSliceRequestBedTypeField:
def test_bed_type_defaults_to_none(self):
req = SliceRequest(
printer_preset=PresetRef(source="local", id="1"),
process_preset=PresetRef(source="local", id="2"),
filament_preset=PresetRef(source="local", id="3"),
)
assert req.bed_type is None
def test_bed_type_accepts_canonical_values(self):
for value in (
"Cool Plate",
"Cool Plate (SuperTack)",
"Engineering Plate",
"High Temp Plate",
"Textured PEI Plate",
"Smooth PEI Plate",
):
req = SliceRequest(
printer_preset=PresetRef(source="local", id="1"),
process_preset=PresetRef(source="local", id="2"),
filament_preset=PresetRef(source="local", id="3"),
bed_type=value,
)
assert req.bed_type == value
def test_bed_type_rejects_overlong_input(self):
with pytest.raises(ValidationError):
SliceRequest(
printer_preset=PresetRef(source="local", id="1"),
process_preset=PresetRef(source="local", id="2"),
filament_preset=PresetRef(source="local", id="3"),
bed_type="x" * 65,
)
class TestPatchProcessBedType:
def test_overwrites_existing_curr_bed_type(self):
process_json = json.dumps({"name": "0.20mm Standard", "curr_bed_type": "Cool Plate"})
result = _patch_process_bed_type(process_json, "Textured PEI Plate")
assert json.loads(result)["curr_bed_type"] == "Textured PEI Plate"
def test_adds_curr_bed_type_when_missing(self):
process_json = json.dumps({"name": "0.20mm Standard"})
result = _patch_process_bed_type(process_json, "Engineering Plate")
parsed = json.loads(result)
assert parsed["curr_bed_type"] == "Engineering Plate"
# Other fields preserved
assert parsed["name"] == "0.20mm Standard"
def test_returns_input_unchanged_when_json_is_invalid(self):
# The slicer would error on this anyway; the patch helper is a
# straight passthrough so failure modes stay attributable to the
# original input rather than the patch.
bogus = "not a json document"
assert _patch_process_bed_type(bogus, "Cool Plate") is bogus
def test_returns_input_unchanged_when_json_is_not_a_dict(self):
not_a_dict = json.dumps(["this", "is", "an", "array"])
assert _patch_process_bed_type(not_a_dict, "Cool Plate") is not_a_dict