mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
End-to-end live progress, two correctness fixes, and a UX warning around
the upstream OrcaSlicer bugs we discovered while testing.
LIVE PROGRESS
=============
Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
"{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
The same wiring covers the SliceModal's filament-analysis preview slice
(the real slice that fires before profile picking, used to discover
which AMS slots an unsliced plate consumes) and the embedded-settings
fallback path triggered by Orca's --load-settings segfault on complex
H2D models.
- Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
switch /slice from execFile to spawn, mkfifo per request, parse the
CLI's structured JSON progress events into a per-process
ProgressStore, expose GET /slice/progress/:requestId.
- Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
accept request_id + on_progress, spawn a 1Hz parallel poller that
forwards each snapshot via SliceDispatchService.set_progress(job_id,
...) onto the matching SliceJob; GET /slice-jobs/:id includes the
latest snapshot on every poll. The 404 from the early-race window
(POST fired before sidecar's progressStore.start) is treated as a
retry rather than terminal -- otherwise the poller bailed before any
progress could ever arrive.
- /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
progress endpoint for the modal's filament-discovery flow (the
/filament-requirements call is server-originated; the browser can't
reach the sidecar directly).
- Frontend: SliceJobTrackerContext re-renders the persistent toast with
the new format when a useful progress frame is present, falls back
to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
support progress. SliceModal.FilamentAnalysisSpinner generates a
per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
inline spinner contents into a separate persistent toast so the
preview slice doesn't feel silent either.
CORRECTNESS FIXES
=================
- MakerWorld imports were persisting URL-encoded filenames verbatim
("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
s the manifest-supplied name and the URL path-tail fallback before
passing to save_3mf_bytes_to_library; frontend defensively
decodeURIComponent s in the slice toast / analysis spinner so
already-imported rows display cleanly without a backfill migration.
- The fallback path's slice_without_profiles call now forwards the
same request_id + on_progress as the primary slice_with_profiles
call so the toast keeps updating across the segfault -> embedded-
settings retry boundary instead of going blank.
ORCASLICER WARNING
==================
Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
extract and a minimal sentinel-value-injected cube fixture:
- OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
update_values_to_printer_extruders_for_multiple_filaments on
painted multi-extruder 3MFs (commented on the existing thread,
not a new issue)
- OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
values BambuStudio writes by default (solid_infill_filament: 0,
tree_support_wall_count: -1, prime_tower_brim_width: -1) and
rejects with exit 238, even though Orca's own GUI tolerates
them (filed by us alongside this change)
Settings -> Workflow -> Slicer card renders an amber inline warning
under the preferred-slicer dropdown when orcaslicer is selected,
linking both upstream issues and recommending BambuStudio until the
fixes land. Option stays pickable -- users who only slice STLs aren't
affected by either bug.
183 lines
6.9 KiB
Python
183 lines
6.9 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,
|
|
request_id: str | None = None,
|
|
) -> 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,
|
|
request_id=request_id,
|
|
)
|
|
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
|