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.
This commit is contained in:
maziggy
2026-04-28 16:36:49 +02:00
parent 3fa0b621dd
commit d5153f1de3
28 changed files with 1347 additions and 473 deletions
+6
View File
@@ -55,6 +55,12 @@ All notable changes to Bambuddy will be documented in this file.
- **"Analyzing plate filaments…" spinner gave no signal that anything was happening on the first Slice-modal open for an unsliced project file** — On a multi-color 3MF without slice_info data, the backend runs a preview slice via the sidecar to discover which AMS slots the picked plate actually consumes. That's the only source of truth: tried two heuristics — painted-face quadtree scan (silently missed extruders when `object_id` mapping between `model_settings.config` and `3D/3dmodel.model` diverged, surfaced as a single dropdown for a 4-color print) and project-wide AMS list (over-rendered every plate to the project's full slot count) — and both produced wrong counts on real-world multi-color projects. Reverted to preview-slice-as-source-of-truth. The result is cached per `(kind, source_id, plate_id, content_hash)` so re-opens of the same plate are instant, but the first open on a complex model is a real slice (multi-second to multi-minute). The inline spinner now shows elapsed seconds and, after 5s, a hint explaining that this is a one-time preview slice and re-opens will be instant — addresses the original "is anything happening?" complaint without sacrificing correctness. Project-wide `extract_project_filaments_from_3mf` remains as a final fallback when the sidecar isn't configured. New i18n key `slice.analyzingPlateFilamentsHint` localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
- **Settings warning when OrcaSlicer is selected as the preferred slicer** — OrcaSlicer 2.3.2 and 2.4.0-dev (latest nightly as of 2026-04-28) have two upstream CLI bugs that together block slicing on most Bambu-authored multi-color / H2D 3MFs: (1) a SIGSEGV in the multi-extruder filament-resolution path on painted 3MFs ([OrcaSlicer/OrcaSlicer#12426](https://github.com/OrcaSlicer/OrcaSlicer/issues/12426)), and (2) the CLI strict-validates parameter values that BambuStudio writes by default — `solid_infill_filament: 0`, `tree_support_wall_count: -1`, `prime_tower_brim_width: -1` — and exits 238 with `Param values in 3mf/config error: ... not in range`, even though OrcaSlicer's own GUI tolerates these ([OrcaSlicer/OrcaSlicer#13386](https://github.com/OrcaSlicer/OrcaSlicer/issues/13386), filed alongside this change with a minimal repro 3MF). Both bugs verified reproducible on the latest nightly build before filing. Settings → Workflow → Slicer card now renders an inline amber alert under the preferred-slicer dropdown when `orcaslicer` is the current selection, linking out to both upstream issues and recommending Bambu Studio until upstream fixes land. The OrcaSlicer option is intentionally left pickable rather than disabled — users who only slice STLs or single-color 3MFs aren't affected by either bug, and forcibly disabling would also affect them. Localised across all 8 UI languages (English + German fully translated).
- **Live progress for the SliceModal's filament-analysis preview slice + URL-decoded filenames in the toast** — Two follow-ups to the live slicer-progress feature: (1) the modal's "Analyzing plate filaments…" preview slice (the real slice that fires before profile picking, to discover which AMS slots an unsliced plate consumes) now shows the same stage + percent live updates as the user-initiated slice. The frontend generates a per-(source, plate) request_id, forwards it via a new `request_id` query param on `/library/files/.../filament-requirements` and `/archives/.../filament-requirements`, the backend plumbs it through `slice_without_profiles` to the sidecar, and a new `GET /api/v1/slicer/preview-progress/{request_id}` proxy endpoint forwards browser polls to the sidecar's `/slice/progress/:requestId` (CORS-safe — the browser can't reach the sidecar directly). The inline spinner and a new persistent toast both render `Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}` while the preview runs; toast dismisses when filaments arrive. (2) MakerWorld imports were persisting URL-encoded filenames (`stormtrooper-helmet%20h2d.3mf`) verbatim because MakerWorld's API returns the same percent-encoding it uses on its CDN URLs. The import path now `urllib.parse.unquote`s both the manifest-supplied name and the URL path-tail fallback before passing to `save_3mf_bytes_to_library`, plus the frontend defensively `decodeURIComponent`s in the slice toast and analysis-spinner messages so already-imported rows display cleanly without a backfill migration. Falls back to the raw string on malformed encodings (`%XY` where `XY` isn't hex). New i18n keys `slice.previewToast` + `slice.previewWithProgress` localised across all 8 UI languages (English + German fully translated).
- **Live slicer progress in the persistent slice toast** — The persistent slice toast already showed elapsed time + a spinner so the user could see the slice was still running, but for long slices on complex multi-color models that "is anything happening?" gap could last minutes. Bambuddy now wires up the slicer CLI's structured progress channel end-to-end, so the toast renders concrete stage labels + live percent — `Stormtrooper.3mf — Generating G-code (75%) — 47s` — through the entire slice. **Sidecar (`bambuddy/profile-resolver` branch of orca-slicer-api)**: switched the sync `/slice` route from `execFile` to `spawn` so the process can run alongside an FIFO reader; on each request the route generates (or accepts a caller-supplied) `requestId`, `mkfifo`s `${workdir}/progress.fifo`, passes `--pipe ${fifo}` to the OrcaSlicer / BambuStudio CLI, and reads the structured JSON-line progress events the slicer emits (`{"message":"Generating G-code","plate_count":1,"plate_index":1,"plate_percent":80,"total_percent":75}`) into a per-process `ProgressStore` keyed by `requestId`. New `GET /slice/progress/:requestId` returns the latest snapshot; entries linger 30s after slice completion so the caller's last poll still reads the terminal "All done, Success" frame instead of a 404. Both slicer forks share the same code lineage from PrusaSlicer's `BackgroundSlicingProcess`, so OrcaSlicer 2.3.2 and BambuStudio 02.06.00.51 emit identical JSON keys (verified by tracing the binary). **Bambuddy backend**: `slicer_api.slice_with_profiles` accepts `request_id` + `on_progress` callback and spawns a 1Hz parallel poller that hits the sidecar's progress endpoint while the blocking POST is in flight; `SliceDispatchService` gained a `set_progress(job_id, snapshot)` method and a `progress` field on `SliceJob`; the slice routes now generate a uuid `request_id` and wire a callback that forwards each snapshot onto the dispatcher. `GET /slice-jobs/:id` includes `progress` on every poll. **Frontend**: `SliceJobTrackerContext` reads the new `progress` field and re-renders the persistent toast with `{name} — {stage} ({percent}%) — {elapsed}` whenever a useful frame is present, falling back to the existing elapsed-time-only message when the sidecar hasn't emitted anything yet (early "Initializing" phase) or doesn't support progress (older sidecars without the FIFO wiring). 12 sidecar unit tests for the JSON-line parser + ProgressStore (cancellation/grace-window, malformed lines, missing fields), 3 dispatcher tests for `set_progress` (attach/replace/clear, unknown-job-id silent ignore), 3 slicer_api tests for the form-field forwarding + on_progress callback wire-up + 404 short-circuit, 2 frontend SliceJobTracker tests pinning the new toast format and the no-progress fallback. New i18n key `slice.runningWithProgress` localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Graceful when the sidecar lacks `--pipe` support (tested live: OrcaSlicer 2.3.2 + BambuStudio 02.06.00.51 both work; older sidecars without the new endpoint return 404 and the toast cleanly degrades to elapsed-time-only).
- **No visual indicator while a slice job was running — users couldn't tell if a long slice was still progressing or had hung** — Previously SliceJobTrackerProvider emitted one transient toast on enqueue ("Slicing X in the background…") and one on completion ("Sliced X"), with nothing in between. For large multi-color models that take 30s–several minutes to slice, the start toast auto-dismissed after 3s and left a UX dead zone where users would ask "is it still slicing?". The tracker now opens a persistent `slice-job-{id}` toast with a spinner that updates every second showing elapsed time + phase ("Queued: X — 4s" → "Slicing X — 47s"), then is replaced by the existing transient success/error toast on terminal state. Polling cadence (1.5s) is unchanged — a separate 1Hz tick re-renders just the elapsed-time counter so the toast stays smooth even if the backend is slow to respond. Time format compresses gracefully past 60s ("1m 5s") and 60m ("1h 12m"). 4 new unit tests in `SliceJobTrackerContext.test.tsx` covering: persistent toast renders at t=0 (no wait for first tick), elapsed time updates each second while running, success completion replaces persistent with transient "Sliced X", failure replaces with transient error toast carrying the sidecar's `error_detail`. New i18n keys `slice.queuedToast` / `slice.runningToast` localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
- **MakerWorld URL-paste resolver listed plate instances without showing which printer each was sliced for** — MakerWorld's `/instances/hits` endpoint omits the per-instance compatibility info that lives on `design.instances[].extention.modelInfo` (`compatibility` = primary printer the instance was sliced for, `otherCompatibility` = additional printers the uploader marked it compatible with), so every instance row in the resolved-design preview looked identical and users blindly picked the first one regardless of whether it matched their printer — leading to "I downloaded the H2D version and got A1 g-code" complaints. The resolve route now joins both endpoint payloads by instance ID and forwards both fields onto each hit; the MakerWorld page renders "Sliced for {primaryPrinter}" + (when present) "Also marked compatible: ..." per instance row. Backend tests in `test_makerworld_routes.py::TestResolve` cover the merge happy path (compatibility lists land on the right hits) and the "missing modelInfo" fallback (older designs / hits without a matching design.instances entry don't crash the response, just lose the optional fields). New i18n keys `makerworld.slicedFor` / `makerworld.alsoCompatible` localised across all 8 UI languages.
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)}")
@@ -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)
@@ -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 == []
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>