fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard

Five follow-up fixes to cross-printer re-slicing, all surfaced while
  testing archive re-slices.

  1. Re-sliced archive now records the printer it was sliced FOR.
     slice_and_persist_as_archive copied sliced_for_model from the source
     archive, so re-slicing X1C->H2D still showed "X1C sliced". Read it
     from the freshly-sliced 3MF's parsed metadata instead, falling back
     to the source only when absent.

  2. Real slicer rejections are surfaced instead of silently masked.
     _run_slicer_with_fallback retried with the 3MF's embedded settings on
     any sidecar 5xx — including genuine content rejections (object off
     the bed, incompatible filament temps), which "succeeded" only by
     re-slicing for the source's original printer. A new
     _slicer_rejection_message detects the slicer's own error string and
     surfaces it as a 400; the embedded-settings fallback is kept only for
     true CLI crashes.

  3. A failed slice opens an error modal, not a 3s toast. The slicer's
     reason is actionable and a toast hides it before it can be read. New
     AlertModal (acknowledge-only); SliceJobTrackerContext shows it on a
     failed job. New slice.failedTitle key in all 9 locales.

  4. Sliced files no longer report "0 g" filament usage. The sidecar
     doesn't always populate the X-Filament-Used-* headers;
     ThreeMFParser._parse_gcode_header now also reads the slicer's own
     "total filament weight/length" from the G-code header, and both
     slice-persist paths fall back to it when the sidecar reports 0.

  5. Nozzle-class re-slice guard. Re-slicing across the single-nozzle <->
     dual-nozzle boundary (e.g. X1C -> H2D) fails BambuStudio's
     multi-extruder validation; both slice routes now reject it up front
     with a clear 400. The dual-nozzle model classification — previously
     an inline tuple duplicated across start_print and the K-profile
     routes — is centralized into DUAL_NOZZLE_MODELS / is_dual_nozzle_model
     in printer_models.py, consumed by all three sites and the guard.

  Full cross-nozzle-class re-slicing (dual-nozzle project_settings
  reconciliation) remains separately tracked.

  Tests: _slicer_rejection_message, _canonical_printer_model,
  guard_nozzle_class_reslice, is_dual_nozzle_model, the G-code-header
  filament parse, AlertModal, and end-to-end slice-API coverage including
  an X1C-archive-to-H2D 400. Backend ruff + i18n parity clean; frontend
  build clean.
This commit is contained in:
maziggy
2026-05-22 12:34:08 +02:00
parent 16da533c9a
commit 4925b4c830
26 changed files with 871 additions and 141 deletions
+5
View File
@@ -20,6 +20,11 @@ All notable changes to Bambuddy will be documented in this file.
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
### Fixed
- **Re-slicing across the single-nozzle / dual-nozzle boundary now fails fast with a clear message** — Re-slicing a model that was sliced for a single-nozzle printer (X1C, P1S, A1, …) onto a dual-nozzle printer (H2D / H2D Pro), or vice versa, produced a cryptic BambuStudio rejection — *"temperature difference of the filaments used is too large"* or *"G-code in unprintable area of multi-extruder printers"* — and an attempt at automatically converting the embedded single-nozzle layout segfaults the slicer CLI. Full cross-nozzle-class re-slicing isn't supported yet (it needs a proper dual-nozzle `project_settings` reconciliation — tracked separately). Until then, both slice routes (`POST /archives/{id}/slice`, `POST /library/files/{id}/slice`) now detect a nozzle-class mismatch up front and reject it synchronously with a `400` and a plain explanation, instead of letting the user wait for a confusing slicer failure. The guard is fail-open — it only fires when both the source's `sliced_for_model` and the target printer resolve to known models of *different* nozzle class; an un-sliced source (first-time slice) or an undeterminable target is never blocked. As part of this, the dual-nozzle model classification — previously an inline `("H2D", "H2D PRO", …)` tuple **duplicated across three call sites** (`start_print`, the two K-profile routes) — is centralized into `DUAL_NOZZLE_MODELS` + `is_dual_nozzle_model()` in `printer_models.py`, and all three sites plus the new guard now consume that single source of truth. **Tests**: `is_dual_nozzle_model` (H2D/Pro/internal-codes dual, X1C/P1S/A1/H2S single, None safe); `_canonical_printer_model` (strips the `"# "` clone prefix and `" 0.4 nozzle"` suffix); `guard_nozzle_class_reslice` (blocks single→dual and dual→single, allows same-class, no-ops on an un-sliced source); and an end-to-end test that an X1C archive re-sliced for an H2D preset returns `400` before any job is enqueued. 305 slicer/MQTT/K-profile tests + 31 slice-API + the printer-model suite all green; backend ruff clean.
- **Sliced files no longer report "0 g" filament usage** — A slice result — and the re-sliced archive's card — showed `filament_used_g: 0` (and `0 mm`) even for a real multi-hour print, while the print time came through fine. Bambuddy reads filament totals from the slicer sidecar's `X-Filament-Used-G` / `X-Filament-Used-Mm` response headers, and some sidecar builds simply don't populate them. **Fix**: `ThreeMFParser._parse_gcode_header` now also reads the slicer's own totals — `; total filament weight [g] : …` and `; total filament length [mm] : …` — from the produced 3MF's G-code header (verified against a real sliced output: 126.26 g / 41661.4 mm extracted correctly). Both slice-persist paths (`slice_and_persist` for library files, `slice_and_persist_as_archive`) now fall back to those parsed totals when the sidecar header is 0, applying the corrected figure to the stored metadata, the archive's `filament_used_grams` column, and the slice response. The G-code-header read is a fallback only — `slice_info.config` still wins when it carries per-filament `used_g`. **Tests**: 2 new in `test_archive_service.py` (`_parse_gcode_header` extracts weight + length; absent header lines leave the keys unset). 36 archive-service + 23 slice-API tests green; backend ruff clean.
- **A failed slice now opens an error modal instead of a toast that vanishes before it can be read** — Slice failures surfaced through `SliceJobTrackerContext` as a transient error toast, which `ToastContext` auto-dismisses after a flat 3 seconds. Now that a slice failure carries an actionable message — the slicer's own reason, e.g. *"Some objects are located over the boundary of the heated bed."* — 3 seconds is not enough to read it, let alone act on it. **Fix**: a new `AlertModal` component (a small acknowledge-only modal: title, optional subtitle, message, single Close button; Escape / click-outside to dismiss — modelled on `ConfirmModal` but one button). On a failed slice job, `SliceJobTrackerContext` now shows `AlertModal` with the filename as subtitle and the slicer's reason as the body, instead of the error toast — the user dismisses it themselves. Successful slices keep the existing 3 s success toast; the persistent in-progress toast is still cleared on terminal state. New `slice.failedTitle` key translated across all 9 locales. **Tests**: 4 new in `AlertModal.test.tsx` (renders title/subtitle/message, Close button and Escape both fire `onClose`, subtitle line omitted when absent). Frontend build clean; i18n parity holds.
- **Re-slicing for a different printer no longer silently produces a file for the original printer** — Re-slicing an archive or library file for another printer (e.g. an H2D model re-sliced for an X1C) could hand back a file still sliced for the *original* printer, with no error. `_run_slicer_with_fallback` has an embedded-settings fallback built for one narrow case — a 3MF whose `--load-settings` path *crashes* the slicer CLI (#1201) — but its `except SlicerApiServerError` caught **every** sidecar 5xx, including the slicer running fine and *rejecting the job for a real reason*: e.g. exit 204 "objects over the boundary of the heated bed" (the model is laid out for the source printer's larger bed and doesn't fit the target's) or exit 194 "temperature difference of the filaments is too large". On those, Bambuddy retried with `slice_without_profiles`, which slices using the source 3MF's **embedded** settings — i.e. the original printer — and presented the result as success. The cross-printer slice itself works (CLI logs confirm the target bed `{256,256,250}` was applied); the fallback was masking legitimate rejections. **Fix**: a new `_slicer_rejection_message` helper detects the sidecar marker that means the slicer ran and rejected the job (`"Slicing failed with error from slicer:"`) and extracts the slicer's own reason. Such failures now surface as a `400` with that reason (e.g. *"Some objects are located over the boundary of the heated bed."*) instead of falling back. The embedded-settings fallback is kept only for genuine CLI crashes, which carry no slicer error string. Net effect: a cross-printer re-slice either succeeds for the chosen printer or tells the user exactly why it can't — it never silently returns a file for the original printer. **Tests**: 5 new in `test_library_slice_api.py` — 4 unit tests for `_slicer_rejection_message` (extracts the bed-boundary and filament-temp reasons, returns `None` for a generic CLI crash so that still falls back, handles empty/unrelated text) and an integration test asserting a slicer rejection fails the job with status 400 and the slicer's reason, with no fallback retry. The #1201 fallback test and the STL terminal-failure test (both using a generic error message) still pass unchanged. 23 slice-API tests green; backend ruff clean.
- **Re-sliced archive now shows the printer it was sliced for, not the source's printer** — Re-slicing an archive for a different printer (e.g. an X1C archive re-sliced for an H2D) produced a new archive still labelled "sliced for X1C". `slice_and_persist_as_archive` set the new `PrintArchive.sliced_for_model` to `source_archive.sliced_for_model` — blindly inherited from the source — even though the freshly-sliced 3MF embeds the actual target printer, which `ThreeMFParser` already extracts into `parsed_metadata["sliced_for_model"]` (the new archive's `extra_data` JSON even had the correct value; only the dedicated column was wrong). **Fix**: read `sliced_for_model` from the sliced output's parsed metadata, falling back to the source archive only if the output 3MF doesn't carry it — the same `parsed_metadata.get(...) or source_archive...` pattern already used two lines up for filament type/color. **Test**: new `TestSliceArchiveResliceModel` integration test re-slices an X1C-stamped archive with a mock sidecar returning an H2D-embedded 3MF and asserts the new archive is stamped H2D while the source stays X1C. 18 slice-API tests green; backend ruff clean.
- **Self-hosted Inter font now actually loads — `/fonts/*.woff2` was not served (#1460 follow-up)** — The browser console logged `downloadable font: rejected by sanitizer` for `inter-latin.woff2` on every page load. The #1460 PWA fix added `@font-face` rules pointing at `/fonts/inter-latin.woff2` and bundled the woff2 files into `static/fonts/`, but `main.py` only mounts `/assets`, `/img` and `/icons` as static directories — there was no `/fonts` mount. So `/fonts/*.woff2` fell through to the SPA catch-all and returned `index.html` with `200 OK`; the browser's OpenType sanitizer then rejected the HTML-as-a-font. The woff2 files themselves are valid (verified — Inter variable, latin + latin-ext subsets). **Fix**: added a `/fonts` `StaticFiles` mount alongside the existing `/img` and `/icons` mounts. Additionally, the service worker had cached the bad response: `sw.js` lists the two font URLs in `STATIC_ASSETS` and `cache.addAll()` treats the `200 OK` HTML as a successful fetch, so it stored `index.html` under the font URLs in the static cache and served it cache-first. The SW `STATIC_CACHE` version is bumped (`v26` → `v27`) so the `activate` handler purges the poisoned cache and re-fetches the real fonts on next load. The UI falls back to a system sans-serif until deployed, so there is no visible breakage — only the console warning.
- **Library files now display the filename, not the embedded 3MF Title (#1489, reported by @needo37)** — File Manager cards, search and sort keyed off `file_metadata.print_name`, which `ThreeMFParser` lifts from the 3MF's `<metadata name="Title">`. That title is the in-app project title — generic `"Exported 3D Model"` for any Bambu Studio "Save As", a marketing title for a MakerWorld download — and almost never the filename the user actually saved. So a card for `Whatever.3mf` showed `Exported 3D Model`, and the only way to correct it was a rename round-trip (the Rename dialog's Save button is disabled while the name is unchanged, so the user had to rename to a different name and back). The slicer-output write path already dropped `print_name` for exactly this reason; the **four** other write paths that store parsed 3MF metadata onto a `LibraryFile` did not — external-folder scan, managed multipart upload, the multi-file ZIP-upload branch, and MakerWorld import. **Fix**: a shared `_without_print_name()` helper strips `print_name` from library-file metadata, applied at all four import paths (and the slicer path switched to it, so there is one rule). A `LibraryFile`'s display name is its filename; only `PrintArchive` carries a real `print_name`, and that is untouched. The now-redundant filename→`print_name` mirroring in the rename route is removed. A one-time data migration (`_migrate_drop_library_print_name`, idempotent, SQLite `json_remove` / PostgreSQL `jsonb` key-removal branched on `is_sqlite()`) clears `print_name` from rows imported before the fix, so existing libraries correct themselves without the rename workaround. No frontend change — `print_name || filename` naturally yields the filename once `print_name` is gone. **Tests**: 6 new in `test_library_print_name.py` — `_without_print_name` (strips, keeps siblings, `None` pass-through, no-op identity return, no input mutation, print-name-only → `{}`) and the migration (clears `print_name`, leaves siblings and metadata-free rows alone, idempotent). 109 library + dialect tests green; the migration's PostgreSQL branch additionally ran live against real Postgres during the integration-test app boot. Backend ruff clean.
- **Camera: ffmpeg's stderr is now captured when an RTSP stream stalls instead of only when ffmpeg crashes (#1395, reported by @Tschipel)** — A P2S support bundle taken on 0.2.5b1 (the per-model probesize fix already applied) showed the camera still failing: ffmpeg connects, stays alive 30+ seconds, emits zero JPEG bytes, the stream's 30 s `stdout.read` times out, reconnect loop repeats — but with **no ffmpeg stderr anywhere in the log** to say why. Root cause was a diagnostic bug, not the camera path: `_read_ffmpeg_stderr` called `process.stderr.read()` (read-to-EOF). A stalled-but-still-alive ffmpeg — exactly the P2S RTSP failure mode — never closes stderr, so the read blocked until the 2 s `wait_for` timeout and returned `None`, discarding the banner + stream-analysis lines ffmpeg had already printed. ffmpeg's stderr was therefore captured *only* when it fully exited; the earlier "not enough frames to estimate rate" smoking gun was available only because ffmpeg crashed back then, and once the probesize bump turned the crash into a hang the diagnostic went dark. **Fix**: `_read_ffmpeg_stderr` now drains stderr incrementally in bounded 8 KB chunks (64 KB cap), returning whatever ffmpeg has printed so far whether or not it has exited — so a hung stream is self-describing in the next support bundle. Additionally, `generate_rtsp_mjpeg_stream` now logs the resolved per-model `probesize` / `analyzeduration` on the info-level "Starting RTSP camera stream" line (verifiable without debug logging), and the debug-level ffmpeg-command line logs the full argv with only the credential-bearing camera URL redacted, instead of hiding the entire command. No behaviour change to streaming itself — this makes the still-unresolved P2S RTSP stall diagnosable. **Tests**: 4 new in `test_camera_stderr_summary.py` cover `_read_ffmpeg_stderr` capturing output from a *running* (un-exited, no-EOF) ffmpeg — the regression — as well as the exited case, the no-stderr-pipe case, and banner-only output summarizing to `None`. 9 camera-stderr tests green; backend ruff clean.
+6 -1
View File
@@ -3607,7 +3607,7 @@ async def slice_archive(
user originally sent to slice) → ``file_path`` (the sliced 3MF/gcode that
actually printed).
"""
from backend.app.api.routes.library import slice_and_persist_as_archive
from backend.app.api.routes.library import guard_nozzle_class_reslice, slice_and_persist_as_archive
from backend.app.core.database import async_session
from backend.app.services.slice_dispatch import (
http_exception_to_job_error,
@@ -3654,6 +3654,11 @@ async def slice_archive(
archive_id_local = archive.id
user_id = current_user.id if current_user else None
# Block a cross-nozzle-class re-slice (single-nozzle <-> H2D) up front —
# BambuStudio's multi-extruder validator would otherwise reject it with a
# cryptic error. No-op for same-class or un-sliced sources.
await guard_nozzle_class_reslice(db, current_user, request, archive.sliced_for_model)
async def _run(job_id: int):
async with async_session() as task_db:
# Re-fetch the source archive on the background-task session.
+3 -3
View File
@@ -117,9 +117,9 @@ async def set_kprofile(
# device.extruder.info beats serial-prefix heuristics — H2S shares prefix
# "094" with H2D but is single-nozzle (#1386). Model name is the fallback
# for the brief window after connect before push data arrives.
is_dual_nozzle = client._is_dual_nozzle or (
printer.model and printer.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
)
from backend.app.utils.printer_models import is_dual_nozzle_model
is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
if is_edit and is_dual_nozzle:
# Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
+151 -11
View File
@@ -2899,6 +2899,34 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
return json.dumps(profile)
# The sidecar prefixes the slicer CLI's own error_string with this when the
# slicer ran and rejected the job (model off the bed, incompatible filament
# temps, range validation) — as opposed to the CLI crashing before it could
# evaluate the job at all.
_SLICER_REJECTION_MARKER = "Slicing failed with error from slicer:"
def _slicer_rejection_message(error_text: str) -> str | None:
"""Extract the slicer's own rejection reason from a sidecar error string,
or ``None`` when the failure is not a slicer content rejection.
A content rejection means ``--load-settings`` *was* applied — the slicer
got far enough to evaluate the model against the chosen printer and say
no. Retrying with the 3MF's embedded settings would then only "succeed"
by silently reverting to the source file's original printer, masking the
real problem; such failures must reach the user instead.
"""
if _SLICER_REJECTION_MARKER not in error_text:
return None
reason = error_text.split(_SLICER_REJECTION_MARKER, 1)[1]
# Trim the sidecar's trailing exit-code note and any stderr/stdout dump.
for cut in (": Slicer process failed", "\nstderr:", "\nstdout:"):
idx = reason.find(cut)
if idx != -1:
reason = reason[:idx]
return reason.strip() or None
async def _run_slicer_with_fallback(
db: AsyncSession,
*,
@@ -3072,10 +3100,19 @@ async def _run_slicer_with_fallback(
on_progress=progress_callback,
)
except SlicerApiServerError as exc:
rejection = _slicer_rejection_message(str(exc))
if rejection:
# The slicer ran and rejected the job for a content reason —
# the chosen printer/process/filament *were* applied. Falling
# back to embedded settings would silently re-slice for the
# source 3MF's original printer and hide the real problem
# (e.g. re-slicing an H2D model for an X1C: the object is off
# the smaller bed). Surface the slicer's reason instead.
raise HTTPException(status_code=400, detail=rejection) from exc
if not is_3mf:
raise
logger.warning(
"Slicer CLI rejected --load-settings for %s (%s); retrying with embedded settings",
"Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
model_filename,
exc,
)
@@ -3111,6 +3148,83 @@ async def _run_slicer_with_fallback(
return result, used_embedded_settings
def _canonical_printer_model(raw: str | None) -> str | None:
"""Normalise a printer-preset name / ``printer_model`` field to a canonical
model code. Strips the BambuStudio ``"# "`` user-clone prefix and the
``" 0.4 nozzle"`` variant suffix that preset names carry but bare model
names don't — without this, ``"Bambu Lab H2D 0.4 nozzle"`` wouldn't
normalise to ``H2D``."""
import re
from backend.app.utils.printer_models import normalize_printer_model
if not raw:
return None
cleaned = str(raw).strip()
if cleaned.startswith("# "):
cleaned = cleaned[2:].strip()
cleaned = re.sub(r"\s+0\.\d+\s+nozzle$", "", cleaned, flags=re.IGNORECASE)
return normalize_printer_model(cleaned) if cleaned else None
async def _resolve_target_printer_model(db: AsyncSession, user: User | None, request: SliceRequest) -> str | None:
"""Best-effort: the printer model a slice request targets.
Returns ``None`` when it can't be determined (the nozzle-class guard
then simply doesn't fire — fail-open, never blocks a slice spuriously).
"""
from backend.app.services.preset_resolver import resolve_preset_ref
if request.bundle is not None:
return _canonical_printer_model(request.bundle.printer_name)
if request.printer_preset is None:
return None
try:
printer_json = await resolve_preset_ref(db, user, request.printer_preset, "printer")
data = json.loads(printer_json)
if not isinstance(data, dict):
return None
return _canonical_printer_model(
data.get("printer_model") or data.get("printer_settings_id") or data.get("name")
)
except Exception:
return None
async def guard_nozzle_class_reslice(
db: AsyncSession, user: User | None, request: SliceRequest, source_model: str | None
) -> None:
"""Block a re-slice that crosses the single-nozzle <-> dual-nozzle boundary.
Re-slicing a model laid out for a single-nozzle printer onto a dual-nozzle
printer (H2D / H2D Pro), or vice versa, produces a 3MF whose embedded
single-nozzle filament/extruder layout BambuStudio's multi-extruder
validator rejects — and a crude automatic conversion segfaults the CLI.
Fail fast with a clear message instead of a cryptic slicer error.
No-op when the source isn't a sliced file, the target can't be resolved,
or both printers are the same nozzle class.
"""
from backend.app.utils.printer_models import is_dual_nozzle_model
if not source_model:
return
target_model = await _resolve_target_printer_model(db, user, request)
if not target_model:
return
if is_dual_nozzle_model(source_model) == is_dual_nozzle_model(target_model):
return
raise HTTPException(
status_code=400,
detail=(
f"Can't re-slice this file for {target_model}: it was sliced for {source_model}, "
f"and re-slicing between a single-nozzle and a dual-nozzle printer (H2D / H2D Pro) "
f"isn't supported yet — the embedded filament layout can't be converted "
f"automatically. Slice the original model for {target_model} instead."
),
)
async def slice_and_persist(
db: AsyncSession,
*,
@@ -3173,11 +3287,17 @@ async def slice_and_persist(
# row's display falls back to its ".gcode.3mf" filename instead of the
# source file's project title, which would make the two indistinguishable.
metadata: dict = dict(_without_print_name(parsed_metadata) or {})
# Some slicer-sidecar builds leave the X-Filament-Used-* response headers
# unset, so result.filament_used_g/_mm arrive as 0 even for a real
# multi-hour print. Fall back to the totals ThreeMFParser read from the
# produced 3MF's own G-code header.
filament_g = result.filament_used_g or parsed_metadata.get("filament_used_grams") or 0.0
filament_mm = result.filament_used_mm or parsed_metadata.get("filament_used_mm") or 0.0
metadata.update(
{
"print_time_seconds": result.print_time_seconds,
"filament_used_g": result.filament_used_g,
"filament_used_mm": result.filament_used_mm,
"filament_used_g": filament_g,
"filament_used_mm": filament_mm,
}
)
if used_embedded_settings:
@@ -3211,8 +3331,8 @@ async def slice_and_persist(
library_file_id=new_file.id,
name=new_file.filename,
print_time_seconds=result.print_time_seconds,
filament_used_g=result.filament_used_g,
filament_used_mm=result.filament_used_mm,
filament_used_g=filament_g,
filament_used_mm=filament_mm,
used_embedded_settings=used_embedded_settings,
)
@@ -3282,12 +3402,17 @@ async def slice_and_persist_as_archive(
metadata = dict(source_archive.extra_data) if source_archive.extra_data else {}
metadata.update(parsed_metadata)
# Fall back to the produced 3MF's G-code-header totals when the sidecar
# leaves the X-Filament-Used-* headers unset (result.filament_used_g == 0
# even for a real multi-hour print).
filament_g = result.filament_used_g or parsed_metadata.get("filament_used_grams") or 0.0
filament_mm = result.filament_used_mm or parsed_metadata.get("filament_used_mm") or 0.0
metadata.update(
{
"sliced_from_archive_id": source_archive.id,
"print_time_seconds": result.print_time_seconds,
"filament_used_g": result.filament_used_g,
"filament_used_mm": result.filament_used_mm,
"filament_used_g": filament_g,
"filament_used_mm": filament_mm,
}
)
if used_embedded_settings:
@@ -3313,12 +3438,17 @@ async def slice_and_persist_as_archive(
# up alongside its sibling in the archives list.
print_name=(source_archive.print_name or base_name) + " (re-sliced)",
print_time_seconds=result.print_time_seconds,
filament_used_grams=result.filament_used_g or None,
filament_used_grams=filament_g or None,
filament_type=new_filament_type,
filament_color=new_filament_color,
layer_height=source_archive.layer_height,
nozzle_diameter=source_archive.nozzle_diameter,
sliced_for_model=source_archive.sliced_for_model,
# The re-sliced output is for whatever printer the user just picked,
# not the source archive's printer — read the model the slicer baked
# into the new 3MF, falling back to the source only if it's absent.
# (Copying source_archive.sliced_for_model kept a cross-printer
# re-slice, e.g. X1C→H2D, showing the old "X1C sliced" model.)
sliced_for_model=parsed_metadata.get("sliced_for_model") or source_archive.sliced_for_model,
makerworld_url=source_archive.makerworld_url,
designer=source_archive.designer,
# Sliced-but-not-printed: keep status default ("completed") so it
@@ -3335,8 +3465,8 @@ async def slice_and_persist_as_archive(
archive_id=new_archive.id,
name=new_archive.print_name or out_filename,
print_time_seconds=result.print_time_seconds,
filament_used_g=result.filament_used_g,
filament_used_mm=result.filament_used_mm,
filament_used_g=filament_g,
filament_used_mm=filament_mm,
used_embedded_settings=used_embedded_settings,
)
@@ -3402,6 +3532,16 @@ 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
# Block a cross-nozzle-class re-slice (single-nozzle <-> H2D) up front.
# Fires only when the source is itself a sliced file (carries
# sliced_for_model); a plain un-sliced model has no source nozzle class.
await guard_nozzle_class_reslice(
db,
cloud_token_user,
request,
(lib_file.file_metadata or {}).get("sliced_for_model"),
)
async def _run(job_id: int):
async with async_session() as task_db:
try:
+14
View File
@@ -316,6 +316,20 @@ class ThreeMFParser:
if match:
self.metadata["total_layers"] = int(match.group(1))
# Total filament usage. The slicer writes the print's totals into
# the G-code header ("; total filament weight [g] : 126.26"). Only
# a fallback — slice_info.config is more authoritative when present
# — but it covers sliced outputs whose slice_info lacks per-filament
# used_g, and it's the slicer's own figure regardless.
if "filament_used_grams" not in self.metadata:
match = re.search(r";\s*total\s+filament\s+weight\s*\[g\]\s*:\s*([\d.]+)", header, re.IGNORECASE)
if match:
self.metadata["filament_used_grams"] = float(match.group(1))
if "filament_used_mm" not in self.metadata:
match = re.search(r";\s*total\s+filament\s+length\s*\[mm\]\s*:\s*([\d.]+)", header, re.IGNORECASE)
if match:
self.metadata["filament_used_mm"] = float(match.group(1))
# Look for printer_model in gcode header (fallback if not found in slice_info)
# Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C"
if "sliced_for_model" not in self.metadata:
+6 -6
View File
@@ -3287,9 +3287,9 @@ class BambuMQTTClient:
# model name for the brief window after connect before push data
# arrives. _is_dual_nozzle only ever flips False→True, so it's safe
# as the primary signal.
is_dual_nozzle = self._is_dual_nozzle or (
self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
)
from backend.app.utils.printer_models import is_dual_nozzle_model
is_dual_nozzle = self._is_dual_nozzle or is_dual_nozzle_model(self.model)
# Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
ams_mapping2 = []
@@ -4117,9 +4117,9 @@ class BambuMQTTClient:
# Prefer runtime detection from device.extruder.info; fall back to
# model name. H2S is single-nozzle but shares serial prefix "094" with
# H2D, so a prefix-only check misclassified it (#1386).
is_dual_nozzle = self._is_dual_nozzle or (
self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
)
from backend.app.utils.printer_models import is_dual_nozzle_model
is_dual_nozzle = self._is_dual_nozzle or is_dual_nozzle_model(self.model)
if is_dual_nozzle:
# H2D format: uses extruder_id, nozzle_id, nozzle_diameter
+33
View File
@@ -137,6 +137,31 @@ ETHERNET_MODELS = frozenset(
)
# Dual-nozzle (dual-extruder) printers. Single source of truth for nozzle
# class — consumed by ``BambuMQTTClient.start_print``, the K-profile routes,
# and the re-slice nozzle-class guard (previously an inline model tuple
# duplicated across all three). Re-slicing a model laid out for a single-nozzle
# printer onto one of these — or vice versa — is not yet supported: the source
# 3MF's embedded single-nozzle filament/extruder layout is not a valid
# dual-nozzle project and BambuStudio's multi-extruder validator rejects it.
DUAL_NOZZLE_MODELS = frozenset(
[
# Display names (uppercase, no spaces)
"H2D",
"H2DPRO",
"H2C",
"X2D",
# Internal codes
"O1D", # H2D
"O1E", # H2D Pro
"O2D", # H2D Pro (alternate)
"O1C", # H2C
"O1C2", # H2C (dual nozzle variant)
"N6", # X2D
]
)
def has_ethernet(model: str | None) -> bool:
"""Return True if the printer model has an ethernet port."""
if not model:
@@ -145,6 +170,14 @@ def has_ethernet(model: str | None) -> bool:
return normalized in ETHERNET_MODELS
def is_dual_nozzle_model(model: str | None) -> bool:
"""Return True if the printer model has two nozzles (H2D family / X2D)."""
if not model:
return False
normalized = model.strip().upper().replace(" ", "").replace("-", "")
return normalized in DUAL_NOZZLE_MODELS
def get_rod_type(model: str | None) -> str | None:
"""Return the rod/rail type for a printer model.
@@ -22,6 +22,7 @@ import httpx
import pytest
from httpx import AsyncClient
from backend.app.api.routes.library import _slicer_rejection_message
from backend.app.core.config import settings as app_settings
from backend.app.models.library import LibraryFile
from backend.app.models.local_preset import LocalPreset
@@ -775,3 +776,309 @@ class TestSliceJobs:
slice_dispatch._jobs.clear()
r = await async_client.get("/api/v1/slice-jobs/999999")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# POST /archives/{id}/slice — re-sliced archive reflects the target printer
# ---------------------------------------------------------------------------
def _make_sliced_3mf(printer_model_id: str) -> bytes:
"""A minimal sliced-output 3MF that embeds a printer_model_id in
slice_info.config, the way a real Bambu Studio / OrcaSlicer export does.
ThreeMFParser reads this into metadata['sliced_for_model']."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/slice_info.config",
f"<config><plate><metadata key='printer_model_id' value='{printer_model_id}'/></plate></config>",
)
return buf.getvalue()
class TestSliceArchiveResliceModel:
"""Re-slicing an archive for a different printer must stamp the new
archive with the printer it was sliced FOR, not the source's printer."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reslice_uses_target_model_not_source_model(
self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
):
from backend.app.models.archive import PrintArchive
tmp_path = slice_test_setup["tmp_path"]
# archive_dir is a static path off the real data dir; point it under
# base_dir (= tmp_path) so the new archive's file resolves there.
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
# Source archive: a 3MF that was sliced for an X1C.
src_dir = tmp_path / "archives" / "src"
src_dir.mkdir(parents=True, exist_ok=True)
src_3mf = src_dir / "cube.3mf"
src_3mf.write_bytes(_make_3mf_with_settings())
printer = await printer_factory()
source = await archive_factory(
printer.id,
filename="cube.3mf",
file_path=str(src_3mf.relative_to(tmp_path)),
sliced_for_model="X1C",
with_run=False,
)
source_id = source.id
# The slicer returns a 3MF whose embedded printer_model_id is O1D (H2D).
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=200,
content=_make_sliced_3mf("O1D"),
headers={
"x-print-time-seconds": "600",
"x-filament-used-g": "5.0",
"x-filament-used-mm": "1600.0",
},
)
_install_mock_sidecar(handler)
resp = await async_client.post(
f"/api/v1/archives/{source_id}/slice",
json={
"printer_preset_id": slice_test_setup["printer_id"],
"process_preset_id": slice_test_setup["process_id"],
"filament_preset_id": slice_test_setup["filament_id"],
},
)
assert resp.status_code == 202, resp.text
final = await _wait_for_job(async_client, resp.json()["job_id"])
assert final["status"] == "completed", final
new_id = final["result"]["archive_id"]
assert new_id != source_id
new_archive = await db_session.get(PrintArchive, new_id)
# The fix: the re-sliced archive reflects H2D — the printer it was
# sliced for — instead of inheriting X1C from the source archive.
assert new_archive.sliced_for_model == "H2D"
# Source archive is untouched.
source_reloaded = await db_session.get(PrintArchive, source_id)
assert source_reloaded.sliced_for_model == "X1C"
# ---------------------------------------------------------------------------
# Slicer content rejections surface instead of silently falling back
# ---------------------------------------------------------------------------
class TestSlicerRejectionMessage:
"""_slicer_rejection_message distinguishes a real slicer content rejection
(surface it to the user) from a CLI crash (fall back to embedded)."""
def test_extracts_bed_boundary_reason(self):
text = (
"Slicer CLI failed (500): Slicing failed with error from slicer: "
"Some objects are located over the boundary of the heated bed.: "
"Slicer process failed (exit code 204)\nstdout: trace ..."
)
assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
def test_extracts_filament_temp_reason(self):
text = (
"Slicer CLI failed (500): Slicing failed with error from slicer: "
"The temperature difference of the filaments used is too large.: "
"Slicer process failed (exit code 194)"
)
assert _slicer_rejection_message(text) == "The temperature difference of the filaments used is too large."
def test_generic_cli_failure_is_not_a_rejection(self):
# The #1201 CLI-crash signature carries no slicer error_string, so it
# must still fall through to the embedded-settings fallback.
assert _slicer_rejection_message("Slicer CLI failed (500): Failed to slice the model") is None
def test_empty_or_unrelated_text(self):
assert _slicer_rejection_message("") is None
assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") is None
class TestSliceSlicerRejection:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_3mf_surfaces_slicer_rejection_instead_of_falling_back(
self, async_client: AsyncClient, db_session, slice_test_setup
):
"""A real slicer content rejection (e.g. re-slicing for a printer with
a smaller bed) must surface as a 400 — not silently fall back to the
source 3MF's embedded settings, which would re-slice for the original
printer and hide the problem."""
src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "toobig.3mf"
src_3mf_path.write_bytes(_make_3mf_with_settings())
threemf = LibraryFile(
filename="toobig.3mf",
file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
file_type="3mf",
file_size=src_3mf_path.stat().st_size,
)
db_session.add(threemf)
await db_session.commit()
await db_session.refresh(threemf)
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
return httpx.Response(
status_code=500,
json={
"message": (
"Slicing failed with error from slicer: Some objects are "
"located over the boundary of the heated bed."
),
"details": "Slicer process failed (exit code 204)",
},
)
_install_mock_sidecar(handler)
response = await async_client.post(
f"/api/v1/library/files/{threemf.id}/slice",
json={
"printer_preset_id": slice_test_setup["printer_id"],
"process_preset_id": slice_test_setup["process_id"],
"filament_preset_id": slice_test_setup["filament_id"],
},
)
assert response.status_code == 202
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "failed", final
assert final["error_status"] == 400
assert "boundary of the heated bed" in (final["error_detail"] or "")
# The slicer rejection must NOT trigger the embedded-settings retry.
assert call_count["n"] == 1
# ---------------------------------------------------------------------------
# Nozzle-class re-slice guard — single-nozzle <-> dual-nozzle (H2D) is blocked
# ---------------------------------------------------------------------------
from fastapi import HTTPException # noqa: E402
from backend.app.api.routes.library import ( # noqa: E402
_canonical_printer_model,
guard_nozzle_class_reslice,
)
class TestCanonicalPrinterModel:
"""_canonical_printer_model strips the '# ' clone prefix and the
' 0.4 nozzle' variant suffix so preset names resolve to a model code."""
def test_strips_nozzle_suffix(self):
assert _canonical_printer_model("Bambu Lab H2D 0.4 nozzle") == "H2D"
def test_strips_clone_prefix_and_suffix(self):
assert _canonical_printer_model("# Bambu Lab X1 Carbon 0.4 nozzle") == "X1C"
def test_bare_model_and_empty(self):
assert _canonical_printer_model("Bambu Lab H2D") == "H2D"
assert _canonical_printer_model(None) is None
assert _canonical_printer_model("") is None
class TestNozzleClassGuard:
"""guard_nozzle_class_reslice blocks a re-slice that crosses the
single-nozzle <-> dual-nozzle boundary."""
@pytest.mark.asyncio
async def test_single_to_dual_is_blocked(self, monkeypatch):
import backend.app.api.routes.library as lib
async def _target(_db, _user, _request):
return "H2D"
monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
with pytest.raises(HTTPException) as exc:
await guard_nozzle_class_reslice(None, None, None, "X1C")
assert exc.value.status_code == 400
assert "H2D" in exc.value.detail and "X1C" in exc.value.detail
@pytest.mark.asyncio
async def test_dual_to_single_is_blocked(self, monkeypatch):
import backend.app.api.routes.library as lib
async def _target(_db, _user, _request):
return "X1C"
monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
with pytest.raises(HTTPException):
await guard_nozzle_class_reslice(None, None, None, "H2D")
@pytest.mark.asyncio
async def test_same_nozzle_class_is_allowed(self, monkeypatch):
import backend.app.api.routes.library as lib
async def _target(_db, _user, _request):
return "P1S"
monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
# X1C -> P1S: both single-nozzle — no raise.
await guard_nozzle_class_reslice(None, None, None, "X1C")
@pytest.mark.asyncio
async def test_no_source_model_is_a_noop(self, monkeypatch):
import backend.app.api.routes.library as lib
async def _target(_db, _user, _request):
return "H2D"
monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
# Un-sliced source (no sliced_for_model) — first-time slice, never blocked.
await guard_nozzle_class_reslice(None, None, None, None)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_reslice_x1c_to_h2d_returns_400(
self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
):
"""End to end: re-slicing an X1C archive for an H2D printer preset is
rejected synchronously with a 400 — before any job is enqueued."""
tmp_path = slice_test_setup["tmp_path"]
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
src_dir = tmp_path / "archives" / "src"
src_dir.mkdir(parents=True, exist_ok=True)
src_3mf = src_dir / "cube.3mf"
src_3mf.write_bytes(_make_3mf_with_settings())
printer = await printer_factory()
source = await archive_factory(
printer.id,
filename="cube.3mf",
file_path=str(src_3mf.relative_to(tmp_path)),
sliced_for_model="X1C",
with_run=False,
)
# A printer preset whose resolved JSON is an H2D — dual-nozzle.
h2d = LocalPreset(
name="# Bambu Lab H2D 0.4 nozzle",
preset_type="printer",
source="orcaslicer",
setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
)
db_session.add(h2d)
await db_session.commit()
await db_session.refresh(h2d)
resp = await async_client.post(
f"/api/v1/archives/{source.id}/slice",
json={
"printer_preset": {"source": "local", "id": str(h2d.id)},
"process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
"filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
},
)
assert resp.status_code == 400, resp.text
detail = resp.json()["detail"]
assert "H2D" in detail and "X1C" in detail
@@ -643,3 +643,47 @@ class TestReprintCostCalculation:
# After 3 prints (1 original + 2 reprints)
total_after_3_prints = round(single_print_cost * 3, 2)
assert total_after_3_prints == 6.0
class TestGcodeHeaderFilamentUsage:
"""ThreeMFParser pulls total filament usage from the produced 3MF's G-code
header. Some slicer-sidecar builds leave the X-Filament-Used-* response
headers unset, so the slice would otherwise report "0 g" for a real
multi-hour print."""
@staticmethod
def _make_3mf(gcode_header: str) -> str:
import tempfile
import zipfile
fd, path = tempfile.mkstemp(suffix=".3mf")
import os
os.close(fd)
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr("Metadata/plate_1.gcode", gcode_header + "\nG1 X0 Y0\n")
return path
def test_extracts_filament_weight_and_length_from_header(self):
from backend.app.services.archive import ThreeMFParser
header = (
"; HEADER_BLOCK_START\n"
"; BambuStudio 02.06.00.51\n"
"; total layer number: 503\n"
"; total filament length [mm] : 41661.40\n"
"; total filament volume [cm^3] : 100207.42\n"
"; total filament weight [g] : 126.26\n"
)
meta = ThreeMFParser(self._make_3mf(header)).parse()
assert meta.get("filament_used_grams") == 126.26
assert meta.get("filament_used_mm") == 41661.40
assert meta.get("total_layers") == 503
def test_no_filament_keys_when_header_lacks_them(self):
from backend.app.services.archive import ThreeMFParser
meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
assert "filament_used_grams" not in meta
assert "filament_used_mm" not in meta
+26
View File
@@ -8,6 +8,7 @@ from backend.app.utils.printer_models import (
STEEL_ROD_MODELS,
get_rod_type,
has_ethernet,
is_dual_nozzle_model,
normalize_printer_model,
normalize_printer_model_id,
)
@@ -104,3 +105,28 @@ class TestX2DModel:
assert "N6" not in CARBON_ROD_MODELS
assert "X2D" in STEEL_ROD_MODELS
assert "N6" in STEEL_ROD_MODELS
class TestDualNozzleModel:
"""is_dual_nozzle_model — the single source of truth for nozzle class,
consumed by start_print, the K-profile routes, and the re-slice guard."""
def test_h2d_and_pro_are_dual(self):
# Takes a normalized model code (like has_ethernet) — "H2D Pro" with a
# space is accepted; full "Bambu Lab …" names are normalized by callers.
assert is_dual_nozzle_model("H2D") is True
assert is_dual_nozzle_model("H2D Pro") is True
assert is_dual_nozzle_model("H2DPRO") is True
def test_internal_codes_are_dual(self):
assert is_dual_nozzle_model("O1D") is True # H2D
assert is_dual_nozzle_model("O1E") is True # H2D Pro
def test_single_nozzle_models_are_not_dual(self):
# H2S is in the H2 family but single-nozzle (#1386) — must be False.
for model in ("X1C", "X1E", "P1S", "P1P", "A1", "A1 Mini", "P2S", "H2S"):
assert is_dual_nozzle_model(model) is False, model
def test_none_and_empty_are_not_dual(self):
assert is_dual_nozzle_model(None) is False
assert is_dual_nozzle_model("") is False
@@ -0,0 +1,57 @@
/**
* Tests for AlertModal — the acknowledge-only error modal used to surface
* slice failures (and other must-read errors) that a toast would auto-dismiss
* before they can be read.
*/
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
import { AlertModal } from '../../components/AlertModal';
function renderModal(props?: Partial<Parameters<typeof AlertModal>[0]>) {
const onClose = vi.fn();
render(
<I18nextProvider i18n={i18n}>
<AlertModal
title="Slicing failed"
subtitle="Mecha Mewtwo.3mf"
message="Some objects are located over the boundary of the heated bed."
onClose={onClose}
{...props}
/>
</I18nextProvider>,
);
return { onClose };
}
describe('AlertModal', () => {
it('renders the title, subtitle and message', () => {
renderModal();
expect(screen.getByText('Slicing failed')).toBeInTheDocument();
expect(screen.getByText('Mecha Mewtwo.3mf')).toBeInTheDocument();
expect(
screen.getByText('Some objects are located over the boundary of the heated bed.'),
).toBeInTheDocument();
});
it('calls onClose when the Close button is clicked', () => {
const { onClose } = renderModal();
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when Escape is pressed', () => {
const { onClose } = renderModal();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('omits the subtitle line when no subtitle is given', () => {
renderModal({ subtitle: undefined });
expect(screen.queryByText('Mecha Mewtwo.3mf')).not.toBeInTheDocument();
expect(screen.getByText('Slicing failed')).toBeInTheDocument();
});
});
+75
View File
@@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface AlertModalProps {
title: string;
message: string;
/** Optional secondary line shown above the message — e.g. the file the
* alert is about. */
subtitle?: string;
closeText?: string;
variant?: 'error' | 'warning';
onClose: () => void;
}
/**
* A small acknowledge-only modal: title, message, single Close button.
*
* Use it to surface something the user must read and act on, where a toast
* would auto-dismiss before it can be read (e.g. a slicer rejection message).
* For confirm/cancel decisions use ConfirmModal instead.
*/
export function AlertModal({
title,
message,
subtitle,
closeText,
variant = 'error',
onClose,
}: AlertModalProps) {
const { t } = useTranslation();
const resolvedCloseText = closeText ?? t('common.close');
// Close on Escape — matches ConfirmModal's behaviour.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const iconColor = variant === 'warning' ? 'text-yellow-400' : 'text-red-400';
return (
<div
// z-[120]: above other modals (z-50 / z-[110]) and the toast stack, so
// a slice failure surfaced from app-level context is never occluded.
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-[120]"
onClick={onClose}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-full bg-bambu-dark ${iconColor}`}>
<AlertTriangle className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white mb-1">{title}</h3>
{subtitle && <p className="text-xs text-bambu-gray mb-2 truncate">{subtitle}</p>}
<p className="text-bambu-gray text-sm whitespace-pre-line break-words">{message}</p>
</div>
</div>
<div className="flex mt-6">
<Button onClick={onClose} className="flex-1">
{resolvedCloseText}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { api, type SliceJobProgress, type SliceJobState, type SliceJobStatus } from '../api/client';
import { useToast } from './ToastContext';
import { AlertModal } from '../components/AlertModal';
interface TrackedJob {
id: number;
@@ -67,6 +68,10 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
const { showToast, showPersistentToast, dismissToast } = useToast();
const queryClient = useQueryClient();
const [activeJobs, setActiveJobs] = useState<TrackedJob[]>([]);
// A failed slice surfaces as an acknowledge-only modal, not a toast: the
// slicer's reason (e.g. "objects over the bed boundary") is actionable and
// a 3s toast hides it before it can be read.
const [sliceError, setSliceError] = useState<{ name: string; detail: string } | null>(null);
// Stable mutable ref so the polling effect can read the current list
// without re-subscribing every time it changes.
@@ -162,8 +167,10 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
'success',
);
} else if (state.status === 'failed') {
const detail = state.error_detail || t('slice.failed');
showToast(t('slice.failedToast', 'Slicing {{name}} failed: {{detail}}', { name: prettifyFilename(job.sourceName), detail }), 'error');
setSliceError({
name: prettifyFilename(job.sourceName),
detail: state.error_detail || t('slice.failed'),
});
}
// Refresh whichever list owns the result. Both are cheap to invalidate.
@@ -220,6 +227,14 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
return (
<SliceJobTrackerContext.Provider value={{ trackJob, activeJobs }}>
{children}
{sliceError && (
<AlertModal
title={t('slice.failedTitle')}
subtitle={sliceError.name}
message={sliceError.detail}
onClose={() => setSliceError(null)}
/>
)}
</SliceJobTrackerContext.Provider>
);
}
+1
View File
@@ -3460,6 +3460,7 @@ export default {
runningToast: '{{name}} wird gesliced — {{elapsed}}',
runningWithProgress: '{{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
completedToast: '{{name}} wurde gesliced',
failedTitle: 'Slicen fehlgeschlagen',
failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
tier: {
local: 'Importiert',
+1
View File
@@ -3463,6 +3463,7 @@ export default {
runningToast: 'Slicing {{name}} — {{elapsed}}',
runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
completedToast: 'Sliced {{name}}',
failedTitle: 'Slicing failed',
failedToast: 'Slicing {{name}} failed: {{detail}}',
tier: {
local: 'Imported',
+1
View File
@@ -3463,6 +3463,7 @@ export default {
runningToast: 'Laminando {{name}} — {{elapsed}}',
runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
completedToast: '{{name}} laminado',
failedTitle: 'Error al laminar',
failedToast: 'Error al laminar {{name}}: {{detail}}',
tier: {
local: 'Importado',
+1
View File
@@ -3449,6 +3449,7 @@ export default {
runningToast: 'Découpage {{name}} – {{elapsed}}',
runningWithProgress: '{{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
completedToast: '{{name}} découpé',
failedTitle: 'Échec du découpage',
failedToast: 'Échec du découpage de {{name}} : {{detail}}',
tier: {
local: 'Importé',
+1
View File
@@ -3448,6 +3448,7 @@ export default {
runningToast: 'Slicing {{name}} – {{elapsed}}',
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
completedToast: '{{name}} sezionato',
failedTitle: 'Slicing fallito',
failedToast: 'Slicing di {{name}} fallito: {{detail}}',
tier: {
local: 'Importato',
+1
View File
@@ -3460,6 +3460,7 @@ export default {
runningToast: '{{name}}をスライス中 – {{elapsed}}',
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
completedToast: '{{name}}をスライス済み',
failedTitle: 'スライスに失敗しました',
failedToast: '{{name}}のスライスに失敗: {{detail}}',
tier: {
local: 'インポート済み',
+1
View File
@@ -3448,6 +3448,7 @@ export default {
runningToast: 'Fatiando {{name}} – {{elapsed}}',
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
completedToast: '{{name}} fatiado',
failedTitle: 'Falha ao fatiar',
failedToast: 'Falha ao fatiar {{name}}: {{detail}}',
tier: {
local: 'Importado',
+1
View File
@@ -3448,6 +3448,7 @@ export default {
runningToast: '切片 {{name}} — {{elapsed}}',
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
completedToast: '已切片 {{name}}',
failedTitle: '切片失败',
failedToast: '切片 {{name}} 失败:{{detail}}',
tier: {
local: '已导入',
+1
View File
@@ -3448,6 +3448,7 @@ export default {
runningToast: '切片 {{name}} — {{elapsed}}',
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
completedToast: '已切片 {{name}}',
failedTitle: '切片失敗',
failedToast: '切片 {{name}} 失敗:{{detail}}',
tier: {
local: '已匯入',
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-DuJrYPHG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CBuiHfeb.css">
<script type="module" crossorigin src="/assets/index-DOnLEVBo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cqtgi1Io.css">
</head>
<body>
<div id="root"></div>