mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3460,6 +3460,7 @@ export default {
|
||||
runningToast: '{{name}}をスライス中 – {{elapsed}}',
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
completedToast: '{{name}}をスライス済み',
|
||||
failedTitle: 'スライスに失敗しました',
|
||||
failedToast: '{{name}}のスライスに失敗: {{detail}}',
|
||||
tier: {
|
||||
local: 'インポート済み',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3448,6 +3448,7 @@ export default {
|
||||
runningToast: '切片 {{name}} — {{elapsed}}',
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
completedToast: '已切片 {{name}}',
|
||||
failedTitle: '切片失败',
|
||||
failedToast: '切片 {{name}} 失败:{{detail}}',
|
||||
tier: {
|
||||
local: '已导入',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user