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

181 lines
6.8 KiB
Python

"""Preview-slice cache for the SliceModal.
The slice modal needs the per-plate filament list before the user picks
profiles. For sliced files this lives in ``Metadata/slice_info.config`` and
the ``/filament-requirements`` endpoint can read it directly. For unsliced
project files it doesn't exist yet — only the slicer can produce it, since
Bambu Studio applies its own pruning to painted-face data at slice time.
This module wraps the sidecar's ``slice_without_profiles`` call so the
endpoint can run a preview slice with the project's embedded settings,
parse the result's slice_info, and return the actual filament list. Results
are cached by ``(kind, source_id, plate_id, content_hash)`` so repeat
opens of the modal on the same plate are instant; LRU eviction keeps the
cache bounded. Hash invalidation handles in-place file replacement; no TTL
is used because preview-slice output is deterministic for a given file
content.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import zipfile
from collections import OrderedDict
from io import BytesIO
import defusedxml.ElementTree as ET
from backend.app.services.slicer_api import (
SlicerApiError,
SlicerApiService,
)
logger = logging.getLogger(__name__)
_PREVIEW_CACHE_MAX = 256
# Cache values: list[dict] on success, [] on parsed-but-empty (slicer
# returned a 3MF without filament data for this plate — caching the negative
# avoids burning 30s+ per modal open on a known-bad input).
_preview_cache: OrderedDict[tuple[str, int, int, str], list[dict]] = OrderedDict()
# Per-key locks prevent N concurrent modal opens on the same (file, plate)
# from launching N redundant preview slices — only the first one runs, the
# rest wait and read from the cache. Locks are evicted alongside cache
# entries to keep the dict bounded; we do NOT cache transient sidecar
# failures (network errors etc.) so those retry naturally on next request.
_preview_locks: dict[tuple[str, int, int, str], asyncio.Lock] = {}
def _content_hash(file_bytes: bytes) -> str:
return hashlib.sha256(file_bytes).hexdigest()[:16]
async def get_preview_filaments(
*,
kind: str,
source_id: int,
plate_id: int,
file_bytes: bytes,
file_name: str,
api_url: str,
) -> list[dict] | None:
"""Run a preview slice for ``plate_id`` using the file's embedded settings,
parse the resulting slice_info, and return the per-plate filament list.
Returns ``None`` when the preview slice fails — the caller should fall
back to whatever heuristic it has (typically the project_filaments +
painted-face approach in ``threemf_tools``).
"""
h = _content_hash(file_bytes)
key = (kind, source_id, plate_id, h)
cached = _preview_cache.get(key)
if cached is not None:
_preview_cache.move_to_end(key)
return cached
lock = _preview_locks.setdefault(key, asyncio.Lock())
async with lock:
# Re-check after acquiring the lock — another coroutine may have
# populated the cache while we were waiting on it.
cached = _preview_cache.get(key)
if cached is not None:
_preview_cache.move_to_end(key)
return cached
try:
async with SlicerApiService(base_url=api_url) as svc:
result = await svc.slice_without_profiles(
model_bytes=file_bytes,
model_filename=file_name,
plate=plate_id,
export_3mf=True,
)
except SlicerApiError as e:
logger.warning(
"Preview slice failed for %s/%s plate %s: %s",
kind,
source_id,
plate_id,
e,
)
return None
except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
logger.warning("Preview slice unexpected error: %s", e)
return None
filaments = _parse_filaments_from_sliced_3mf(result.content, plate_id)
# Negative-cache the parse failure: a slice that succeeds but yields
# no parsable filament data for this plate is a deterministic
# property of the input. Re-running the slice produces the same
# result, just N seconds slower. Empty list signals "preview was
# tried, no usable data" so the caller can fall through.
cache_value: list[dict] = filaments if filaments is not None else []
_preview_cache[key] = cache_value
if len(_preview_cache) > _PREVIEW_CACHE_MAX:
evicted_key, _ = _preview_cache.popitem(last=False)
# Drop the matching lock so the dict doesn't grow forever.
# Safe to discard: the lock isn't held here, and any later
# request for the same key will mint a fresh lock.
_preview_locks.pop(evicted_key, None)
return filaments
def _parse_filaments_from_sliced_3mf(content: bytes, plate_id: int) -> list[dict] | None:
"""Extract ``<filament>`` entries for ``plate_id`` from a sliced 3MF's
Metadata/slice_info.config. Returns ``None`` on any parse error so the
caller knows to fall back."""
try:
with zipfile.ZipFile(BytesIO(content)) as zf:
if "Metadata/slice_info.config" not in zf.namelist():
return None
data = zf.read("Metadata/slice_info.config").decode()
except (zipfile.BadZipFile, OSError):
return None
try:
root = ET.fromstring(data)
except ET.ParseError:
return None
for plate_elem in root.findall(".//plate"):
idx = None
for meta in plate_elem.findall("metadata"):
if meta.get("key") == "index":
try:
idx = int(meta.get("value", ""))
except (ValueError, TypeError):
pass
break
if idx != plate_id:
continue
out: list[dict] = []
for f in plate_elem.findall("filament"):
fid = f.get("id")
if not fid:
continue
try:
slot_id = int(fid)
except (ValueError, TypeError):
continue
try:
used_grams = float(f.get("used_g", "0"))
except (ValueError, TypeError):
used_grams = 0
try:
used_meters = float(f.get("used_m", "0"))
except (ValueError, TypeError):
used_meters = 0
out.append(
{
"slot_id": slot_id,
"type": f.get("type", ""),
"color": f.get("color", ""),
"used_grams": round(used_grams, 1),
"used_meters": used_meters,
"tray_info_idx": f.get("tray_info_idx", ""),
},
)
return sorted(out, key=lambda x: x["slot_id"])
return None