mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user