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.
This commit is contained in:
maziggy
2026-04-28 12:20:59 +02:00
parent 69b6b5a334
commit 988c00554e
26 changed files with 2488 additions and 526 deletions
+79 -1
View File
@@ -27,7 +27,11 @@ from backend.app.models.user import User
from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
from backend.app.schemas.slicer import SliceRequest
from backend.app.services.archive import ArchiveService
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
from backend.app.utils.threemf_tools import (
extract_nozzle_mapping_from_3mf,
extract_plate_extruder_set_from_3mf,
extract_project_filaments_from_3mf,
)
logger = logging.getLogger(__name__)
@@ -3109,6 +3113,47 @@ async def get_plate_thumbnail(
raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
async def _try_preview_slice_filaments(
db: AsyncSession,
*,
kind: str,
source_id: int,
plate_id: int,
file_path: Path,
) -> list[dict] | None:
"""Run a preview slice via the user's configured sidecar so the filament
list endpoint can return real per-plate filaments for unsliced project
files. Returns ``None`` on any failure — the caller falls back to the
painted-face heuristic."""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
if preferred == "orcaslicer":
configured = await get_setting(db, "orcaslicer_api_url")
api_url = (configured or settings.slicer_api_url).strip()
elif preferred == "bambu_studio":
configured = await get_setting(db, "bambu_studio_api_url")
api_url = (configured or settings.bambu_studio_api_url).strip()
else:
return None
if not api_url:
return None
try:
file_bytes = file_path.read_bytes()
except OSError:
return None
return await get_preview_filaments(
kind=kind,
source_id=source_id,
plate_id=plate_id,
file_bytes=file_bytes,
file_name=file_path.name,
api_url=api_url,
)
@router.get("/{archive_id}/filament-requirements")
async def get_filament_requirements(
archive_id: int,
@@ -3216,6 +3261,39 @@ async def get_filament_requirements(
}
)
# Unsliced project files: slice_info has nothing usable. Try a
# preview-slice via the sidecar — the slicer's own logic
# determines which filaments the plate actually consumes
# (Bambu Studio prunes painted regions whose extruder isn't
# used, etc.) — and parse the resulting slice_info. Cached so
# the modal opens fast on the second visit. Falls back to the
# painted-face heuristic if the sidecar isn't configured or
# the preview slice errors out.
if not filaments and plate_id is not None:
preview = await _try_preview_slice_filaments(
db,
kind="archive",
source_id=archive_id,
plate_id=plate_id,
file_path=file_path,
)
if preview is not None:
filaments = preview
# Last-resort fallback for unsliced files when preview slicing
# also can't help (no sidecar, slicer errored, etc.). See
# library.py for the matching block.
if not filaments:
project_filaments = extract_project_filaments_from_3mf(zf)
if plate_id is not None and project_filaments:
used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id)
if used_slots:
filaments = [f for f in project_filaments if f["slot_id"] in used_slots]
else:
filaments = project_filaments
elif project_filaments:
filaments = project_filaments
# Sort by slot ID
filaments.sort(key=lambda x: x["slot_id"])
+84 -3
View File
@@ -61,7 +61,11 @@ from backend.app.schemas.library import (
from backend.app.schemas.slicer import SliceRequest, SliceResponse
from backend.app.services.archive import ThreeMFParser
from backend.app.services.stl_thumbnail import generate_stl_thumbnail
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
from backend.app.utils.threemf_tools import (
extract_nozzle_mapping_from_3mf,
extract_plate_extruder_set_from_3mf,
extract_project_filaments_from_3mf,
)
logger = logging.getLogger(__name__)
@@ -2338,6 +2342,45 @@ async def get_library_file_plate_thumbnail(
raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
async def _try_preview_slice_filaments(
db: AsyncSession,
*,
kind: str,
source_id: int,
plate_id: int,
file_path: Path,
) -> list[dict] | None:
"""Run a preview slice via the user's configured sidecar. Same shape as
the matching helper in archives.py — see that module for rationale."""
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_preview import get_preview_filaments
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
if preferred == "orcaslicer":
configured = await get_setting(db, "orcaslicer_api_url")
api_url = (configured or app_settings.slicer_api_url).strip()
elif preferred == "bambu_studio":
configured = await get_setting(db, "bambu_studio_api_url")
api_url = (configured or app_settings.bambu_studio_api_url).strip()
else:
return None
if not api_url:
return None
try:
file_bytes = file_path.read_bytes()
except OSError:
return None
return await get_preview_filaments(
kind=kind,
source_id=source_id,
plate_id=plate_id,
file_bytes=file_bytes,
file_name=file_path.name,
api_url=api_url,
)
@router.get("/files/{file_id}/filament-requirements")
async def get_library_file_filament_requirements(
file_id: int,
@@ -2451,6 +2494,38 @@ async def get_library_file_filament_requirements(
}
)
# Unsliced project files: slice_info has nothing usable. Try a
# preview-slice via the sidecar — see archives.py for full
# rationale. Cached per (kind, id, plate, content_hash).
if not filaments and plate_id is not None:
preview = await _try_preview_slice_filaments(
db,
kind="library_file",
source_id=file_id,
plate_id=plate_id,
file_path=file_path,
)
if preview is not None:
filaments = preview
# Last-resort fallback when preview slicing also can't help (no
# sidecar configured, slicer errored, etc.). project_settings
# gives us the full AMS slot config; the plate-extruder filter
# narrows it to the slots the painted faces reference.
if not filaments:
project_filaments = extract_project_filaments_from_3mf(zf)
if plate_id is not None and project_filaments:
used_slots = extract_plate_extruder_set_from_3mf(zf, plate_id)
if used_slots:
filaments = [f for f in project_filaments if f["slot_id"] in used_slots]
else:
# No extruder metadata anywhere — return the full
# project list rather than zero so the user still
# gets to pick (over-rendering > under-rendering).
filaments = project_filaments
elif project_filaments:
filaments = project_filaments
# Sort by slot ID
filaments.sort(key=lambda x: x["slot_id"])
@@ -2567,11 +2642,17 @@ async def _run_slicer_with_fallback(
refs = {
"printer": request.printer_preset,
"process": request.process_preset,
"filament": request.filament_preset,
}
for slot, ref in refs.items():
assert ref is not None, "schema validator guarantees PresetRef is set"
presets[slot] = await resolve_preset_ref(db, user, ref, slot)
# Multi-color: resolve each filament slot in plate order. The schema
# validator backfilled `filament_presets` from the legacy `filament_preset`
# field for single-color callers, so this list is always non-empty.
filament_jsons: list[str] = []
for ref in request.filament_presets:
assert ref is not None, "schema validator guarantees filament list is non-None"
filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
# Slicer routing — pick the sidecar URL by preferred_slicer.
# The per-install URL setting (Settings UI → Slicer card) wins; an
@@ -2607,7 +2688,7 @@ async def _run_slicer_with_fallback(
model_filename=model_filename,
printer_profile_json=presets["printer"],
process_profile_json=presets["process"],
filament_profile_json=presets["filament"],
filament_profile_jsons=filament_jsons,
plate=request.plate,
export_3mf=request.export_3mf,
)
+113 -34
View File
@@ -12,6 +12,7 @@ without faking an "ok with empty list" response.
from __future__ import annotations
import hashlib
import json
import logging
import time
@@ -105,41 +106,54 @@ async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dic
cloud = BambuCloudService(region=region)
cloud.set_token(token)
try:
raw = await cloud.get_slicer_settings()
except BambuCloudAuthError:
# Don't clear the token here — the cloud-status endpoint owns that
# lifecycle. Just report expired so the UI can prompt re-auth.
return _empty_slots(), "expired"
except BambuCloudError as e:
logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
return _empty_slots(), "unreachable"
except Exception as e: # noqa: BLE001 — defensive: never crash the modal
logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
return _empty_slots(), "unreachable"
try:
raw = await cloud.get_slicer_settings()
except BambuCloudAuthError:
# Don't clear the token here — the cloud-status endpoint owns that
# lifecycle. Just report expired so the UI can prompt re-auth.
return _empty_slots(), "expired"
except BambuCloudError as e:
logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
return _empty_slots(), "unreachable"
except Exception as e: # noqa: BLE001 — defensive: never crash the modal
logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
return _empty_slots(), "unreachable"
slots = _empty_slots()
for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
type_data = raw.get(cloud_type, {})
# The cloud splits presets into "private" (the user's own) and "public"
# (Bambu's stock cloud presets). Both are valid choices — surface them
# in the natural order private → public so a user's customisations
# appear above the stock entries with the same names. Stock entries
# that share names with private ones get deduped out within the cloud
# tier itself.
seen_names: set[str] = set()
for entry in type_data.get("private", []) + type_data.get("public", []):
name = entry.get("name")
setting_id = entry.get("setting_id") or entry.get("id")
if not name or not setting_id or name in seen_names:
continue
seen_names.add(name)
slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
# Cloud filament presets carry no metadata in this response on
# purpose: the per-preset detail endpoint
# (/v1/iot-service/api/slicer/setting/{id}) is rate-limited at roughly
# 10/sec per token, so fetching N filament presets to enrich them
# one-by-one trips Bambu's limiter and returns 429 on every request
# for users with large preset libraries (#1150 follow-up).
#
# The dedup pass (see _dedupe_by_name) compensates: when a cloud entry
# wins over a same-named local entry, the cloud entry inherits the
# local entry's filament_type / filament_colour. So cloud presets that
# also exist locally still get metadata-aware pre-pick in the
# SliceModal; cloud-only presets fall back to plain priority order.
_cloud_cache[cache_key] = (now, slots)
return slots, "ok"
finally:
await cloud.close()
slots = _empty_slots()
for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
type_data = raw.get(cloud_type, {})
# The cloud splits presets into "private" (the user's own) and "public"
# (Bambu's stock cloud presets). Both are valid choices — surface them
# in the natural order private → public so a user's customisations
# appear above the stock entries with the same names. Stock entries
# that share names with private ones get deduped out within the cloud
# tier itself.
seen_names: set[str] = set()
for entry in type_data.get("private", []) + type_data.get("public", []):
name = entry.get("name")
setting_id = entry.get("setting_id") or entry.get("id")
if not name or not setting_id or name in seen_names:
continue
seen_names.add(name)
slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
_cloud_cache[cache_key] = (now, slots)
return slots, "ok"
async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
"""Local imports — no caching needed, single indexed DB read."""
@@ -151,10 +165,40 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
slot = type_to_slot.get(p.preset_type)
if slot is None:
continue
slots[slot].append(UnifiedPreset(id=str(p.id), name=p.name, source="local"))
extra: dict[str, str | None] = {}
if slot == "filament":
extra["filament_type"], extra["filament_colour"] = _parse_filament_metadata(p.setting)
slots[slot].append(
UnifiedPreset(id=str(p.id), name=p.name, source="local", **extra),
)
return slots
def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
"""Extract first-slot ``filament_type`` and ``filament_colour`` from a
stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we
take the first entry since pre-pick matching is one-slot-at-a-time.
Defensive parse: any error returns (None, None) so a corrupt row never
breaks the listing."""
if not setting_json:
return None, None
try:
data = json.loads(setting_json)
except (ValueError, TypeError):
return None, None
if not isinstance(data, dict):
return None, None
return _first_scalar(data.get("filament_type")), _first_scalar(data.get("filament_colour"))
def _first_scalar(value: object) -> str | None:
if isinstance(value, list) and value:
return value[0] if isinstance(value[0], str) else None
if isinstance(value, str) and value:
return value
return None
async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
"""Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
global _bundled_cache
@@ -186,7 +230,13 @@ async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPres
continue
# Bundled presets are addressed by name (the slicer resolves them
# by name during the `inherits:` walk), so name doubles as id.
slots[slot].append(UnifiedPreset(id=name, name=name, source="standard"))
extra: dict[str, str | None] = {}
if slot == "filament":
extra["filament_type"] = entry.get("filament_type")
extra["filament_colour"] = entry.get("filament_colour")
slots[slot].append(
UnifiedPreset(id=name, name=name, source="standard", **extra),
)
_bundled_cache = (now, slots)
return slots
@@ -236,7 +286,36 @@ def _dedupe_by_name(
Order within each tier is preserved as-is — only "lower-priority duplicates"
are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud
public AND standard bundled) only renders once, in the cloud tier.
Filament metadata is **merged across tiers** during dedup: when a cloud
entry wins over a same-named local entry, the cloud entry inherits the
local entry's ``filament_type`` and ``filament_colour`` (cloud entries
carry no metadata themselves because we deliberately don't fetch each
setting's content — see _fetch_cloud_presets). Without this merge, the
SliceModal's metadata-aware pre-pick would silently lose match data for
every preset the user has both cloud-synced and locally imported, and
fall back to plain priority selection.
"""
# Build a lookup: filament name → metadata from the highest-quality tier
# that has it. Local + standard both expose parsed metadata; cloud
# doesn't. Take whichever non-empty entry shows up first.
metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
for tier in (local, standard):
for p in tier["filament"]:
if p.name in metadata_by_name:
continue
if p.filament_type or p.filament_colour:
metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
# Backfill cloud entries that don't have their own metadata.
for p in cloud["filament"]:
if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
t, c = metadata_by_name[p.name]
if p.filament_type is None and t is not None:
p.filament_type = t
if p.filament_colour is None and c is not None:
p.filament_colour = c
deduped_local = _empty_slots()
deduped_standard = _empty_slots()
for slot in ("printer", "process", "filament"):