mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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:
parent
b51ef33472
commit
ccf985abfc
19 changed files with 643 additions and 119 deletions
|
|
@ -13,6 +13,9 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
|
||||
- **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone.
|
||||
|
||||
### Added
|
||||
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. 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 explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
|
||||
|
||||
### Fixed
|
||||
- **Color catalog presets now apply `extra_colors` (gradient stops) and `effect_type` (sparkle / wood / marble / glow / matte) onto the spool, not just hex + name** ([#1340](https://github.com/maziggy/bambuddy/issues/1340), reported by @maugsburger) — Creating a catalog entry that pairs a base color with multi-color gradient stops and a visual effect, then clicking that swatch in the Edit Spool dialog, only copied `color_name` and `rgba` over — the `extra_colors` and `effect_type` fields were silently dropped. The data was flowing from the backend correctly (`GET /api/v1/inventory/color-catalog` returns both fields per the `ColorCatalogEntry` schema in [`frontend/src/api/client.ts`](frontend/src/api/client.ts)), but three layers above stripped them: (1) [`SpoolFormModal.tsx`](frontend/src/components/SpoolFormModal.tsx) typed its `colorCatalog` state with a narrower shape that omitted the two fields; (2) [`ColorSection.tsx`](frontend/src/components/spool-form/ColorSection.tsx) mapped catalog entries to `CatalogDisplayColor` (the typed-down shape rendered on swatches) without propagating them; (3) the `selectColor()` handler only set `rgba` + `color_name` on click. **Fix:** widened both types in [`spool-form/types.ts`](frontend/src/components/spool-form/types.ts) (`CatalogDisplayColor` + `ColorSectionProps.catalogColors`) to carry the optional `extra_colors` and `effect_type`, propagated them through the four `matchedCatalogColors` mapping callbacks (byBrand / exact full-material / normalized-trailing-`+` / base-material prefix), and extended `selectColor` to take optional `extraColors` / `effectType` parameters. **Semantic rule:** catalog swatches are complete presets — picking one writes BOTH gradient and effect from the entry (overwriting any existing values), so a gradient catalog entry applies its stops AND a solid catalog entry clears any old gradient that was on the spool. Recent-colors and the hardcoded-fallback palette are plain hex pickers — picking one keeps any existing `extra_colors` / `effect_type` untouched, since those swatches aren't presets, just color picks. **Bonus:** fixed the en-US spelling drift the reporter flagged in their nitpick — `'Extra colours'` and `'wrong colour loaded'` strings (which had been seeded into all 8 locale files as English fallbacks) standardized to `'Extra colors'` and `'wrong color loaded'`; matching comment blocks (`// Multi-colour ...`) normalized in the same pass. **Regression tests** in [`__tests__/components/spool-form/ColorSectionCatalogExtras.test.tsx`](frontend/src/__tests__/components/spool-form/ColorSectionCatalogExtras.test.tsx) (3 cases): catalog click with gradient + effect propagates all four fields to `updateField`, catalog click on a solid preset clears any pre-existing extras/effect (preset-replaces-look semantic), and fallback palette click leaves extras/effect untouched. All 23 spool-form tests + 8 i18n parity tests pass; build clean.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
80
backend/tests/unit/test_slice_request_bed_type.py
Normal file
80
backend/tests/unit/test_slice_request_bed_type.py
Normal file
|
|
@ -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
|
||||
|
|
@ -138,11 +138,15 @@ describe('SliceModal', () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getByText('My Custom X1C')).toBeDefined();
|
||||
});
|
||||
// 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
|
||||
// between process and filament — it overrides curr_bed_type on the
|
||||
// process preset so the related controls cluster — and defaults to "".
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(selects).toHaveLength(3);
|
||||
expect(selects).toHaveLength(4);
|
||||
expect(selects[0].value).toBe('local:1');
|
||||
expect(selects[1].value).toBe('local:2');
|
||||
expect(selects[2].value).toBe('local:3');
|
||||
expect(selects[2].value).toBe('');
|
||||
expect(selects[3].value).toBe('local:3');
|
||||
|
||||
// Slice button is enabled because all three slots auto-defaulted and
|
||||
// the preview-slice query has resolved (mock returns immediately).
|
||||
|
|
@ -240,6 +244,65 @@ describe('SliceModal', () => {
|
|||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/42',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
// Order with the dropdown now sits between Process and Filament:
|
||||
// printer (0), process (1), bed-type (2), filament (3+). Find the
|
||||
// bed-type select by name rather than positional index so this stays
|
||||
// green if the layout adds another control around it.
|
||||
const bedSelect = screen.getAllByRole('combobox').find((el) =>
|
||||
(el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
|
||||
) as HTMLSelectElement;
|
||||
expect(bedSelect).toBeDefined();
|
||||
await user.selectOptions(bedSelect, 'Textured PEI Plate');
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.objectContaining({ bed_type: 'Textured PEI Plate' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('omits bed_type when the user leaves it on Auto (no override)', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'pending',
|
||||
status_url: '/api/v1/slice-jobs/42',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
|
||||
onClose,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
|
||||
expect(body).not.toHaveProperty('bed_type');
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the user override the default and pick a Standard preset', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
|
|
@ -603,8 +666,8 @@ describe('SliceModal', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
||||
// 1 printer + 1 process + 2 filament = 4 dropdowns.
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(4);
|
||||
// 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('pre-picks each filament slot by matching colour metadata', async () => {
|
||||
|
|
@ -686,9 +749,11 @@ describe('SliceModal', () => {
|
|||
|
||||
const user = userEvent.setup();
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
// Slots 0 (printer) and 1 (process) are auto-picked. Slots 2 and 3 are
|
||||
// the two filament dropdowns. Swap slot-2 (was black) to white.
|
||||
await user.selectOptions(selects[2], 'cloud:F-WHITE');
|
||||
// Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
|
||||
// (#1337). Auto-picks land on printer/process/filaments; bed-type
|
||||
// defaults to "". Swap filament-1 (index 3) from the auto-picked black
|
||||
// to white.
|
||||
await user.selectOptions(selects[3], 'cloud:F-WHITE');
|
||||
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -884,12 +949,14 @@ describe('SliceModal', () => {
|
|||
|
||||
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
|
||||
|
||||
// Both filament rows render — 1 printer + 1 process + 2 filament = 4.
|
||||
// Both filament rows render — 1 printer + 1 process + 1 bed-type +
|
||||
// 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
|
||||
// follow at 3 and 4.
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(selects).toHaveLength(4);
|
||||
expect(selects).toHaveLength(5);
|
||||
// Slot 1 (used) is editable, slot 2 (not used) is disabled.
|
||||
expect(selects[2].disabled).toBe(false);
|
||||
expect(selects[3].disabled).toBe(true);
|
||||
expect(selects[3].disabled).toBe(false);
|
||||
expect(selects[4].disabled).toBe(true);
|
||||
// The disabled row's label calls out why it's disabled.
|
||||
expect(screen.getByText(/not used by this plate/i)).toBeDefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1206,6 +1206,12 @@ export interface SliceRequest {
|
|||
bundle?: SliceBundleSpec;
|
||||
plate?: number;
|
||||
export_3mf?: boolean;
|
||||
// Build-plate override (#1337). When omitted, the slicer uses the process
|
||||
// preset's curr_bed_type as-is. Canonical values match BambuStudio /
|
||||
// OrcaSlicer's enum: "Cool Plate", "Engineering Plate", "High Temp Plate",
|
||||
// "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
|
||||
// "Supertack Plate".
|
||||
bed_type?: string | null;
|
||||
}
|
||||
|
||||
// GET /api/v1/slicer/bundles — Printer Preset Bundles imported from
|
||||
|
|
|
|||
|
|
@ -261,6 +261,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|||
// and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
|
||||
// the user picks one (or implicitly for single-plate sources).
|
||||
const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
|
||||
// Build-plate override (#1337). null = inherit from the process preset
|
||||
// (the default). Set to a canonical slicer enum value to patch
|
||||
// curr_bed_type into the resolved process JSON before slicing — needed
|
||||
// because the process preset's default plate (typically "Cool Plate") is
|
||||
// incompatible with high-temp filaments like ABS / ASA / PC, and the
|
||||
// user had no way to switch plates without cloning the preset.
|
||||
const [bedType, setBedType] = useState<string | null>(null);
|
||||
|
||||
const platesQuery = useQuery({
|
||||
queryKey: ['slicePlates', source.kind, source.id],
|
||||
|
|
@ -429,6 +436,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|||
body = {
|
||||
bundle: bundleSpec,
|
||||
...(selectedPlate != null ? { plate: selectedPlate } : {}),
|
||||
// Bed-type override (#1337) also flows through the bundle path —
|
||||
// the sidecar forwards `bedType` as --curr_bed_type to the CLI.
|
||||
...(bedType != null ? { bed_type: bedType } : {}),
|
||||
};
|
||||
} else {
|
||||
if (
|
||||
|
|
@ -451,6 +461,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|||
// omit otherwise so the backend default applies for STL / single-plate
|
||||
// 3MF sources where the concept doesn't apply.
|
||||
...(selectedPlate != null ? { plate: selectedPlate } : {}),
|
||||
// Bed-type override (#1337).
|
||||
...(bedType != null ? { bed_type: bedType } : {}),
|
||||
};
|
||||
}
|
||||
if (source.kind === 'libraryFile') {
|
||||
|
|
@ -651,6 +663,17 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Bed-type override (#1337). Always visible, always enabled.
|
||||
In non-bundle mode the backend patches curr_bed_type on the
|
||||
resolved process JSON before forwarding to the sidecar; in
|
||||
bundle mode the same value rides through as a sidecar form
|
||||
field so the bundle's materialised process JSON gets the
|
||||
override applied there too. */}
|
||||
<BedTypeDropdown
|
||||
value={bedType}
|
||||
onChange={setBedType}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
{/* Filament reqs may need a server-side preview-slice for
|
||||
unsliced project files (single-pass, then cached). Show a
|
||||
scoped spinner so the user sees the printer/process
|
||||
|
|
@ -831,6 +854,56 @@ function CloudStatusBanner({ status }: { status: SlicerCloudStatus }) {
|
|||
);
|
||||
}
|
||||
|
||||
// Build-plate options offered in the SliceModal (#1337). Values are the
|
||||
// canonical strings the slicer's StaticPrintConfig validator accepts as
|
||||
// `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
|
||||
// enum; OrcaSlicer accepts the same set with a Supertack alias that users
|
||||
// can target via the same dropdown if they re-import their presets.
|
||||
const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
|
||||
{ value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
|
||||
{
|
||||
value: 'Cool Plate (SuperTack)',
|
||||
labelKey: 'slice.bedType.coolPlateSuperTack',
|
||||
fallback: 'Cool Plate SuperTack',
|
||||
},
|
||||
{ value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
|
||||
{ value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
|
||||
{ value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
|
||||
{ value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
|
||||
];
|
||||
|
||||
function BedTypeDropdown({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs text-bambu-gray mb-1">
|
||||
{t('slice.bedType.label', 'Build plate')}
|
||||
</span>
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
|
||||
>
|
||||
<option value="">{t('slice.bedType.auto', 'Auto (use process preset)')}</option>
|
||||
{BED_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey, opt.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresetDropdownProps {
|
||||
label: string;
|
||||
slot: Slot;
|
||||
|
|
|
|||
|
|
@ -3388,6 +3388,16 @@ export default {
|
|||
expired: 'Bambu-Cloud-Sitzung abgelaufen — erneut anmelden, um die Cloud-Profile zu aktualisieren.',
|
||||
unreachable: 'Bambu Cloud ist gerade nicht erreichbar. Lokale und Standard-Profile funktionieren weiterhin.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Druckbett',
|
||||
auto: 'Auto (aus Prozess-Profil)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
|
|
@ -3391,6 +3391,16 @@ export default {
|
|||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
|
|
@ -3377,6 +3377,16 @@ export default {
|
|||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
|
|
@ -3376,6 +3376,16 @@ export default {
|
|||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
|
|
@ -3388,6 +3388,16 @@ export default {
|
|||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
|
|
@ -3376,6 +3376,16 @@ export default {
|
|||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
|
|
@ -3376,6 +3376,16 @@ export default {
|
|||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
|
|
@ -3376,6 +3376,16 @@ export default {
|
|||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
coolPlate: 'Cool Plate',
|
||||
coolPlateSuperTack: 'Cool Plate SuperTack',
|
||||
engineering: 'Engineering Plate',
|
||||
highTemp: 'High Temp Plate',
|
||||
texturedPEI: 'Textured PEI Plate',
|
||||
smoothPEI: 'Smooth PEI Plate',
|
||||
},
|
||||
},
|
||||
|
||||
// Spoolman
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-oQDjU99K.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CG1DM53r.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BkYu3kLs.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue