Files
bambuddy/backend/app/schemas/slicer.py
T
maziggy 988c00554e feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).

  Permissions: no new endpoint paths added. Preview-slice runs inside
  /filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
  dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
  widened.

  Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
  precedence; 9 unit tests for slice_preview cache behaviour (LRU
  eviction with lock cleanup, content-hash invalidation, concurrent
  thundering-herd guard, no-cache-poison on transient sidecar failure);
  15 unit tests for the two new threemf_tools helpers (5 + 10 cases
  including the 60/40 painted-threshold regression pin); a multi-filament
  wire-format test pinning the multipart part count + order; 22 frontend
  SliceModal tests covering plate picker, multi-color render,
  metadata-aware pre-pick, manual override, and the new tier order.
2026-04-28 12:20:59 +02:00

142 lines
5.9 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 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)
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.",
)
@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."""
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