Files
bambuddy/backend/app/services/slicer_api.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

288 lines
11 KiB
Python

"""HTTP client for an OrcaSlicer / BambuStudio API sidecar.
Bambuddy stores user printer/process/filament profiles itself (cloud-synced
or locally imported), so the slice flow always sends the model file plus an
explicit JSON profile triplet to the sidecar's `/slice` endpoint. The sidecar
shape mirrors `AFKFelix/orca-slicer-api` (multipart upload, `--load-settings`
under the hood, response body is raw G-code or 3MF with metadata in the
`X-Print-Time-Seconds` / `X-Filament-Used-G` / `X-Filament-Used-Mm` headers).
"""
import logging
from typing import NamedTuple
import httpx
logger = logging.getLogger(__name__)
class SlicerApiError(Exception):
"""Base error from the slicer API sidecar."""
class SlicerApiUnavailableError(SlicerApiError):
"""Sidecar is unreachable (connection error, no response)."""
class SlicerApiServerError(SlicerApiError):
"""Sidecar responded with a 5xx — usually the wrapped slicer CLI exited
non-zero (range-validation reject, segfault on complex models, etc.).
Distinguished from `SlicerApiUnavailableError` so the caller can decide
whether to retry with a different request shape (e.g. a 3MF embedded-
settings fallback)."""
class SlicerInputError(SlicerApiError):
"""Sidecar rejected the input as invalid (4xx)."""
class SliceResult(NamedTuple):
"""Result of a slice operation."""
content: bytes
print_time_seconds: int
filament_used_g: float
filament_used_mm: float
_shared_http_client: httpx.AsyncClient | None = None
def _format_sidecar_error(response: httpx.Response) -> str:
"""Build a human-readable error string from a sidecar 4xx/5xx response.
The sidecar's `AppError` middleware emits a JSON body of the shape
``{"message": "...", "details": "..."}``. Earlier versions of this
client only read ``message``, which left every CLI failure surfaced
as the generic ``Failed to slice the model`` because the *actual*
CLI stderr / `error_string` lives in ``details``. Including both
means ``bambuddy.log`` carries the real reason a slice rejected
the supplied profiles instead of an unhelpful generic line.
"""
try:
payload = response.json()
except Exception:
return response.text[:500]
if not isinstance(payload, dict):
return str(payload)[:500]
message = payload.get("message") or ""
details = payload.get("details") or ""
if message and details:
return f"{message}: {details}"[:500]
return (message or details or response.text)[:500]
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
"""Register an app-scoped client so per-request services can pool transport."""
global _shared_http_client
_shared_http_client = client
def _guess_model_content_type(filename: str) -> str:
lower = filename.lower()
if lower.endswith(".stl"):
return "model/stl"
if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
return "model/3mf"
if lower.endswith(".step") or lower.endswith(".stp"):
return "model/step"
return "application/octet-stream"
class SlicerApiService:
"""Talks to an OrcaSlicer / BambuStudio API sidecar."""
def __init__(
self,
base_url: str,
*,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 300.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
if client is not None:
self._client = client
self._owns_client = False
elif _shared_http_client is not None:
self._client = _shared_http_client
self._owns_client = False
else:
self._client = httpx.AsyncClient(timeout=timeout_seconds)
self._owns_client = True
async def close(self) -> None:
if self._owns_client:
await self._client.aclose()
async def __aenter__(self) -> "SlicerApiService":
return self
async def __aexit__(self, *_: object) -> None:
await self.close()
async def health(self) -> dict:
"""GET /health — used to surface a clear "sidecar offline" error before
accepting a slice request from the user."""
try:
response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 400:
raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
return response.json()
async def list_bundled_profiles(self) -> dict:
"""GET /profiles/bundled — return the slicer's stock profiles by slot.
Powers the "Standard" tier of Bambuddy's SliceModal preset dropdowns.
The sidecar walks the slicer's read-only `resources/profiles/BBL/`
tree and returns ``{printer, process, filament}`` arrays of
``{name, base_id}`` (alphabetised, instantiable presets only — abstract
bases like `fdm_filament_pla` are filtered out by the sidecar).
Returns an empty-shaped dict when the sidecar is unreachable so the
unified-presets endpoint can degrade to "no standard tier" without
crashing the modal — cloud + local-imported profiles still render.
"""
try:
response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 400:
raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
return response.json()
async def slice_with_profiles(
self,
*,
model_bytes: bytes,
model_filename: str,
printer_profile_json: str,
process_profile_json: str,
filament_profile_jsons: list[str],
plate: int | None = None,
export_3mf: bool = False,
) -> SliceResult:
"""POST /slice with model + printer/process/filament profiles.
``filament_profile_jsons`` is plate-slot-ordered: index 0 is the
profile for slot 1, etc. Single-color callers pass a one-element
list. Multiple ``filamentProfile`` parts are sent as a repeated form
field — the sidecar's route declares ``maxCount: 16`` and the
slicing service joins them as semicolon-separated
``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
Raises:
SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
SlicerApiUnavailableError: connection error or 5xx from sidecar.
"""
# httpx supports repeated multipart fields when files is a list of
# tuples — using the dict form would silently overwrite duplicate
# keys and ship only the last filament profile.
files: list[tuple[str, tuple[str, bytes, str]]] = [
("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))),
("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")),
("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")),
]
for idx, fjson in enumerate(filament_profile_jsons):
files.append(
(
"filamentProfile",
(f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"),
)
)
data: dict[str, str] = {}
if plate is not None:
data["plate"] = str(plate)
if export_3mf:
data["exportType"] = "3mf"
try:
response = await self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=self.timeout_seconds,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 500:
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
return SliceResult(
content=response.content,
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
)
async def slice_without_profiles(
self,
*,
model_bytes: bytes,
model_filename: str,
plate: int | None = None,
export_3mf: bool = False,
) -> SliceResult:
"""POST /slice with only the model file and no profile triplet.
For 3MF inputs this lets the slicer fall back on the file's embedded
`Metadata/project_settings.config`. Used as a fallback when
`slice_with_profiles` triggers a CLI segfault or other 5xx —
complex H2D / multi-extruder models hit upstream bugs in both the
OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
"""
files = {
"file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
}
data: dict[str, str] = {}
if plate is not None:
data["plate"] = str(plate)
if export_3mf:
data["exportType"] = "3mf"
try:
response = await self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=self.timeout_seconds,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 500:
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
return SliceResult(
content=response.content,
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
)
def _safe_int(value: str | None) -> int:
if not value:
return 0
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _safe_float(value: str | None) -> float:
if not value:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0