mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +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.
173 lines
6.2 KiB
Python
173 lines
6.2 KiB
Python
"""In-memory background dispatcher for slice jobs.
|
|
|
|
Mirrors the shape of `background_dispatch.py` (the print-upload dispatcher)
|
|
but tailored for slicing: jobs are independent (no printer-busy gating),
|
|
short-lived (typically 5-60s), and the result is a `LibraryFile` or
|
|
`PrintArchive` row rather than a printer-side dispatch.
|
|
|
|
The frontend kicks off a slice via `POST /library/files/{id}/slice` or
|
|
`POST /archives/{id}/slice`, gets back `{job_id, status_url}`, then polls
|
|
`GET /slice-jobs/{id}` until status is `completed` or `failed`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Literal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
SliceJobStatus = Literal["pending", "running", "completed", "failed"]
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SliceJob:
|
|
id: int
|
|
kind: Literal["library_file", "archive"]
|
|
source_id: int
|
|
source_name: str
|
|
status: SliceJobStatus = "pending"
|
|
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
started_at: datetime | None = None
|
|
completed_at: datetime | None = None
|
|
# On success: the body returned to the caller — usually a SliceResponse
|
|
# or SliceArchiveResponse dict.
|
|
result: dict[str, Any] | None = None
|
|
# On failure: HTTP status + error message.
|
|
error_status: int | None = None
|
|
error_detail: str | None = None
|
|
# Live progress fed by the sidecar's --pipe channel while the slicer
|
|
# is running. Populated by a polling task spawned alongside the
|
|
# blocking POST /slice request; None when the sidecar doesn't
|
|
# support progress (older sidecars, no request_id, etc.). Surfaced
|
|
# in the SliceJobState response so the persistent toast can render
|
|
# "Generating G-code (75%)" instead of just elapsed time.
|
|
progress: dict[str, Any] | None = None
|
|
|
|
|
|
# Retention: keep finished jobs around for 30 minutes so the polling client
|
|
# always sees a terminal state on its next tick. After that, the next access
|
|
# sweep prunes them.
|
|
_RETENTION_SECONDS = 30 * 60
|
|
|
|
|
|
class SliceDispatchService:
|
|
def __init__(self) -> None:
|
|
self._jobs: dict[int, SliceJob] = {}
|
|
self._next_id: int = 1
|
|
self._lock = asyncio.Lock()
|
|
self._tasks: dict[int, asyncio.Task] = {}
|
|
|
|
async def enqueue(
|
|
self,
|
|
*,
|
|
kind: Literal["library_file", "archive"],
|
|
source_id: int,
|
|
source_name: str,
|
|
run: Callable[[int], Awaitable[dict[str, Any]]],
|
|
) -> SliceJob:
|
|
"""Register a new slice job and start it on the event loop.
|
|
|
|
``run`` is an async callable that takes the freshly-created
|
|
``job_id`` (so it can wire up live-progress reporting via
|
|
:meth:`set_progress`) and returns the response body the caller
|
|
will receive once status flips to ``completed``.
|
|
"""
|
|
async with self._lock:
|
|
job = SliceJob(
|
|
id=self._next_id,
|
|
kind=kind,
|
|
source_id=source_id,
|
|
source_name=source_name,
|
|
)
|
|
self._next_id += 1
|
|
self._jobs[job.id] = job
|
|
self._sweep_locked()
|
|
|
|
task = asyncio.create_task(self._run_job(job, run), name=f"slice-job-{job.id}")
|
|
self._tasks[job.id] = task
|
|
return job
|
|
|
|
async def _run_job(
|
|
self,
|
|
job: SliceJob,
|
|
run: Callable[[int], Awaitable[dict[str, Any]]],
|
|
) -> None:
|
|
job.started_at = datetime.now(timezone.utc)
|
|
job.status = "running"
|
|
try:
|
|
result = await run(job.id)
|
|
job.result = result
|
|
job.status = "completed"
|
|
except _SliceJobError as exc:
|
|
# Caller-controlled HTTP error — propagate status + detail.
|
|
job.status = "failed"
|
|
job.error_status = exc.status_code
|
|
job.error_detail = exc.detail
|
|
except Exception as exc:
|
|
logger.exception("Slice job %s failed unexpectedly", job.id)
|
|
job.status = "failed"
|
|
job.error_status = 500
|
|
job.error_detail = f"Unexpected error: {exc}"
|
|
finally:
|
|
job.completed_at = datetime.now(timezone.utc)
|
|
self._tasks.pop(job.id, None)
|
|
|
|
def get(self, job_id: int) -> SliceJob | None:
|
|
return self._jobs.get(job_id)
|
|
|
|
def set_progress(self, job_id: int, progress: dict[str, Any] | None) -> None:
|
|
"""Update the live-progress snapshot for a running job.
|
|
|
|
Called by the slice route's progress poller every ~1s while the
|
|
sidecar slice request is in flight. Silently ignores unknown ids
|
|
(the job may have just finished and been retention-swept) so a
|
|
late poll doesn't crash the polling task.
|
|
"""
|
|
job = self._jobs.get(job_id)
|
|
if job is not None:
|
|
job.progress = progress
|
|
|
|
def _sweep_locked(self) -> None:
|
|
"""Drop finished jobs older than the retention window. Caller holds
|
|
the lock."""
|
|
now = datetime.now(timezone.utc)
|
|
stale_ids = [
|
|
jid
|
|
for jid, job in self._jobs.items()
|
|
if job.status in ("completed", "failed")
|
|
and job.completed_at is not None
|
|
and (now - job.completed_at).total_seconds() > _RETENTION_SECONDS
|
|
]
|
|
for jid in stale_ids:
|
|
self._jobs.pop(jid, None)
|
|
|
|
|
|
class _SliceJobError(Exception):
|
|
"""Raised inside a slice job's `run` callable to surface a specific
|
|
HTTP status + detail. The dispatcher catches these and stores them on
|
|
the job. Callers convert ``HTTPException`` to this on the boundary.
|
|
"""
|
|
|
|
def __init__(self, status_code: int, detail: str) -> None:
|
|
super().__init__(detail)
|
|
self.status_code = status_code
|
|
self.detail = detail
|
|
|
|
|
|
def http_exception_to_job_error(exc) -> _SliceJobError:
|
|
"""Convert a starlette ``HTTPException`` into the dispatcher's error
|
|
type. Handles the common case where slice helpers raise FastAPI's
|
|
``HTTPException`` for validation / sidecar failures.
|
|
"""
|
|
return _SliceJobError(exc.status_code, str(exc.detail))
|
|
|
|
|
|
# Module-level singleton, started/stopped by main.py's lifespan.
|
|
slice_dispatch = SliceDispatchService()
|