Files
bambuddy/backend/app/schemas/slicer.py
T
maziggy ccf985abfc 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.
2026-05-14 15:28:41 +02:00

200 lines
8.2 KiB
Python

"""Pydantic schemas for slice requests."""
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class PresetRef(BaseModel):
"""A source-aware reference to a printer / process / filament preset.
The SliceModal pulls dropdown options from three tiers (cloud / local /
standard). At submit time the client sends one of these per slot so the
backend knows where to fetch the preset content from at slice time.
"""
source: Literal["cloud", "local", "standard"]
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`.
Two preset shapes are accepted per slot for backwards-compatibility:
- **Legacy** — bare integer ``*_preset_id`` fields point into the
``local_presets`` table. Existing clients (and stale browser tabs after
a Bambuddy upgrade) keep working unchanged.
- **Source-aware** — ``*_preset`` carries an explicit
``{source, id}``. Required for cloud / standard tiers; also accepted
(and equivalent) for local presets when the client is on the new modal.
Exactly one of each pair must be set; the validator normalises legacy
integer ids into a ``PresetRef(source='local', id=str(id))`` so the
downstream resolver only deals with one shape.
"""
# Legacy fields — kept optional so older clients continue to work.
printer_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer printer_preset. LocalPreset id with preset_type='printer'.",
)
process_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer process_preset. LocalPreset id with preset_type='process'.",
)
filament_preset_id: int | None = Field(
default=None,
description="DEPRECATED: prefer filament_preset. LocalPreset id with preset_type='filament'.",
)
# Source-aware fields — set by the new SliceModal.
printer_preset: PresetRef | None = None
process_preset: PresetRef | None = None
filament_preset: PresetRef | None = None
# Multi-color: one PresetRef per AMS slot the source plate uses. Order is
# significant — the slicer matches index-by-index against the plate's
# filament slots. Always preferred over the legacy singular field; the
# validator promotes a singular field into ``[singular]`` when the list
# 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,
description="Plate number to slice (1-indexed). Defaults to plate 1 on the sidecar.",
)
export_3mf: bool = Field(
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":
"""Each slot must end up with a `PresetRef` set. Legacy integer ids
become `(source='local', id=str(int))` so the route handler only
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.
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"),
):
ref = getattr(self, ref_attr)
legacy_id = getattr(self, legacy_attr)
if ref is None and legacy_id is None:
raise ValueError(
f"{slot} preset is required: provide '{ref_attr}' (preferred) or legacy '{legacy_attr}'"
)
if ref is None:
setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
# Filament accepts THREE shapes, in priority order:
# 1. filament_presets — multi-color array (new clients)
# 2. filament_preset — source-aware singular (single-color new clients)
# 3. filament_preset_id — legacy bare integer (old clients)
# The first non-empty shape wins; missing all three raises.
if not self.filament_presets:
if self.filament_preset is not None:
self.filament_presets = [self.filament_preset]
elif self.filament_preset_id is not None:
fallback = PresetRef(source="local", id=str(self.filament_preset_id))
self.filament_preset = fallback
self.filament_presets = [fallback]
else:
raise ValueError(
"filament preset is required: provide 'filament_presets' (preferred), "
"'filament_preset', or legacy 'filament_preset_id'"
)
elif self.filament_preset is None:
# Multi-color caller: backfill the singular from the first slot
# so callers that still read the legacy field see a stable value.
self.filament_preset = self.filament_presets[0]
return self
class SliceResponse(BaseModel):
"""Response from `POST /library/files/{file_id}/slice`. The result lands
in the user's library as a new ``LibraryFile`` (in the same folder as
the source)."""
library_file_id: int
name: str
print_time_seconds: int
filament_used_g: float
filament_used_mm: float
used_embedded_settings: bool = False
class SliceArchiveResponse(BaseModel):
"""Response from `POST /archives/{archive_id}/slice`. The result lands
in the user's archives as a new ``PrintArchive`` row, inheriting
printer / project metadata from the source archive."""
archive_id: int
name: str
print_time_seconds: int
filament_used_g: float
filament_used_mm: float
used_embedded_settings: bool = False