mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user