Explorar el Código

feat(slicer): live progress + filament discovery polish + OrcaSlicer warning

  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.
maziggy hace 4 semanas
padre
commit
d5153f1de3

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 4 - 0
CHANGELOG.md


+ 8 - 2
backend/app/api/routes/archives.py

@@ -3128,11 +3128,13 @@ async def _try_preview_slice_filaments(
     source_id: int,
     plate_id: int,
     file_path: Path,
+    request_id: str | None = None,
 ) -> 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."""
+    painted-face heuristic. ``request_id`` flows through to the sidecar
+    for live progress on the SliceModal's inline spinner + toast."""
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
 
@@ -3159,6 +3161,7 @@ async def _try_preview_slice_filaments(
         file_bytes=file_bytes,
         file_name=file_path.name,
         api_url=api_url,
+        request_id=request_id,
     )
 
 
@@ -3166,6 +3169,7 @@ async def _try_preview_slice_filaments(
 async def get_filament_requirements(
     archive_id: int,
     plate_id: int | None = None,
+    request_id: str | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
@@ -3286,6 +3290,7 @@ async def get_filament_requirements(
                         source_id=archive_id,
                         plate_id=plate_id,
                         file_path=file_path,
+                        request_id=request_id,
                     )
                     if preview is not None:
                         used_slot_ids = {f["slot_id"] for f in preview}
@@ -3375,7 +3380,7 @@ async def slice_archive(
     archive_id_local = archive.id
     user_id = current_user.id if current_user else None
 
-    async def _run():
+    async def _run(job_id: int):
         async with async_session() as task_db:
             # Re-fetch the source archive on the background-task session.
             src_archive = await task_db.get(PrintArchive, archive_id_local)
@@ -3391,6 +3396,7 @@ async def slice_archive(
                     request=request,
                     source_archive=src_archive,
                     current_user_id=user_id,
+                    job_id=job_id,
                 )
             except HTTPException as exc:
                 raise http_exception_to_job_error(exc) from exc

+ 48 - 2
backend/app/api/routes/library.py

@@ -2360,9 +2360,15 @@ async def _try_preview_slice_filaments(
     source_id: int,
     plate_id: int,
     file_path: Path,
+    request_id: str | None = None,
 ) -> 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."""
+    the matching helper in archives.py — see that module for rationale.
+
+    ``request_id``: when supplied, forwarded to the sidecar so the
+    SliceModal's inline spinner + toast can poll the matching progress
+    endpoint and show "Generating G-code (45%)" for the preview as well.
+    """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
 
@@ -2389,6 +2395,7 @@ async def _try_preview_slice_filaments(
         file_bytes=file_bytes,
         file_name=file_path.name,
         api_url=api_url,
+        request_id=request_id,
     )
 
 
@@ -2396,6 +2403,7 @@ async def _try_preview_slice_filaments(
 async def get_library_file_filament_requirements(
     file_id: int,
     plate_id: int | None = None,
+    request_id: str | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
@@ -2532,6 +2540,7 @@ async def get_library_file_filament_requirements(
                         source_id=file_id,
                         plate_id=plate_id,
                         file_path=file_path,
+                        request_id=request_id,
                     )
                     if preview is not None:
                         used_slot_ids = {f["slot_id"] for f in preview}
@@ -2629,6 +2638,7 @@ async def _run_slicer_with_fallback(
     model_filename: str,
     request: SliceRequest,
     current_user_id: int | None = None,
+    job_id: int | None = None,
 ):
     """Validate presets, dispatch to the right sidecar, run the slicer with
     the auto-fallback for 3MF inputs whose `--load-settings` path crashes the
@@ -2638,6 +2648,13 @@ async def _run_slicer_with_fallback(
     `current_user_id` is needed to resolve **cloud** presets — the cloud token
     is per-user when auth is enabled. For the legacy / local-only path it can
     be left ``None``.
+
+    `job_id`: when set, a request_id is generated and a parallel poller
+    pushes the sidecar's --pipe-fed progress events onto
+    ``slice_dispatch.set_progress(job_id, ...)`` so the UI's persistent
+    toast can show "Generating G-code (75%)" instead of just elapsed
+    time. Pass None for synchronous routes that aren't tracked by the
+    dispatcher.
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.preset_resolver import resolve_preset_ref
@@ -2710,6 +2727,23 @@ async def _run_slicer_with_fallback(
 
     used_embedded_settings = False
     service = SlicerApiService(api_url)
+    # When this slice is dispatcher-tracked, generate a request_id so
+    # the sidecar publishes progress under it, and wire a callback that
+    # forwards each frame onto SliceDispatchService.set_progress for the
+    # status-poll endpoint to surface to the UI.
+    progress_request_id: str | None = None
+    progress_callback = None
+    if job_id is not None:
+        from uuid import uuid4
+
+        from backend.app.services.slice_dispatch import slice_dispatch as _dispatch
+
+        progress_request_id = str(uuid4())
+
+        def _on_progress(snapshot: dict) -> None:
+            _dispatch.set_progress(job_id, snapshot)
+
+        progress_callback = _on_progress
     try:
         try:
             result = await service.slice_with_profiles(
@@ -2720,6 +2754,8 @@ async def _run_slicer_with_fallback(
                 filament_profile_jsons=filament_jsons,
                 plate=request.plate,
                 export_3mf=request.export_3mf,
+                request_id=progress_request_id,
+                on_progress=progress_callback,
             )
         except SlicerApiServerError as exc:
             if not is_3mf:
@@ -2729,11 +2765,16 @@ async def _run_slicer_with_fallback(
                 model_filename,
                 exc,
             )
+            # Forward the same request_id + callback so the toast's live
+            # progress keeps updating across the fallback retry instead
+            # of going blank for the rest of the slice.
             result = await service.slice_without_profiles(
                 model_bytes=model_bytes,
                 model_filename=model_filename,
                 plate=request.plate,
                 export_3mf=request.export_3mf,
+                request_id=progress_request_id,
+                on_progress=progress_callback,
             )
             used_embedded_settings = True
     except SlicerInputError as exc:
@@ -2757,6 +2798,7 @@ async def slice_and_persist(
     extra_metadata: dict | None,
     request: SliceRequest,
     current_user_id: int | None,
+    job_id: int | None = None,
 ) -> SliceResponse:
     """Slice a model and save the result as a new ``LibraryFile`` in
     ``folder_id`` (same folder as the source by convention).
@@ -2775,6 +2817,7 @@ async def slice_and_persist(
         model_filename=model_filename,
         request=library_request,
         current_user_id=current_user_id,
+        job_id=job_id,
     )
 
     base_name = model_filename.rsplit(".", 1)[0]
@@ -2862,6 +2905,7 @@ async def slice_and_persist_as_archive(
     request: SliceRequest,
     source_archive,  # PrintArchive — hint kept loose to avoid cyclic import
     current_user_id: int | None,
+    job_id: int | None = None,
 ):
     """Slice a model and save the result as a new ``PrintArchive`` row,
     inheriting printer / project / makerworld metadata from the source
@@ -2882,6 +2926,7 @@ async def slice_and_persist_as_archive(
         model_bytes=model_bytes,
         model_filename=model_filename,
         request=archive_request,
+        job_id=job_id,
         current_user_id=current_user_id,
     )
 
@@ -3031,7 +3076,7 @@ async def slice_library_file(
     src_ext = Path(lib_file.filename).suffix.lower() or ".3mf"
     model_filename = f"{src_print_name}{src_ext}" if src_print_name else lib_file.filename
 
-    async def _run():
+    async def _run(job_id: int):
         async with async_session() as task_db:
             try:
                 response = await slice_and_persist(
@@ -3042,6 +3087,7 @@ async def slice_library_file(
                     extra_metadata={"sliced_from_library_file_id": source_lib_file_id},
                     request=request,
                     current_user_id=user_id,
+                    job_id=job_id,
                 )
             except HTTPException as exc:
                 raise http_exception_to_job_error(exc) from exc

+ 11 - 3
backend/app/api/routes/makerworld.py

@@ -14,6 +14,7 @@ from __future__ import annotations
 
 import logging
 import os
+from urllib.parse import unquote
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import Response
@@ -339,7 +340,12 @@ async def import_instance(
     # filename regardless (see library.py), so this is defence-in-depth.
     raw_name = manifest.get("name")
     if isinstance(raw_name, str) and raw_name.strip():
-        suggested_name = os.path.basename(raw_name.strip()) or f"makerworld-{body.model_id}.3mf"
+        # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
+        # because the same string round-trips through HTTP URLs in the
+        # CDN download path. Decode before persisting so the library
+        # row, the slice toast, and every later UI surface show the
+        # human-readable form.
+        suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
     else:
         suggested_name = f"makerworld-{body.model_id}.3mf"
     if not signed_url or not isinstance(signed_url, str):
@@ -369,8 +375,10 @@ async def import_instance(
         await service.close()
 
     # Prefer the server-provided human-readable filename; the signed URL's
-    # path ends in a UUID that's not meaningful to users.
-    filename = suggested_name if suggested_name.endswith(".3mf") else download_filename
+    # path ends in a UUID that's not meaningful to users. Decode the
+    # fallback path-tail too — same percent-encoding round-trip applies
+    # there as on the manifest-supplied name.
+    filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download_filename)
 
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,

+ 4 - 0
backend/app/api/routes/slice_jobs.py

@@ -35,6 +35,10 @@ async def get_slice_job(
         "created_at": job.created_at.isoformat(),
         "started_at": job.started_at.isoformat() if job.started_at else None,
         "completed_at": job.completed_at.isoformat() if job.completed_at else None,
+        # Live progress fed by the sidecar's --pipe channel. Null when
+        # the slicer hasn't emitted yet (early "Initializing" phase) or
+        # the sidecar doesn't support progress (older versions).
+        "progress": job.progress,
     }
     if job.status == "completed":
         body["result"] = job.result

+ 44 - 0
backend/app/api/routes/slicer_presets.py

@@ -359,3 +359,47 @@ async def list_unified_presets(
         standard=UnifiedPresetsBySlot(**standard),
         cloud_status=cloud_status,
     )
+
+
+@router.get("/preview-progress/{request_id}")
+async def get_preview_slice_progress(
+    request_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
+):
+    """Proxy to the sidecar's ``GET /slice/progress/:requestId``.
+
+    The SliceModal's filament-requirements call kicks off a real preview
+    slice on the sidecar to discover which AMS slots the picked plate
+    actually consumes. That HTTP call holds open for the full slice
+    duration (multi-second to multi-minute on complex models), and the
+    browser can't reach the sidecar directly thanks to the same-origin
+    policy + the sidecar's CORS allowlist. This endpoint forwards the
+    poll so the modal's inline spinner can show "Generating G-code (45%)"
+    instead of an opaque elapsed-time counter while the preview runs.
+
+    Returns the sidecar's snapshot verbatim, or 404 when the request_id
+    is unknown / completed and grace-window-expired.
+    """
+    import httpx
+
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        from fastapi import HTTPException
+
+        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
+    url = f"{api_url}/slice/progress/{request_id}"
+    try:
+        async with httpx.AsyncClient(timeout=5.0) as client:
+            response = await client.get(url)
+    except httpx.RequestError:
+        # Sidecar unreachable: surface as 503 instead of 500 so the
+        # frontend's poller can keep trying without flagging a hard error.
+        from fastapi import HTTPException
+
+        raise HTTPException(status_code=503, detail="Slicer sidecar unreachable") from None
+    if response.status_code == 404:
+        from fastapi import HTTPException
+
+        raise HTTPException(status_code=404, detail="Progress unavailable")
+    return response.json()

+ 26 - 6
backend/app/services/slice_dispatch.py

@@ -41,6 +41,13 @@ class SliceJob:
     # 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
@@ -62,13 +69,14 @@ class SliceDispatchService:
         kind: Literal["library_file", "archive"],
         source_id: int,
         source_name: str,
-        run: Callable[[], Awaitable[dict[str, Any]]],
+        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 performs the actual slice + save
-        and returns the response body the caller will receive once status
-        flips to ``completed``.
+        ``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(
@@ -88,12 +96,12 @@ class SliceDispatchService:
     async def _run_job(
         self,
         job: SliceJob,
-        run: Callable[[], Awaitable[dict[str, Any]]],
+        run: Callable[[int], Awaitable[dict[str, Any]]],
     ) -> None:
         job.started_at = datetime.now(timezone.utc)
         job.status = "running"
         try:
-            result = await run()
+            result = await run(job.id)
             job.result = result
             job.status = "completed"
         except _SliceJobError as exc:
@@ -113,6 +121,18 @@ class SliceDispatchService:
     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."""

+ 2 - 0
backend/app/services/slice_preview.py

@@ -59,6 +59,7 @@ async def get_preview_filaments(
     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.
@@ -90,6 +91,7 @@ async def get_preview_filaments(
                     model_filename=file_name,
                     plate=plate_id,
                     export_3mf=True,
+                    request_id=request_id,
                 )
         except SlicerApiError as e:
             logger.warning(

+ 102 - 0
backend/app/services/slicer_api.py

@@ -8,7 +8,9 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 `X-Print-Time-Seconds` / `X-Filament-Used-G` / `X-Filament-Used-Mm` headers).
 """
 
+import asyncio
 import logging
+from collections.abc import Callable
 from typing import NamedTuple
 
 import httpx
@@ -153,6 +155,46 @@ class SlicerApiService:
             raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
         return response.json()
 
+    async def _poll_progress(
+        self,
+        request_id: str,
+        on_progress: Callable[[dict], None],
+    ) -> None:
+        """Poll the sidecar's progress endpoint at ~1Hz and forward each
+        snapshot to ``on_progress``. Runs until cancelled.
+
+        4xx is NOT treated as terminal: the FIRST poll fires the moment
+        the slice POST is sent, which can be milliseconds before the
+        request actually lands on the sidecar and `progressStore.start()`
+        runs — so a fresh request legitimately returns 404 for the first
+        tick or two. Bailing on the first 404 (the original implementation)
+        meant we'd quit before progress could ever arrive. The polling
+        task is cancelled by the outer slice request anyway, so a
+        sustained 404 (older sidecar without progress support, or post-
+        slice grace expiry) just costs a few wasted GETs that the cancel
+        will stop. Network errors and non-JSON 5xx are swallowed; the
+        next tick retries.
+        """
+        url = f"{self.base_url}/slice/progress/{request_id}"
+        while True:
+            try:
+                response = await self._client.get(url, timeout=5.0)
+                if response.status_code == 200:
+                    payload = response.json()
+                    if isinstance(payload, dict):
+                        on_progress(payload)
+                # 404 / other 4xx = no progress available (yet, or ever
+                # for older sidecars). Keep polling — the outer slice
+                # request will cancel this task on completion.
+            except (httpx.RequestError, ValueError):
+                # ValueError covers JSONDecodeError when the sidecar
+                # returns a non-JSON 5xx. Don't crash the poller.
+                pass
+            try:
+                await asyncio.sleep(1.0)
+            except asyncio.CancelledError:
+                return
+
     async def slice_with_profiles(
         self,
         *,
@@ -163,6 +205,8 @@ class SlicerApiService:
         filament_profile_jsons: list[str],
         plate: int | None = None,
         export_3mf: bool = False,
+        request_id: str | None = None,
+        on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
         """POST /slice with model + printer/process/filament profiles.
 
@@ -173,6 +217,12 @@ class SlicerApiService:
         slicing service joins them as semicolon-separated
         ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
 
+        ``request_id``: when supplied, the sidecar wires --pipe to a
+        per-request FIFO and publishes structured JSON progress events to
+        its in-memory ProgressStore under this id. Bambuddy's slice
+        dispatch polls ``GET /slice/progress/{request_id}`` in parallel
+        to drive the live-progress toast.
+
         Raises:
             SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
             SlicerApiUnavailableError: connection error or 5xx from sidecar.
@@ -198,6 +248,20 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        if request_id is not None:
+            data["requestId"] = request_id
+
+        # When the caller supplied a request_id, kick off a parallel
+        # poller that reads the sidecar's --pipe-fed progress endpoint
+        # and surfaces structured updates via on_progress. Uses a
+        # short-tick poll (1s) since the slicer emits stage changes
+        # several times per minute on complex models.
+        progress_task: asyncio.Task | None = None
+        if request_id is not None and on_progress is not None:
+            progress_task = asyncio.create_task(
+                self._poll_progress(request_id, on_progress),
+                name=f"slicer-progress-{request_id}",
+            )
 
         try:
             response = await self._client.post(
@@ -208,6 +272,13 @@ class SlicerApiService:
             )
         except httpx.RequestError as exc:
             raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+        finally:
+            if progress_task is not None:
+                progress_task.cancel()
+                try:
+                    await progress_task
+                except (asyncio.CancelledError, Exception):
+                    pass  # Polling errors must not fail the slice.
 
         if response.status_code >= 500:
             raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
@@ -228,6 +299,8 @@ class SlicerApiService:
         model_filename: str,
         plate: int | None = None,
         export_3mf: bool = False,
+        request_id: str | None = None,
+        on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
         """POST /slice with only the model file and no profile triplet.
 
@@ -236,6 +309,14 @@ class SlicerApiService:
         `slice_with_profiles` triggers a CLI segfault or other 5xx —
         complex H2D / multi-extruder models hit upstream bugs in both the
         OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
+
+        Also used by the SliceModal's per-plate filament discovery path:
+        for an unsliced project file we run a real preview slice via the
+        sidecar to find which AMS slots the picked plate consumes. The
+        ``request_id`` parameter routes the sidecar's --pipe progress
+        events to the ProgressStore so the modal's inline spinner +
+        toast can show "Generating G-code (75%)" for that preview as
+        well.
         """
         files = {
             "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
@@ -245,6 +326,20 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        if request_id is not None:
+            data["requestId"] = request_id
+
+        # Same progress-poller wiring as slice_with_profiles. Used by the
+        # SliceModal's preview slice (for filament discovery) AND the
+        # embedded-settings fallback path triggered by an Orca/Bambu CLI
+        # segfault on complex H2D models — both want to keep updating
+        # the user's toast through the slow operation.
+        progress_task: asyncio.Task | None = None
+        if request_id is not None and on_progress is not None:
+            progress_task = asyncio.create_task(
+                self._poll_progress(request_id, on_progress),
+                name=f"slicer-progress-{request_id}",
+            )
 
         try:
             response = await self._client.post(
@@ -255,6 +350,13 @@ class SlicerApiService:
             )
         except httpx.RequestError as exc:
             raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+        finally:
+            if progress_task is not None:
+                progress_task.cancel()
+                try:
+                    await progress_task
+                except (asyncio.CancelledError, Exception):
+                    pass
 
         if response.status_code >= 500:
             raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")

+ 109 - 0
backend/tests/unit/services/test_slice_dispatch_progress.py

@@ -0,0 +1,109 @@
+"""Tests for SliceDispatchService.set_progress.
+
+The dispatcher exposes set_progress so the slice-route's parallel poller
+(spawned alongside the blocking sidecar slice request) can publish
+``{stage, total_percent, plate_index, plate_count}`` snapshots that the
+status-poll endpoint surfaces to the UI's persistent progress toast.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from backend.app.services.slice_dispatch import SliceDispatchService
+
+
+@pytest.mark.asyncio
+async def test_set_progress_attaches_snapshot_to_running_job():
+    dispatcher = SliceDispatchService()
+
+    started = asyncio.Event()
+    release = asyncio.Event()
+
+    async def runner(job_id: int) -> dict:
+        started.set()
+        # Hold the job in the running state until the test releases it.
+        await release.wait()
+        return {"library_file_id": 1}
+
+    job = await dispatcher.enqueue(
+        kind="library_file",
+        source_id=1,
+        source_name="x.stl",
+        run=runner,
+    )
+    await started.wait()
+
+    # Without progress published yet, the job's progress is None.
+    assert dispatcher.get(job.id) is not None
+    assert dispatcher.get(job.id).progress is None
+
+    # First snapshot lands on the job.
+    dispatcher.set_progress(
+        job.id,
+        {"stage": "Detecting perimeters", "total_percent": 12},
+    )
+    snap = dispatcher.get(job.id).progress
+    assert snap == {"stage": "Detecting perimeters", "total_percent": 12}
+
+    # Second snapshot replaces, doesn't merge — the dispatcher just
+    # holds the latest frame; the sidecar's pipe protocol always emits
+    # the full set, so partial-frame merging would be wrong.
+    dispatcher.set_progress(
+        job.id,
+        {"stage": "Generating G-code", "total_percent": 75, "plate_index": 1},
+    )
+    snap = dispatcher.get(job.id).progress
+    assert snap == {
+        "stage": "Generating G-code",
+        "total_percent": 75,
+        "plate_index": 1,
+    }
+
+    # Release the runner so the job completes and the test cleans up.
+    release.set()
+    # Yield to the event loop so the runner's completion settles.
+    await asyncio.sleep(0)
+    await asyncio.sleep(0)
+
+
+@pytest.mark.asyncio
+async def test_set_progress_silently_ignores_unknown_job_id():
+    """A late poll after retention sweep mustn't crash the polling task."""
+    dispatcher = SliceDispatchService()
+    # Should be a no-op, not an exception.
+    dispatcher.set_progress(99999, {"stage": "x", "total_percent": 50})
+
+
+@pytest.mark.asyncio
+async def test_set_progress_can_clear_to_none():
+    """Allow clearing — useful when the slice transitions to a final
+    state and we want the toast to revert to the elapsed-time fallback
+    on subsequent polls."""
+    dispatcher = SliceDispatchService()
+    started = asyncio.Event()
+    release = asyncio.Event()
+
+    async def runner(job_id: int) -> dict:
+        started.set()
+        await release.wait()
+        return {"library_file_id": 1}
+
+    job = await dispatcher.enqueue(
+        kind="library_file",
+        source_id=1,
+        source_name="x.stl",
+        run=runner,
+    )
+    await started.wait()
+
+    dispatcher.set_progress(job.id, {"stage": "x", "total_percent": 50})
+    assert dispatcher.get(job.id).progress is not None
+    dispatcher.set_progress(job.id, None)
+    assert dispatcher.get(job.id).progress is None
+
+    release.set()
+    await asyncio.sleep(0)
+    await asyncio.sleep(0)

+ 154 - 0
backend/tests/unit/services/test_slicer_api.py

@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+import asyncio
+
 import httpx
 import pytest
 
@@ -325,3 +327,155 @@ class TestHealth:
         service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
         with pytest.raises(SlicerApiUnavailableError):
             await service.health()
+
+
+class TestSliceWithProfilesProgress:
+    """Live-progress wiring for slice_with_profiles.
+
+    When the caller supplies a ``request_id`` and an ``on_progress``
+    callback, the service forwards the id as a ``requestId`` form field
+    (the sidecar uses it to wire up `--pipe` per request) and spawns a
+    background poller that calls back into ``on_progress`` for each
+    snapshot the sidecar publishes. The poller is cancelled the moment
+    the slice POST returns.
+    """
+
+    @pytest.mark.asyncio
+    async def test_request_id_forwarded_as_form_field(self):
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path == "/slice":
+                captured["body"] = request.content
+                return httpx.Response(
+                    status_code=200,
+                    content=b"PK\x03\x04 fake",
+                    headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+                )
+            # /slice/progress/<id> — return 404 so the poller exits cleanly.
+            return httpx.Response(status_code=404, json={"error": "not_found"})
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.stl",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+            request_id="abc-123",
+            on_progress=lambda _snap: None,
+        )
+        # The form field name on the wire is `requestId` (camelCase) to
+        # match the sidecar's SlicingSettings shape.
+        body = captured["body"].decode("utf-8", errors="ignore")
+        assert "requestId" in body
+        assert "abc-123" in body
+
+    @pytest.mark.asyncio
+    async def test_on_progress_called_with_snapshots(self):
+        # Drive enough poller ticks for at least one progress 200 to land
+        # before the slice response unblocks the caller.
+        slice_release = asyncio.Event()
+        snapshots: list[dict] = []
+
+        async def slice_handler() -> httpx.Response:
+            # Hold the slice POST until the test signals release, mimicking
+            # a real long-running slice.
+            await slice_release.wait()
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04",
+                headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path == "/slice":
+                # MockTransport supports async handlers if we return a
+                # coroutine — but the simpler path is to drive completion
+                # via the captured event below.
+                pass
+            if request.url.path == "/slice/progress/req-1":
+                return httpx.Response(
+                    status_code=200,
+                    json={
+                        "stage": "Generating G-code",
+                        "total_percent": 75,
+                        "plate_percent": 80,
+                        "plate_index": 1,
+                        "plate_count": 1,
+                        "updated_at": 0,
+                    },
+                )
+            return httpx.Response(404)
+
+        # Use an async handler so the slice POST blocks until released.
+        async def async_handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path == "/slice":
+                return await slice_handler()
+            return handler(request)
+
+        client = httpx.AsyncClient(transport=httpx.MockTransport(async_handler))
+        service = SlicerApiService("http://sidecar:3000", client=client)
+
+        # Run the slice with progress callback, releasing it after a beat.
+        async def release_after_first_snapshot():
+            # Wait until the poller has published at least one snapshot
+            # via the on_progress callback, then unblock the slice POST.
+            for _ in range(60):
+                if snapshots:
+                    break
+                await asyncio.sleep(0.05)
+            slice_release.set()
+
+        release_task = asyncio.create_task(release_after_first_snapshot())
+        try:
+            await service.slice_with_profiles(
+                model_bytes=b"x",
+                model_filename="Cube.stl",
+                printer_profile_json="{}",
+                process_profile_json="{}",
+                filament_profile_jsons=["{}"],
+                request_id="req-1",
+                on_progress=lambda snap: snapshots.append(snap),
+            )
+        finally:
+            release_task.cancel()
+            await asyncio.gather(release_task, return_exceptions=True)
+            await client.aclose()
+
+        assert snapshots, "on_progress was never called"
+        first = snapshots[0]
+        assert first["stage"] == "Generating G-code"
+        assert first["total_percent"] == 75
+
+    @pytest.mark.asyncio
+    async def test_progress_404_does_not_crash_or_stop_polling(self):
+        """A 404 from /slice/progress/:id is expected during the early
+        race window (POST fired before sidecar's progressStore.start()
+        ran) and from older sidecars without progress support. Neither
+        should crash the slice or block the response — the poller just
+        keeps trying until the outer cancel fires."""
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path == "/slice":
+                return httpx.Response(
+                    status_code=200,
+                    content=b"PK\x03\x04",
+                    headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+                )
+            return httpx.Response(status_code=404, json={"error": "not_found"})
+
+        snapshots: list[dict] = []
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        result = await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.stl",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+            request_id="legacy-sidecar",
+            on_progress=lambda snap: snapshots.append(snap),
+        )
+        assert result is not None
+        # Sustained 404 → no snapshots ever forwarded.
+        assert snapshots == []

+ 121 - 0
frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx

@@ -261,4 +261,125 @@ describe('SliceJobTrackerProvider — persistent progress toast', () => {
     // complaint that motivated `_format_sidecar_error` on the backend.
     expect(screen.getByText(/sidecar segfault/)).toBeDefined();
   });
+
+  // The backend now exposes a `progress` field on each slice-job poll
+  // result, fed by the sidecar's --pipe channel. When a useful frame
+  // is present the toast must show "{name} — {stage} ({percent}%) —
+  // {elapsed}" so the user sees concrete progress instead of a wall of
+  // elapsed time.
+  it('weaves stage + percent into the toast when the sidecar reports progress', async () => {
+    let pollCount = 0;
+    mockApi.getSliceJob.mockImplementation(async () => {
+      pollCount += 1;
+      // First poll: running with a useful progress frame.
+      if (pollCount === 1) {
+        return {
+          job_id: 7,
+          status: 'running',
+          kind: 'library_file',
+          source_id: 200,
+          source_name: 'Helmet.3mf',
+          created_at: new Date().toISOString(),
+          started_at: new Date().toISOString(),
+          completed_at: null,
+          progress: {
+            stage: 'Generating G-code',
+            total_percent: 75,
+            plate_percent: 80,
+            plate_index: 1,
+            plate_count: 1,
+            updated_at: Date.now(),
+          },
+        };
+      }
+      // Subsequent polls keep the same frame so the test loop stays stable.
+      return {
+        job_id: 7,
+        status: 'running',
+        kind: 'library_file',
+        source_id: 200,
+        source_name: 'Helmet.3mf',
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: null,
+        progress: {
+          stage: 'Generating G-code',
+          total_percent: 75,
+          plate_percent: 80,
+          plate_index: 1,
+          plate_count: 1,
+          updated_at: Date.now(),
+        },
+      };
+    });
+
+    render(
+      <Wrapper>
+        <TrackTrigger id={7} name="Helmet.3mf" />
+      </Wrapper>,
+    );
+
+    act(() => {
+      screen.getByText('track-7').click();
+    });
+
+    // Drive the 1.5s poll so the progress frame lands in the ref, then
+    // tick the 1s renderer so the toast picks it up.
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    await act(async () => {
+      await Promise.resolve();
+    });
+    act(() => {
+      vi.advanceTimersByTime(1000);
+    });
+
+    // Toast contains the stage + percent + filename.
+    const text = screen.getByText(/Generating G-code/);
+    expect(text.textContent).toMatch(/Helmet\.3mf/);
+    expect(text.textContent).toMatch(/75%/);
+  });
+
+  it('falls back to elapsed-time message when progress is null', async () => {
+    // Sidecar without --pipe support / pre-progress feature: state.progress
+    // stays null and the toast shows the existing "Slicing X — 47s" text.
+    mockApi.getSliceJob.mockResolvedValue({
+      job_id: 8,
+      status: 'running',
+      kind: 'library_file',
+      source_id: 201,
+      source_name: 'OldSidecar.3mf',
+      created_at: new Date().toISOString(),
+      started_at: new Date().toISOString(),
+      completed_at: null,
+      progress: null,
+    });
+
+    render(
+      <Wrapper>
+        <TrackTrigger id={8} name="OldSidecar.3mf" />
+      </Wrapper>,
+    );
+
+    act(() => {
+      screen.getByText('track-8').click();
+    });
+
+    await act(async () => {
+      vi.advanceTimersByTime(1500);
+    });
+    await act(async () => {
+      await Promise.resolve();
+    });
+    act(() => {
+      vi.advanceTimersByTime(1000);
+    });
+
+    // The "Slicing X — Ns" / "Queued: X — Ns" fallback must still render
+    // — the absence of progress mustn't blank the toast.
+    expect(screen.getByText(/OldSidecar\.3mf/)).toBeDefined();
+    // No progress percent is shown when null.
+    expect(screen.queryByText(/%/)).toBeNull();
+  });
 });

+ 45 - 6
frontend/src/api/client.ts

@@ -1192,6 +1192,17 @@ export interface SliceJobEnqueueResponse {
   status_url: string;
 }
 
+export interface SliceJobProgress {
+  /** Stage label emitted by the slicer ("Generating G-code", "Slicing finished"). */
+  stage: string;
+  total_percent: number;
+  plate_percent: number;
+  /** 1-indexed plate position; 0 means "all plates" / final completion. */
+  plate_index: number;
+  plate_count: number;
+  updated_at: number;
+}
+
 export interface SliceJobState {
   job_id: number;
   status: SliceJobStatus;
@@ -1201,6 +1212,10 @@ export interface SliceJobState {
   created_at: string;
   started_at: string | null;
   completed_at: string | null;
+  /** Live progress fed by the sidecar's --pipe channel; null until the
+   * slicer emits its first frame (early "Initializing" phase) or when
+   * the sidecar doesn't support progress. */
+  progress: SliceJobProgress | null;
   result?: SliceResponse | SliceArchiveResponse;
   error_status?: number;
   error_detail?: string;
@@ -3657,8 +3672,11 @@ export const api = {
   },
   getArchivePlates: (archiveId: number) =>
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
-  getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
-    request<{
+  getArchiveFilamentRequirements: (archiveId: number, plateId?: number, requestId?: string) => {
+    const qs = new URLSearchParams();
+    if (plateId !== undefined) qs.set('plate_id', String(plateId));
+    if (requestId) qs.set('request_id', requestId);
+    return request<{
       archive_id: number;
       filename: string;
       plate_id: number | null;
@@ -3668,8 +3686,10 @@ export const api = {
         color: string;
         used_grams: number;
         used_meters: number;
+        used_in_plate?: boolean;
       }>;
-    }>(`/archives/${archiveId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
+    }>(`/archives/${archiveId}/filament-requirements${qs.toString() ? `?${qs}` : ''}`);
+  },
   reprintArchive: (
     archiveId: number,
     printerId: number,
@@ -4918,8 +4938,11 @@ export const api = {
     }),
   getLibraryFilePlates: (fileId: number) =>
     request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
-  getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
-    request<{
+  getLibraryFileFilamentRequirements: (fileId: number, plateId?: number, requestId?: string) => {
+    const qs = new URLSearchParams();
+    if (plateId !== undefined) qs.set('plate_id', String(plateId));
+    if (requestId) qs.set('request_id', requestId);
+    return request<{
       file_id: number;
       filename: string;
       filaments: Array<{
@@ -4928,8 +4951,24 @@ export const api = {
         color: string;
         used_grams: number;
         used_meters: number;
+        used_in_plate?: boolean;
       }>;
-    }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
+    }>(`/library/files/${fileId}/filament-requirements${qs.toString() ? `?${qs}` : ''}`);
+  },
+
+  /** Poll the sidecar's per-request progress snapshot via the Bambuddy
+   * proxy. Used by the SliceModal's filament-discovery path so the inline
+   * spinner + persistent toast can show "Generating G-code (45%)" while
+   * the preview slice runs. Returns null on 404 (sidecar doesn't yet
+   * have an entry — early race window — or it expired) so the poller
+   * can keep trying. */
+  getPreviewSliceProgress: async (requestId: string): Promise<SliceJobProgress | null> => {
+    try {
+      return await request<SliceJobProgress>(`/slicer/preview-progress/${encodeURIComponent(requestId)}`);
+    } catch {
+      return null;
+    }
+  },
 
   // GitHub Backup
   getGitHubBackupConfig: () =>

+ 118 - 7
frontend/src/components/SliceModal.tsx

@@ -6,12 +6,14 @@ import {
   api,
   type PresetRef,
   type PresetSource,
+  type SliceJobProgress,
   type SlicerCloudStatus,
   type UnifiedPreset,
   type UnifiedPresetsBySlot,
   type UnifiedPresetsResponse,
 } from '../api/client';
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
+import { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
 import type { PlateFilament } from '../types/plates';
 import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
@@ -108,22 +110,105 @@ function fromRefValue(raw: string): PresetRef | null {
 // on a complex multi-color model that's a real slice — multi-second to
 // multi-minute. The static "Analyzing plate filaments…" string left
 // users wondering whether anything was happening, so the spinner now
-// shows elapsed seconds and a hint that explains the wait. After ~5s it
-// also surfaces a "this is a one-time slice — repeat opens are instant"
+// shows elapsed seconds, polls the sidecar's --pipe progress (via the
+// /slicer/preview-progress proxy) for live stage + percent, and after ~5s
+// surfaces a "this is a one-time slice — repeat opens are instant"
 // note so users don't worry it'll be slow forever.
-function FilamentAnalysisSpinner() {
+//
+// requestId: a UUID generated by the modal when the filament-requirements
+// fetch starts. Forwarded to the sidecar via the API call AND used here
+// to poll the matching progress snapshot. Same id, two consumers.
+function FilamentAnalysisSpinner({
+  requestId,
+  sourceName,
+}: {
+  requestId: string;
+  sourceName: string;
+}) {
   const { t } = useTranslation();
+  const { showPersistentToast, dismissToast } = useToast();
   const [elapsed, setElapsed] = useState(0);
+  const [progress, setProgress] = useState<SliceJobProgress | null>(null);
+  // Defensive decode — see prettifyFilename comment in SliceJobTrackerContext.
+  let prettyName = sourceName;
+  try {
+    prettyName = decodeURIComponent(sourceName);
+  } catch {
+    /* keep raw on malformed encoding */
+  }
+
+  // Elapsed-time tick.
   useEffect(() => {
     const startedAt = Date.now();
     const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
     return () => clearInterval(id);
   }, []);
+
+  // Progress polling — once per second while the spinner is mounted.
+  // Mirrors the slice-job tracker's cadence. Sidecar 404s during the
+  // race window between fetch start and progressStore.start() are
+  // swallowed by the API method (returns null) so we keep polling.
+  useEffect(() => {
+    let cancelled = false;
+    const id = setInterval(async () => {
+      if (cancelled) return;
+      const snap = await api.getPreviewSliceProgress(requestId);
+      if (!cancelled && snap) setProgress(snap);
+    }, 1000);
+    return () => {
+      cancelled = true;
+      clearInterval(id);
+    };
+  }, [requestId]);
+
+  // Mirror the spinner's contents into a persistent toast so the user
+  // sees activity even when their cursor is elsewhere on the page.
+  // Dismissed in the parent's effect when the requirements arrive.
+  const toastId = `slice-preview-${requestId}`;
+  useEffect(() => {
+    const hasUseful = progress && progress.stage && progress.total_percent > 0;
+    const elapsedStr = formatElapsed(elapsed);
+    if (hasUseful) {
+      showPersistentToast(
+        toastId,
+        t(
+          'slice.previewWithProgress',
+          'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+          {
+            name: prettyName,
+            stage: progress!.stage,
+            percent: Math.min(100, Math.max(0, Math.round(progress!.total_percent))),
+            elapsed: elapsedStr,
+          },
+        ),
+        'loading',
+      );
+    } else {
+      showPersistentToast(
+        toastId,
+        t('slice.previewToast', 'Analyzing {{name}} — {{elapsed}}', {
+          name: prettyName,
+          elapsed: elapsedStr,
+        }),
+        'loading',
+      );
+    }
+    return () => {
+      dismissToast(toastId);
+    };
+  }, [elapsed, progress, sourceName, showPersistentToast, dismissToast, t, toastId]);
+
+  const stage = progress?.stage;
+  const percent = progress?.total_percent;
+  const inlineLabel =
+    stage && typeof percent === 'number' && percent > 0
+      ? `${stage} (${Math.min(100, Math.max(0, Math.round(percent)))}%)`
+      : t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…');
   return (
     <div className="flex flex-col gap-1 text-bambu-gray text-sm py-2">
       <div className="flex items-center gap-2">
         <Loader2 className="w-4 h-4 animate-spin" />
-        {t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…')}
+        {inlineLabel}
         <span className="text-xs tabular-nums">{elapsed}s</span>
       </div>
       {elapsed >= 5 && (
@@ -138,6 +223,17 @@ function FilamentAnalysisSpinner() {
   );
 }
 
+function formatElapsed(seconds: number): string {
+  const s = Math.max(0, Math.floor(seconds));
+  if (s < 60) return `${s}s`;
+  const m = Math.floor(s / 60);
+  const remS = s % 60;
+  if (m < 60) return `${m}m ${remS}s`;
+  const h = Math.floor(m / 60);
+  const remM = m % 60;
+  return `${h}h ${remM}m`;
+}
+
 export function SliceModal({ source, onClose }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
@@ -179,13 +275,25 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // extraction. plate_id is always sent: single-plate falls through to plate
   // 1 server-side; multi-plate uses the user's pick.
   const effectivePlateId = selectedPlate ?? 1;
+  // Generate a request_id per (source, plate) pair so the backend's
+  // preview-slice and the FilamentAnalysisSpinner's progress poll share
+  // the same id. useMemo keeps it stable across renders within the same
+  // pair; switching plates regenerates so a stale poll doesn't bleed
+  // progress between plates.
+  const previewRequestId = useMemo(() => {
+    const id =
+      typeof crypto !== 'undefined' && 'randomUUID' in crypto
+        ? crypto.randomUUID()
+        : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+    return id;
+  }, [source.kind, source.id, effectivePlateId]);
   const filamentReqsQuery = useQuery({
     queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
     queryFn: async () => {
       if (source.kind === 'libraryFile') {
-        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId);
+        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId);
       }
-      return api.getArchiveFilamentRequirements(source.id, effectivePlateId);
+      return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId);
     },
     enabled: !needsPlatePicker,
     staleTime: 60_000,
@@ -406,7 +514,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   scoped spinner so the user sees the printer/process
                   dropdowns instead of an opaque "Loading presets…" wait. */}
               {filamentReqsQuery.isLoading ? (
-                <FilamentAnalysisSpinner />
+                <FilamentAnalysisSpinner
+                  requestId={previewRequestId}
+                  sourceName={source.filename}
+                />
               ) : (
                 filamentSlots.map((slot, idx) => {
                   // Slots flagged by the backend as not used by the

+ 57 - 6
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -15,7 +15,7 @@
 import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQueryClient } from '@tanstack/react-query';
-import { api, type SliceJobState, type SliceJobStatus } from '../api/client';
+import { api, type SliceJobProgress, type SliceJobState, type SliceJobStatus } from '../api/client';
 import { useToast } from './ToastContext';
 
 interface TrackedJob {
@@ -36,6 +36,21 @@ const TICK_INTERVAL_MS = 1000;
 
 const toastIdFor = (jobId: number) => `slice-job-${jobId}`;
 
+/** Decode percent-encoded characters in a filename so the toast doesn't
+ * show `stormtrooper-helmet%20h2d.3mf` for files that came from a source
+ * with URL-encoded names (MakerWorld API, S3 path tails, etc.). The
+ * MakerWorld import path now decodes at persist time, but already-imported
+ * rows still carry the encoded form — this is a belt-and-suspenders
+ * decode at display time so old rows look right too. Wrapped in try/catch
+ * because malformed encodings (`%XY` where XY isn't hex) throw URIError. */
+function prettifyFilename(name: string): string {
+  try {
+    return decodeURIComponent(name);
+  } catch {
+    return name;
+  }
+}
+
 function formatElapsed(seconds: number): string {
   const s = Math.max(0, Math.floor(seconds));
   if (s < 60) return `${s}s`;
@@ -58,10 +73,12 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const activeJobsRef = useRef<TrackedJob[]>([]);
   activeJobsRef.current = activeJobs;
 
-  // Per-job start time + latest phase, kept in refs so the 1s tick
-  // doesn't need to re-render on every update. Keyed by job id.
+  // Per-job start time, latest phase, and latest progress snapshot,
+  // kept in refs so the 1s tick doesn't need to re-render on every
+  // update. Keyed by job id.
   const startedAtRef = useRef<Map<number, number>>(new Map());
   const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
+  const progressRef = useRef<Map<number, SliceJobProgress | null>>(new Map());
 
   const renderProgressToast = useCallback(
     (job: TrackedJob) => {
@@ -69,6 +86,33 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
       if (startedAt == null) return;
       const elapsedSecs = (Date.now() - startedAt) / 1000;
       const phase = phaseRef.current.get(job.id) ?? 'pending';
+      const elapsedStr = formatElapsed(elapsedSecs);
+      const progress = progressRef.current.get(job.id) ?? null;
+      // When the sidecar has emitted at least one progress frame, weave
+      // the stage label + percent into the toast — that's what makes the
+      // wait feel professional ("Generating G-code 75%" beats "Slicing X
+      // — 47s"). Falls back to the elapsed-time-only message in three
+      // cases: queued/pending phase before the slicer has started,
+      // missing or zero progress (Initializing), or sidecar without
+      // --pipe support.
+      const hasUseful = progress && progress.stage && progress.total_percent > 0;
+      if (phase === 'running' && hasUseful) {
+        showPersistentToast(
+          toastIdFor(job.id),
+          t(
+            'slice.runningWithProgress',
+            '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+            {
+              name: prettifyFilename(job.sourceName),
+              stage: progress.stage,
+              percent: Math.min(100, Math.max(0, Math.round(progress.total_percent))),
+              elapsed: elapsedStr,
+            },
+          ),
+          'loading',
+        );
+        return;
+      }
       const messageKey = phase === 'pending' ? 'slice.queuedToast' : 'slice.runningToast';
       const fallback =
         phase === 'pending'
@@ -76,7 +120,7 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
           : 'Slicing {{name}} — {{elapsed}}';
       showPersistentToast(
         toastIdFor(job.id),
-        t(messageKey, fallback, { name: job.sourceName, elapsed: formatElapsed(elapsedSecs) }),
+        t(messageKey, fallback, { name: prettifyFilename(job.sourceName), elapsed: elapsedStr }),
         'loading',
       );
     },
@@ -88,6 +132,7 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
       setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
       startedAtRef.current.set(id, Date.now());
       phaseRef.current.set(id, 'pending');
+      progressRef.current.set(id, null);
       // Render the initial frame immediately so the user sees the toast
       // before the first tick lands (~1s delay otherwise).
       renderProgressToast({ id, kind, sourceName });
@@ -100,6 +145,7 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
       setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
       startedAtRef.current.delete(job.id);
       phaseRef.current.delete(job.id);
+      progressRef.current.delete(job.id);
 
       // Replace the persistent progress toast with a transient
       // success/error toast (auto-dismisses after 3s, same as showToast).
@@ -112,12 +158,12 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
         // embedded-settings fallback as a normal path) and just added
         // noise — see the trailing yellow toast complaint, removed.
         showToast(
-          t('slice.completedToast', 'Sliced {{name}}', { name: job.sourceName }),
+          t('slice.completedToast', 'Sliced {{name}}', { name: prettifyFilename(job.sourceName) }),
           'success',
         );
       } else if (state.status === 'failed') {
         const detail = state.error_detail || t('slice.failed');
-        showToast(t('slice.failedToast', 'Slicing {{name}} failed: {{detail}}', { name: job.sourceName, detail }), 'error');
+        showToast(t('slice.failedToast', 'Slicing {{name}} failed: {{detail}}', { name: prettifyFilename(job.sourceName), detail }), 'error');
       }
 
       // Refresh whichever list owns the result. Both are cheap to invalidate.
@@ -139,6 +185,11 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
         try {
           const state = await api.getSliceJob(job.id);
           phaseRef.current.set(job.id, state.status);
+          // Capture the latest progress snapshot if the sidecar fed
+          // one through. The 1s tick re-renders the toast off this ref.
+          if (state.progress) {
+            progressRef.current.set(job.id, state.progress);
+          }
           if (state.status === 'completed' || state.status === 'failed') {
             completeJob(job, state);
           }

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -1830,6 +1830,7 @@ export default {
     embeddedOverlay: 'Eingebettetes Overlay',
     preferredSlicer: 'Bevorzugter Slicer',
     preferredSlicerDescription: 'Wähle die Slicer-Anwendung zum Öffnen von Dateien',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev haben bekannte CLI-Bugs, die das Slicen vieler von BambuStudio erstellter 3MF-Dateien blockieren — siehe Upstream-Issues #12426 (Segfault bei bemalten Multi-Extruder-Dateien) und #13386 (zu strikte Parameter-Range-Validierung). Bis die Upstream-Fixes verfügbar sind, wird Bambu Studio empfohlen.',
     useSlicerApi: 'Slicer-API verwenden',
     useSlicerApiDescription: 'Wenn aktiv, öffnen "Slice"-Aktionen das in-App Slicer-Modal und rufen den Slicer-API-Sidecar. Aus (Standard): Übergabe an den Desktop-Slicer per URI-Schema.',
     slicerCard: 'Slicer',
@@ -3255,6 +3256,8 @@ export default {
     loadingPresets: 'Profile werden geladen…',
     analyzingPlateFilaments: 'Plattenfilamente werden analysiert…',
     analyzingPlateFilamentsHint: 'Es wird ein Probeschnitt ausgeführt, um die belegten AMS-Slots dieser Platte zu ermitteln. Wird zwischengespeichert — erneutes Öffnen ist sofort.',
+    previewToast: '{{name}} wird analysiert — {{elapsed}}',
+    previewWithProgress: '{{name}} wird analysiert — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— wird von dieser Platte nicht verwendet',
     printerMismatch: 'Dieses 3MF wurde für {{source}} gesliced, du hast aber {{target}} ausgewählt. Der Slicer-CLI kann ein 3MF nicht für einen anderen Drucker neu slicen — öffne die Quelle in Bambu Studio, ändere den Drucker und exportiere neu.',
     noPresetsForSlot: 'Keine Profile verfügbar',
@@ -3266,6 +3269,7 @@ export default {
     startedToast: '{{name}} wird im Hintergrund gesliced…',
     queuedToast: 'Warteschlange: {{name}} — {{elapsed}}',
     runningToast: '{{name}} wird gesliced — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: '{{name}} wurde gesliced',
     failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -1833,6 +1833,7 @@ export default {
     embeddedOverlay: 'Embedded Overlay',
     preferredSlicer: 'Preferred Slicer',
     preferredSlicerDescription: 'Choose which slicer application to open files with',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
     useSlicerApi: 'Use Slicer API',
     useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
     slicerCard: 'Slicer',
@@ -3258,6 +3259,8 @@ export default {
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
     analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    previewToast: 'Analyzing {{name}} — {{elapsed}}',
+    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
@@ -3269,6 +3272,7 @@ export default {
     startedToast: 'Slicing {{name}} in the background…',
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -1779,6 +1779,7 @@ export default {
     embeddedOverlay: 'Superposition intégrée',
     preferredSlicer: 'Slicer préféré',
     preferredSlicerDescription: 'Application pour ouvrir les fichiers',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
     useSlicerApi: 'Use Slicer API',
     useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
     slicerCard: 'Slicer',
@@ -3177,6 +3178,8 @@ export default {
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
     analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    previewToast: 'Analyzing {{name}} — {{elapsed}}',
+    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
@@ -3188,6 +3191,7 @@ export default {
     startedToast: 'Slicing {{name}} in the background…',
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -1779,6 +1779,7 @@ export default {
     embeddedOverlay: 'Overlay incorporato',
     preferredSlicer: 'Slicer preferito',
     preferredSlicerDescription: 'Scegli quale applicazione slicer usare per aprire i file',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
     useSlicerApi: 'Use Slicer API',
     useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
     slicerCard: 'Slicer',
@@ -3176,6 +3177,8 @@ export default {
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
     analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    previewToast: 'Analyzing {{name}} — {{elapsed}}',
+    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
@@ -3187,6 +3190,7 @@ export default {
     startedToast: 'Slicing {{name}} in the background…',
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -1804,6 +1804,7 @@ export default {
     embeddedOverlay: '埋め込みオーバーレイ',
     preferredSlicer: '優先スライサー',
     preferredSlicerDescription: 'ファイルを開くスライサーアプリケーションを選択',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
     useSlicerApi: 'Use Slicer API',
     useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
     slicerCard: 'Slicer',
@@ -3215,6 +3216,8 @@ export default {
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
     analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    previewToast: 'Analyzing {{name}} — {{elapsed}}',
+    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
@@ -3226,6 +3229,7 @@ export default {
     startedToast: 'Slicing {{name}} in the background…',
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1779,6 +1779,7 @@ export default {
     embeddedOverlay: 'Sobreposição Incorporada',
     preferredSlicer: 'Fatiador Preferido',
     preferredSlicerDescription: 'Escolha qual aplicativo de fatiamento abrirá os arquivos',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
     useSlicerApi: 'Use Slicer API',
     useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
     slicerCard: 'Slicer',
@@ -3190,6 +3191,8 @@ export default {
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
     analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    previewToast: 'Analyzing {{name}} — {{elapsed}}',
+    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
@@ -3201,6 +3204,7 @@ export default {
     startedToast: 'Slicing {{name}} in the background…',
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1831,6 +1831,7 @@ export default {
     embeddedOverlay: '嵌入式叠加层',
     preferredSlicer: '首选切片软件',
     preferredSlicerDescription: '选择要用于打开文件的切片软件',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
     useSlicerApi: 'Use Slicer API',
     useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
     slicerCard: 'Slicer',
@@ -3242,6 +3243,8 @@ export default {
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
     analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    previewToast: 'Analyzing {{name}} — {{elapsed}}',
+    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
@@ -3253,6 +3256,7 @@ export default {
     startedToast: 'Slicing {{name}} in the background…',
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1831,6 +1831,7 @@ export default {
     embeddedOverlay: '嵌入式疊加層',
     preferredSlicer: '首選切片軟體',
     preferredSlicerDescription: '選擇要用於開啟檔案的切片軟體',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
     useSlicerApi: 'Use Slicer API',
     useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
     slicerCard: 'Slicer',
@@ -3242,6 +3243,8 @@ export default {
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',
     analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
+    previewToast: 'Analyzing {{name}} — {{elapsed}}',
+    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
     noPresetsForSlot: 'No presets available',
@@ -3253,6 +3256,7 @@ export default {
     startedToast: 'Slicing {{name}} in the background…',
     queuedToast: 'Queued: {{name}} — {{elapsed}}',
     runningToast: 'Slicing {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: 'Sliced {{name}}',
     failedToast: 'Slicing {{name}} failed: {{detail}}',
     tier: {

+ 19 - 0
frontend/src/pages/SettingsPage.tsx

@@ -4100,6 +4100,25 @@ export function SettingsPage() {
                 <p className="text-xs text-bambu-gray mt-1">
                   {t('settings.preferredSlicerDescription')}
                 </p>
+                {/* Upstream OrcaSlicer 2.3.2 / 2.4.0-dev have two known
+                    CLI bugs that block slicing many Bambu-authored 3MFs:
+                    a SIGSEGV on painted multi-extruder 3MFs (#12426) and
+                    a strict range-check on sentinel parameter values
+                    BambuStudio writes by default. Until the upstream
+                    fixes land, surface a clear warning when a user has
+                    OrcaSlicer selected so they know what to expect; we
+                    don't auto-switch them in case they're testing. */}
+                {(localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer' && (
+                  <div
+                    role="alert"
+                    className="text-xs text-amber-200 bg-amber-900/20 border border-amber-700/40 rounded p-2 mt-2"
+                  >
+                    {t(
+                      'settings.orcaslicerKnownIssuesWarning',
+                      'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
+                    )}
+                  </div>
+                )}
               </div>
               <div className="flex items-center justify-between gap-3">
                 <div className="min-w-0">

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Bbpbjxtl.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DRnoASko.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DrRF4CKf.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B6Qs-684.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Bbpbjxtl.css">
+    <script type="module" crossorigin src="/assets/index-DRnoASko.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DrRF4CKf.css">
   </head>
   <body>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio