Browse Source

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.
maziggy 5 days ago
parent
commit
4925b4c830

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 6 - 1
backend/app/api/routes/archives.py

@@ -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
backend/app/api/routes/kprofiles.py

@@ -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
backend/app/api/routes/library.py

@@ -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 - 0
backend/app/services/archive.py

@@ -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
backend/app/services/bambu_mqtt.py

@@ -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 - 0
backend/app/utils/printer_models.py

@@ -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.
 

+ 307 - 0
backend/tests/integration/test_library_slice_api.py

@@ -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

+ 44 - 0
backend/tests/unit/services/test_archive_service.py

@@ -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 - 0
backend/tests/unit/test_printer_models.py

@@ -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

+ 57 - 0
frontend/src/__tests__/components/AlertModal.test.tsx

@@ -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 - 0
frontend/src/components/AlertModal.tsx

@@ -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 - 2
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -3460,6 +3460,7 @@ export default {
     runningToast: '{{name}}をスライス中 – {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     completedToast: '{{name}}をスライス済み',
+    failedTitle: 'スライスに失敗しました',
     failedToast: '{{name}}のスライスに失敗: {{detail}}',
     tier: {
       local: 'インポート済み',

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

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3448,6 +3448,7 @@ export default {
     runningToast: '切片 {{name}} — {{elapsed}}',
     runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     completedToast: '已切片 {{name}}',
+    failedTitle: '切片失败',
     failedToast: '切片 {{name}} 失败:{{detail}}',
     tier: {
       local: '已导入',

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

@@ -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 it is too large
+ 0 - 0
static/assets/index-CBuiHfeb.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Cqtgi1Io.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DOnLEVBo.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-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>

Some files were not shown because too many files changed in this diff