Просмотр исходного кода

Merge branch 'dev' into feature/russian-localization

pterodaktil02 1 месяц назад
Родитель
Сommit
26f2b096d3

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 27 - 2
backend/app/api/routes/library.py

@@ -3528,6 +3528,12 @@ async def _run_slicer_with_fallback(
         presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
         presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
 
 
     used_embedded_settings = False
     used_embedded_settings = False
+    # "Slice as designed" (#2611): honour the file's embedded
+    # project_settings.config instead of the picked profile triplet. Only
+    # meaningful for a 3MF that actually carries embedded settings; the UI
+    # gates the toggle on the picked printer matching the design's target,
+    # so this path never re-targets across printer models.
+    embedded_mode = bool(request.use_embedded_settings and is_3mf)
     service = SlicerApiService(api_url)
     service = SlicerApiService(api_url)
 
 
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
@@ -3606,7 +3612,22 @@ async def _run_slicer_with_fallback(
 
 
     try:
     try:
         try:
         try:
-            if use_cross_class_slice_all:
+            if embedded_mode:
+                # No --load-settings: feed the CLI the file's own
+                # project_settings.config untouched so the designer's tweaks
+                # (walls, infill, etc.) drive the slice. primary_bytes is
+                # already sentinel-sanitised above, the same bytes the
+                # crash-fallback uses. The resolved presets go unused here.
+                result = await service.slice_without_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+                used_embedded_settings = True
+            elif use_cross_class_slice_all:
                 from backend.app.services.slicer_3mf_convert import (
                 from backend.app.services.slicer_3mf_convert import (
                     count_plates_in_3mf,
                     count_plates_in_3mf,
                     merge_plate_3mfs,
                     merge_plate_3mfs,
@@ -3708,7 +3729,11 @@ async def _run_slicer_with_fallback(
                 # (e.g. re-slicing an H2D model for an X1C: the object is off
                 # (e.g. re-slicing an H2D model for an X1C: the object is off
                 # the smaller bed). Surface the slicer's reason instead.
                 # the smaller bed). Surface the slicer's reason instead.
                 raise HTTPException(status_code=400, detail=rejection) from exc
                 raise HTTPException(status_code=400, detail=rejection) from exc
-            if not is_3mf:
+            if not is_3mf or embedded_mode:
+                # embedded_mode already sliced with the file's own settings —
+                # there is nothing to fall back TO, so surface the server
+                # error (the outer handler turns it into a 502) instead of
+                # re-running the same embedded slice.
                 raise
                 raise
             logger.warning(
             logger.warning(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",

+ 115 - 0
backend/app/core/database.py

@@ -586,6 +586,116 @@ async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
         )
         )
 
 
 
 
+async def _migrate_scope_run_filament_to_plate(conn) -> None:
+    """Repair completed print-log rows that stored a multi-plate 3MF's whole-file
+    filament (and cost) instead of the printed plate's (#2614).
+
+    When the AMS tracker measured nothing for a completed run, the per-run filament
+    fell back to ``PrintArchive.filament_used_grams`` — the sum over EVERY plate of
+    the source 3MF (right for the archive card / project rollup, wrong for one
+    printed plate). So each printed plate of a 22-plate file logged the full ~12 kg,
+    inflating lifetime / user / project / filament stats by the plate count. The
+    forward fix scopes new rows; this repairs the rows already written.
+
+    Only completed rows whose stored grams EXACTLY equal the archive's whole-file
+    value are touched — that is the mis-copy signature. Tracker-measured rows (a
+    rounded spool-delta sum) and partial-progress rows (scaled to progress) never
+    match, so they are never clobbered. Cost is scaled by the plate's share of the
+    whole so it stays consistent with the corrected grams. Runs AFTER the #2603
+    archive plate_id backfill so ``print_archives.plate_id`` is populated.
+
+    Gated to run **exactly once** via a settings flag. This is not merely for
+    idempotency: a genuine single-plate print carries a ``plate_id`` too (the UI
+    always sends one), and for it the plate estimate legitimately equals the
+    whole-file value — so those rows match the signature on every boot. Without
+    the one-shot gate we would re-parse every single-plate 3MF on the print log at
+    each startup, a cost that grows without bound with print history. One pass is
+    enough: the forward fix keeps all new rows correct.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+    flag = "_backfill_2614_plate_filament_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT ple.id AS entry_id, ple.filament_used_grams AS grams, ple.cost AS cost, "
+                    "a.plate_id AS plate_id, a.filament_used_grams AS whole_grams, a.file_path AS file_path "
+                    "FROM print_log_entries ple "
+                    "JOIN print_archives a ON a.id = ple.archive_id "
+                    "WHERE ple.status = 'completed' "
+                    "AND a.plate_id IS NOT NULL "
+                    "AND a.file_path IS NOT NULL "
+                    "AND a.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams = a.filament_used_grams"
+                )
+            )
+        ).fetchall()
+
+        corrected = 0
+        grams_removed = 0.0
+        for row in rows:
+            path = Path(row.file_path)
+            if not path.is_absolute():
+                path = settings.base_dir / row.file_path
+            if not path.exists():
+                continue
+            try:
+                plate_grams = extract_plate_metadata_from_3mf(path, row.plate_id).filament_used_grams
+            except Exception as exc:
+                logger.warning(
+                    "[#2614] could not read plate %s of %s for log entry %s: %s",
+                    row.plate_id,
+                    row.file_path,
+                    row.entry_id,
+                    exc,
+                )
+                continue
+            if not plate_grams or plate_grams <= 0:
+                continue
+            new_grams = round(plate_grams, 2)
+            if abs(new_grams - (row.grams or 0)) < 0.01:
+                continue  # nothing to change (e.g. a genuine single-plate file)
+            new_cost = row.cost
+            whole = row.whole_grams or 0
+            if row.cost and whole > 0:
+                new_cost = round(row.cost * (plate_grams / whole), 2)
+            await conn.execute(
+                text("UPDATE print_log_entries SET filament_used_grams = :g, cost = :c WHERE id = :id"),
+                {"g": new_grams, "c": new_cost, "id": row.entry_id},
+            )
+            corrected += 1
+            grams_removed += (row.grams or 0) - new_grams
+
+        if corrected:
+            logger.info(
+                "[#2614] Re-scoped %d completed print-log row(s) from whole-file to plate filament "
+                "(removed %.0f g of over-counted usage from statistics)",
+                corrected,
+                grams_removed,
+            )
+
+        # Mark done unconditionally (even when nothing matched) so this one-shot
+        # never re-scans the print log on subsequent boots. id/timestamps come
+        # from the table's own defaults; "key" is quoted as it's a keyword.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _migrate_drop_library_print_name(conn) -> None:
 async def _migrate_drop_library_print_name(conn) -> None:
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
 
 
@@ -3566,6 +3676,11 @@ async def run_migrations(conn):
                 )
                 )
             )
             )
 
 
+    # Migration: repair completed print-log rows that stored a multi-plate 3MF's
+    # whole-file filament instead of the printed plate's (#2614). Runs AFTER the
+    # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
+    await _migrate_scope_run_filament_to_plate(conn)
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)

+ 47 - 2
backend/app/main.py

@@ -772,6 +772,41 @@ def _compute_run_filament_grams(
     return None
     return None
 
 
 
 
+def _plate_scoped_run_estimate(archive, full_path) -> tuple[float | None, float | None]:
+    """Per-run (grams, cost) scoped to the plate this run actually printed (#2614).
+
+    ``PrintArchive.filament_used_grams`` / ``.cost`` are the sum over EVERY plate of
+    the source 3MF — correct for the archive card and project rollup, but wrong for a
+    single plate dispatched from a multi-plate file: without scoping, each printed
+    plate of a 22-plate file logs the whole ~12 kg and inflates every statistic. When
+    the archive carries a ``plate_id`` and its 3MF is on disk, return that plate's
+    slicer estimate instead; cost is scaled by the plate's share of the whole so it
+    stays consistent with the scoped grams without re-doing the filament price lookup.
+    Falls back to the archive's whole-file values when there's no plate to scope to.
+    """
+    whole_grams = archive.filament_used_grams
+    if archive.plate_id is None or full_path is None or not full_path.exists():
+        return whole_grams, archive.cost
+    try:
+        from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+        plate_grams = extract_plate_metadata_from_3mf(full_path, archive.plate_id).filament_used_grams
+    except Exception as exc:
+        logging.getLogger(__name__).debug(
+            "[#2614] plate-scoped estimate failed for archive %s (plate %s): %s",
+            archive.id,
+            archive.plate_id,
+            exc,
+        )
+        return whole_grams, archive.cost
+    if not plate_grams or plate_grams <= 0:
+        return whole_grams, archive.cost
+    plate_cost = archive.cost
+    if archive.cost and whole_grams and whole_grams > 0:
+        plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
+    return round(plate_grams, 2), plate_cost
+
+
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     stored_ams_mapping = data.get("ams_mapping")
     stored_ams_mapping = data.get("ams_mapping")
@@ -4791,9 +4826,19 @@ async def on_print_complete(printer_id: int, data: dict):
                 # math (failed / cancelled / stopped get scaled to progress
                 # math (failed / cancelled / stopped get scaled to progress
                 # or to tracked spool deltas).
                 # or to tracked spool deltas).
                 _run_status = data.get("status", "completed")
                 _run_status = data.get("status", "completed")
+                # #2614: scope the per-run estimate to the printed plate. For a
+                # multi-plate 3MF dispatched one plate at a time, the archive's
+                # filament/cost are the whole-file totals; the PrintLogEntry must
+                # reflect only this plate. No effect on single-plate archives (the
+                # plate estimate equals the whole-file value) or on the tracker
+                # path (measured spool deltas win in _compute_run_filament_grams).
+                _est_full_path = (
+                    app_settings.base_dir / archive.file_path if archive.file_path else None
+                )  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
+                _est_grams, _est_cost = _plate_scoped_run_estimate(archive, _est_full_path)
                 _run_grams = _compute_run_filament_grams(
                 _run_grams = _compute_run_filament_grams(
                     _run_status,
                     _run_status,
-                    archive.filament_used_grams,
+                    _est_grams,
                     data.get("progress"),
                     data.get("progress"),
                     usage_results,
                     usage_results,
                 )
                 )
@@ -4806,7 +4851,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 if usage_results:
                 if usage_results:
                     _run_cost = sum(r.get("cost") or 0 for r in usage_results) or None
                     _run_cost = sum(r.get("cost") or 0 for r in usage_results) or None
                 if _run_cost is None and _run_status == "completed":
                 if _run_cost is None and _run_status == "completed":
-                    _run_cost = archive.cost
+                    _run_cost = _est_cost
 
 
                 await write_log_entry(
                 await write_log_entry(
                     db,
                     db,

+ 15 - 0
backend/app/schemas/slicer.py

@@ -82,6 +82,21 @@ class SliceRequest(BaseModel):
         default=False,
         default=False,
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
     )
     )
+    use_embedded_settings: bool = Field(
+        default=False,
+        description=(
+            "3MF only. Slice using the file's embedded "
+            "``Metadata/project_settings.config`` (the designer's own tweaks — wall "
+            "count, infill, etc.) instead of the picked printer/process/filament "
+            "triplet. This is the 'slice as designed' path: no ``--load-settings`` "
+            "override, so a MakerWorld author's settings survive. Ignored for STL / "
+            "plain-model 3MF (no embedded profile to honour). The preset refs are "
+            "still required by the validator but go unused on this path. Only makes "
+            "sense when the picked printer matches the design's target model — the "
+            "UI gates the toggle on that; there is no cross-printer re-targeting here "
+            "(that is exactly what the profile path is for)."
+        ),
+    )
     bed_type: str | None = Field(
     bed_type: str | None = Field(
         default=None,
         default=None,
         max_length=64,
         max_length=64,

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

@@ -534,6 +534,103 @@ class TestSliceLibraryFile:
         assert "Metadata/cut_information.xml" in names
         assert "Metadata/cut_information.xml" in names
         assert "3D/3dmodel.model" in names
         assert "3D/3dmodel.model" in names
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_use_embedded_settings_skips_profile_triplet(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        # "Slice as designed" (#2611): with use_embedded_settings the 3MF is
+        # sliced on its own project_settings.config — no --load-settings — so
+        # the sidecar request carries ONLY the model file, never the
+        # printer/process/filament profile parts. Succeeds on the first call
+        # (no crash-fallback), and the result is flagged used_embedded_settings.
+        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "designed.3mf"
+        src_3mf_path.write_bytes(_make_3mf_with_settings({"wall_loops": "5"}))
+        threemf = LibraryFile(
+            filename="designed.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)
+
+        captured: dict = {}
+        call_count = {"n": 0}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            call_count["n"] += 1
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _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"],
+                "use_embedded_settings": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is True
+        assert call_count["n"] == 1  # embedded path taken directly, no fallback retry
+
+        # The multipart body must NOT carry any profile part — that is the
+        # whole point of the mode. Their presence would mean --load-settings
+        # ran and overrode the designer's embedded settings.
+        body = captured["body"]
+        assert b"printerProfile" not in body
+        assert b"presetProfile" not in body
+        assert b"filamentProfile" not in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_use_embedded_settings_ignored_for_stl(self, async_client: AsyncClient, slice_test_setup):
+        # An STL has no embedded project settings to honour, so the flag is a
+        # no-op: the normal profile path runs and the triplet is forwarded.
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "1",
+                    "x-filament-used-g": "0",
+                    "x-filament-used-mm": "0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_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"],
+                "use_embedded_settings": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is False
+        assert b"printerProfile" in captured["body"]  # profile path still ran
+
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # GET /slice-jobs/{id}
 # GET /slice-jobs/{id}

+ 75 - 1
backend/tests/unit/test_run_filament_helper.py

@@ -6,7 +6,10 @@ don't inflate stats with the full slicer estimate, and tracker-aware so
 completed prints agree with the per-spool counter on the Inventory page.
 completed prints agree with the per-spool counter on the Inventory page.
 """
 """
 
 
-from backend.app.main import _compute_run_filament_grams
+from types import SimpleNamespace
+
+import backend.app.utils.threemf_tools as threemf_tools
+from backend.app.main import _compute_run_filament_grams, _plate_scoped_run_estimate
 
 
 
 
 class TestComputeRunFilamentGrams:
 class TestComputeRunFilamentGrams:
@@ -69,3 +72,74 @@ class TestComputeRunFilamentGrams:
     def test_completed_with_none_estimate_returns_none(self):
     def test_completed_with_none_estimate_returns_none(self):
         # Archive somehow has no estimate (rare; archive_print parsed nothing).
         # Archive somehow has no estimate (rare; archive_print parsed nothing).
         assert _compute_run_filament_grams("completed", None, 100, []) is None
         assert _compute_run_filament_grams("completed", None, 100, []) is None
+
+
+class TestPlateScopedRunEstimate:
+    """#2614: a plate dispatched from a multi-plate 3MF must log only that plate's
+    filament/cost, not the archive's whole-file totals."""
+
+    def _archive(self, **kw):
+        return SimpleNamespace(
+            id=1,
+            plate_id=kw.get("plate_id", 3),
+            filament_used_grams=kw.get("filament_used_grams", 12006.49),
+            cost=kw.get("cost", 240.13),
+            file_path=kw.get("file_path", "archive/1/heart.gcode.3mf"),
+        )
+
+    def _patch_plate_grams(self, monkeypatch, grams):
+        monkeypatch.setattr(
+            threemf_tools,
+            "extract_plate_metadata_from_3mf",
+            lambda path, plate_id: SimpleNamespace(filament_used_grams=grams),
+        )
+
+    def test_scopes_grams_and_scales_cost_to_plate(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), f)
+        assert grams == 350.0
+        # cost scaled by the plate's share of the whole-file grams.
+        assert cost == round(240.13 * (350.0 / 12006.49), 2)
+
+    def test_no_plate_id_returns_whole_file_values(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        # Extractor must not even be consulted.
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(plate_id=None), f)
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_missing_file_returns_whole_file_values(self, monkeypatch):
+        from pathlib import Path
+
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), Path("/nope/gone.3mf"))
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_zero_plate_estimate_falls_back(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        self._patch_plate_grams(monkeypatch, 0.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), f)
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_extractor_error_falls_back(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+
+        def _boom(path, plate_id):
+            raise ValueError("corrupt 3mf")
+
+        monkeypatch.setattr(threemf_tools, "extract_plate_metadata_from_3mf", _boom)
+        grams, cost = _plate_scoped_run_estimate(self._archive(), f)
+        assert (grams, cost) == (12006.49, 240.13)
+
+    def test_no_archive_cost_keeps_cost_none(self, monkeypatch, tmp_path):
+        f = tmp_path / "heart.gcode.3mf"
+        f.write_bytes(b"stub")
+        self._patch_plate_grams(monkeypatch, 350.0)
+        grams, cost = _plate_scoped_run_estimate(self._archive(cost=None), f)
+        assert grams == 350.0
+        assert cost is None

+ 217 - 0
backend/tests/unit/test_run_filament_plate_scope_2614.py

@@ -0,0 +1,217 @@
+"""Backfill for whole-file filament mis-copied onto per-plate print-log rows (#2614).
+
+A plate dispatched from a multi-plate 3MF, when the AMS tracker measured nothing,
+logged the archive's whole-file filament (the sum over every plate) into
+PrintLogEntry.filament_used_grams — inflating stats by the plate count. The
+forward fix scopes new rows; _migrate_scope_run_filament_to_plate repairs the
+rows already written, touching only the exact whole-file mis-copies.
+"""
+
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.utils.threemf_tools as threemf_tools
+from backend.app.core import database as database_module
+from backend.app.core.database import Base, _migrate_scope_run_filament_to_plate
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.printer import Printer
+
+WHOLE = 12006.49  # 22-plate file total
+PLATE = 350.0  # the printed plate's own estimate
+COST = 240.13  # whole-file cost
+
+
+@pytest.fixture
+async def engine(tmp_path):
+    eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    try:
+        yield eng
+    finally:
+        await eng.dispose()
+
+
+@pytest.fixture
+def stub_3mf(tmp_path, monkeypatch):
+    """A stub file on disk + a patched extractor returning the plate estimate."""
+    monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
+    fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
+    fp.parent.mkdir(parents=True)
+    fp.write_bytes(b"stub")
+    monkeypatch.setattr(
+        threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
+    )
+    return "archive/1/heart.gcode.3mf"
+
+
+async def _archive(db, file_path, *, plate_id=3, whole=WHOLE, cost=COST):
+    p = Printer(name="P", serial_number="S", ip_address="1.1.1.1", access_code="c", model="X1C")
+    db.add(p)
+    await db.flush()
+    a = PrintArchive(
+        filename="heart.gcode.3mf",
+        file_path=file_path,
+        file_size=1,
+        status="completed",
+        plate_id=plate_id,
+        filament_used_grams=whole,
+        cost=cost,
+    )
+    db.add(a)
+    await db.flush()
+    return a
+
+
+@pytest.mark.asyncio
+async def test_rescopes_miscopied_row_and_scales_cost(engine, stub_3mf):
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(mis)
+        await db.commit()
+        mis_id = mis.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        fixed = await db.get(PrintLogEntry, mis_id)
+        assert fixed.filament_used_grams == PLATE
+        assert fixed.cost == round(COST * (PLATE / WHOLE), 2)
+
+
+@pytest.mark.asyncio
+async def test_leaves_tracker_measured_and_partial_rows_alone(engine, stub_3mf):
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        # Measured spool delta (rounded), != whole-file → must be untouched.
+        tracked = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=96.5, cost=2.0)
+        # A partial (failed) run scaled to progress, != whole-file → untouched.
+        partial = PrintLogEntry(archive_id=a.id, status="failed", filament_used_grams=1200.6, cost=24.0)
+        db.add_all([tracked, partial])
+        await db.commit()
+        tracked_id, partial_id = tracked.id, partial.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, tracked_id)).filament_used_grams == 96.5
+        assert (await db.get(PrintLogEntry, partial_id)).filament_used_grams == 1200.6
+
+
+@pytest.mark.asyncio
+async def test_idempotent_second_run_is_a_noop(engine, stub_3mf):
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(mis)
+        await db.commit()
+        mis_id = mis.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == PLATE
+
+
+@pytest.mark.asyncio
+async def test_one_shot_gate_prevents_rescan_on_later_boots(engine, stub_3mf):
+    """After the first pass writes its settings flag, a later boot does no work —
+    the migration must never re-scan the print log every startup (single-plate rows
+    legitimately match the whole-file==plate signature forever, so an ungated
+    version would re-parse every single-plate 3MF on each boot)."""
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, stub_3mf)
+        first = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(first)
+        await db.commit()
+        first_id, archive_id = first.id, a.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)  # fixes `first`, writes the flag
+
+    # A fresh mis-copy appears after the one-shot already ran.
+    async with sm() as db:
+        later = PrintLogEntry(archive_id=archive_id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(later)
+        await db.commit()
+        later_id = later.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)  # gate short-circuits; no scan
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, first_id)).filament_used_grams == PLATE
+        # Deliberately untouched: the gate skipped the whole pass. New mis-copies
+        # can't occur anyway — the forward fix scopes every row at write time.
+        assert (await db.get(PrintLogEntry, later_id)).filament_used_grams == WHOLE
+
+
+@pytest.mark.asyncio
+async def test_skips_row_when_3mf_missing(engine, tmp_path, monkeypatch):
+    # base_dir set, but the archive's file was never on disk → row is left alone
+    # (can't compute a plate value; don't guess).
+    monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
+    monkeypatch.setattr(
+        threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda path, plate_id: SimpleNamespace(filament_used_grams=PLATE),
+    )
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, "archive/1/gone.gcode.3mf")
+        mis = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(mis)
+        await db.commit()
+        mis_id = mis.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        assert (await db.get(PrintLogEntry, mis_id)).filament_used_grams == WHOLE
+
+
+@pytest.mark.asyncio
+async def test_single_plate_archive_not_relabelled(engine, tmp_path, monkeypatch):
+    # A genuine single-plate archive whose plate estimate equals the whole-file
+    # value must not be rewritten (no-op guard on unchanged grams).
+    monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
+    fp = tmp_path / "archive" / "1" / "heart.gcode.3mf"
+    fp.parent.mkdir(parents=True)
+    fp.write_bytes(b"stub")
+    monkeypatch.setattr(
+        threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda path, plate_id: SimpleNamespace(filament_used_grams=WHOLE),
+    )
+    sm = async_sessionmaker(engine, expire_on_commit=False)
+    async with sm() as db:
+        a = await _archive(db, "archive/1/heart.gcode.3mf", plate_id=1)
+        row = PrintLogEntry(archive_id=a.id, status="completed", filament_used_grams=WHOLE, cost=COST)
+        db.add(row)
+        await db.commit()
+        row_id = row.id
+
+    async with engine.begin() as conn:
+        await _migrate_scope_run_filament_to_plate(conn)
+
+    async with sm() as db:
+        fixed = await db.get(PrintLogEntry, row_id)
+        assert fixed.filament_used_grams == WHOLE
+        assert fixed.cost == COST

+ 73 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -259,6 +259,79 @@ describe('SliceModal', () => {
     await waitFor(() => expect(onClose).toHaveBeenCalled());
     await waitFor(() => expect(onClose).toHaveBeenCalled());
   });
   });
 
 
+  it('offers "use the file\'s built-in settings" when the printer matches the design, and sends the flag (#2611)', async () => {
+    const onClose = vi.fn();
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    // A project 3MF whose embedded printer matches a listed preset — the
+    // printer pre-pick lands on it, so selectedPrinterName === embedded and
+    // the "slice as designed" toggle is offered.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Designed.3mf',
+      plates: [],
+      is_multi_plate: false,
+      embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle',
+      embedded_process: '0.20mm Standard',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose,
+    });
+
+    const user = userEvent.setup();
+    const toggle = (await screen.findByLabelText(
+      /Use the file's built-in settings/,
+    )) as HTMLInputElement;
+    expect(toggle.checked).toBe(false);
+
+    // All preset dropdowns are live until the toggle is on, then bypassed —
+    // the printer included, so changing it can't silently drop the mode.
+    const printerSelect = presetSelects()[0];
+    const processSelect = presetSelects()[1];
+    expect(printerSelect.disabled).toBe(false);
+    expect(processSelect.disabled).toBe(false);
+    await user.click(toggle);
+    expect(printerSelect.disabled).toBe(true);
+    expect(processSelect.disabled).toBe(true);
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+        100,
+        expect.objectContaining({ use_embedded_settings: true }),
+      );
+    });
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('hides the embedded-settings toggle when the picked printer differs from the design (#2611)', async () => {
+    // Embedded target is a model with no matching preset in the listing, so
+    // the printer pre-pick falls back to the local default (Imported X1C),
+    // which does not match — honouring embedded settings would risk the
+    // wrong bed, so the toggle stays hidden.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Designed.3mf',
+      plates: [],
+      is_multi_plate: false,
+      embedded_printer: 'Bambu Lab P1S 0.4 nozzle',
+      embedded_process: '0.20mm Standard',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
+    expect(screen.queryByLabelText(/Use the file's built-in settings/)).toBeNull();
+  });
+
   it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
   it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
     const onClose = vi.fn();
     const onClose = vi.fn();
     mockApi.sliceLibraryFile.mockResolvedValue({
     mockApi.sliceLibraryFile.mockResolvedValue({

+ 4 - 3
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -104,14 +104,15 @@ describe('SpoolBuddyLayout', () => {
 
 
   it('suppresses the global toast viewport while mounted', () => {
   it('suppresses the global toast viewport while mounted', () => {
     const { unmount } = renderLayout();
     const { unmount } = renderLayout();
-    // Visible viewport gets `hidden` class while the kiosk is up.
-    const viewport = document.querySelector('div.fixed.bottom-4.right-20');
+    // Visible viewport gets `hidden` class while the kiosk is up. Position is
+    // set via safe-area calc() (#2612) so match the stable data-testid.
+    const viewport = document.querySelector('[data-testid="toast-viewport"]');
     expect(viewport?.className).toContain('hidden');
     expect(viewport?.className).toContain('hidden');
 
 
     // Cleanup restores the viewport when the kiosk unmounts (e.g. user
     // Cleanup restores the viewport when the kiosk unmounts (e.g. user
     // navigates back to the main app).
     // navigates back to the main app).
     unmount();
     unmount();
-    const viewportAfter = document.querySelector('div.fixed.bottom-4.right-20');
+    const viewportAfter = document.querySelector('[data-testid="toast-viewport"]');
     // After unmount the toast container is gone with the provider; the
     // After unmount the toast container is gone with the provider; the
     // important guarantee is the suppression flag was untoggled, which the
     // important guarantee is the suppression flag was untoggled, which the
     // ToastContext tests pin directly. Here we only assert no crash on
     // ToastContext tests pin directly. Here we only assert no crash on

+ 24 - 2
frontend/src/__tests__/contexts/ToastContext.test.tsx

@@ -118,8 +118,9 @@ describe('ToastContext viewport suppression', () => {
       </ToastProvider>
       </ToastProvider>
     );
     );
 
 
-    // Toast viewport is the fixed-position container with bottom-4 right-20.
-    const findViewport = () => container.querySelector('div.fixed.bottom-4.right-20');
+    // Toast viewport is the fixed-position container; position is set via
+    // safe-area calc() (#2612) so match the stable data-testid, not classes.
+    const findViewport = () => container.querySelector('[data-testid="toast-viewport"]');
     expect(findViewport()?.className).not.toContain('hidden');
     expect(findViewport()?.className).not.toContain('hidden');
 
 
     act(() => {
     act(() => {
@@ -140,4 +141,25 @@ describe('ToastContext viewport suppression', () => {
     });
     });
     expect(findViewport()?.className).not.toContain('hidden');
     expect(findViewport()?.className).not.toContain('hidden');
   });
   });
+
+  it('caps every toast to the viewport width so it cannot run off-screen (#2612)', () => {
+    const { container, getByTestId } = render(
+      <ToastProvider>
+        <ViewportProbe />
+      </ToastProvider>
+    );
+
+    act(() => {
+      getByTestId('show-toast').click();
+    });
+
+    // The fixed-width dispatch toast (420px) overflowed the left edge of a
+    // phone in an installed PWA. Every toast now carries a viewport-relative
+    // max-width so it stays on-screen; pin it here.
+    const viewport = container.querySelector('[data-testid="toast-viewport"]');
+    const toast = viewport?.querySelector<HTMLElement>('div[style]');
+    expect(toast?.style.maxWidth).toContain('100vw');
+    expect(toast?.style.maxWidth).toContain('safe-area-inset-left');
+    expect(toast?.style.maxWidth).toContain('safe-area-inset-right');
+  });
 });
 });

+ 5 - 0
frontend/src/api/client.ts

@@ -1543,6 +1543,11 @@ export interface SliceRequest {
   // "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
   // "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
   // "Supertack Plate".
   // "Supertack Plate".
   bed_type?: string | null;
   bed_type?: string | null;
+  // "Slice as designed" (#2611). 3MF only: slice using the file's embedded
+  // project_settings.config (the designer's own wall count, infill, etc.)
+  // instead of the picked profile triplet. The preset refs above are still
+  // required by the backend validator but go unused on this path.
+  use_embedded_settings?: boolean;
 }
 }
 
 
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.

+ 61 - 4
frontend/src/components/SliceModal.tsx

@@ -220,6 +220,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // user had no way to switch plates without cloning the preset.
   // user had no way to switch plates without cloning the preset.
   const [bedType, setBedType] = useState<string | null>(null);
   const [bedType, setBedType] = useState<string | null>(null);
 
 
+  // "Slice as designed" (#2611). When on, the backend honours the source
+  // 3MF's embedded project_settings.config (the designer's own wall count,
+  // infill, etc.) instead of the picked process/filament profiles. Only
+  // offered when the picked printer matches the design's target model —
+  // see canUseEmbedded below.
+  const [useEmbedded, setUseEmbedded] = useState(false);
+
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // with one pick, or save the current selection as a new pipeline.
   // with one pick, or save the current selection as a new pipeline.
   const pipelinesQuery = useQuery({
   const pipelinesQuery = useQuery({
@@ -380,6 +387,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
   const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
   const embeddedProcess = platesQuery.data?.embedded_process ?? null;
   const embeddedProcess = platesQuery.data?.embedded_process ?? null;
 
 
+  // "Slice as designed" is offered only when the source carries embedded
+  // settings (a real project 3MF, not an STL) AND the picked printer matches
+  // the design's target model. The match gate is load-bearing: honouring
+  // embedded settings for a different model would place the model on the
+  // wrong bed. Names come from the same preset namespace, so a normalised
+  // (strip "# " prefix, case-fold) equality is enough.
+  const canUseEmbedded = useMemo<boolean>(() => {
+    if (!embeddedPrinter || !embeddedProcess || !selectedPrinterName) return false;
+    const norm = (s: string) => s.replace(/^#\s*/, '').trim().toLowerCase();
+    return norm(selectedPrinterName) === norm(embeddedPrinter);
+  }, [embeddedPrinter, embeddedProcess, selectedPrinterName]);
+
+  // Drop back to profile slicing whenever the toggle stops being offered
+  // (e.g. the user switches to a printer that doesn't match the design).
+  useEffect(() => {
+    if (!canUseEmbedded) setUseEmbedded(false);
+  }, [canUseEmbedded]);
+
   // Printer pre-pick: defaults to the printer the 3MF was prepared for when
   // Printer pre-pick: defaults to the printer the 3MF was prepared for when
   // that preset is available, else the first listed printer. Runs once when
   // that preset is available, else the first listed printer. Runs once when
   // presets first arrive; later re-renders preserve any manual choice.
   // presets first arrive; later re-renders preserve any manual choice.
@@ -476,6 +501,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       filament_presets: filamentPresets as PresetRef[],
       filament_presets: filamentPresets as PresetRef[],
       ...(plate != null ? { plate } : {}),
       ...(plate != null ? { plate } : {}),
       ...(bedType != null ? { bed_type: bedType } : {}),
       ...(bedType != null ? { bed_type: bedType } : {}),
+      // The preset refs above are still sent (the backend validator requires
+      // them) but go unused when this flag is set — the slicer falls back on
+      // the file's embedded project_settings.config instead.
+      ...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
     };
     };
   }
   }
 
 
@@ -710,25 +739,53 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 data={presetsQuery.data}
                 data={presetsQuery.data}
                 value={printerPreset}
                 value={printerPreset}
                 onChange={setPrinterPreset}
                 onChange={setPrinterPreset}
-                disabled={isEnqueuing}
+                // Locked in embedded mode too: the picked printer is unused on
+                // the embedded-settings path, and changing it away from the
+                // design's target would drop canUseEmbedded and yank the toggle
+                // out from under the user (#2611).
+                disabled={isEnqueuing || useEmbedded}
               />
               />
+              {/* "Slice as designed" (#2611): honour the file's embedded
+                  settings instead of the picked process/filament. Offered
+                  only when the picked printer matches the design's target. */}
+              {canUseEmbedded && (
+                <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
+                  <input
+                    type="checkbox"
+                    checked={useEmbedded}
+                    onChange={(e) => setUseEmbedded(e.target.checked)}
+                    disabled={isEnqueuing}
+                    className="mt-0.5 cursor-pointer"
+                  />
+                  <span>
+                    {t('slice.useEmbedded')}
+                    <span className="block text-xs text-bambu-gray/70">
+                      {t('slice.useEmbeddedHint')}
+                    </span>
+                  </span>
+                </label>
+              )}
               <PresetDropdown
               <PresetDropdown
                 label={t('slice.process')}
                 label={t('slice.process')}
                 slot="process"
                 slot="process"
                 data={presetsQuery.data}
                 data={presetsQuery.data}
                 value={processPreset}
                 value={processPreset}
                 onChange={setProcessPreset}
                 onChange={setProcessPreset}
-                disabled={isEnqueuing}
+                disabled={isEnqueuing || useEmbedded}
                 selectedPrinterName={selectedPrinterName}
                 selectedPrinterName={selectedPrinterName}
                 compatIndex={compatIndex}
                 compatIndex={compatIndex}
               />
               />
               {/* Bed-type override (#1337). Always visible, always enabled.
               {/* Bed-type override (#1337). Always visible, always enabled.
                   The backend patches curr_bed_type on the resolved process
                   The backend patches curr_bed_type on the resolved process
                   JSON before forwarding to the sidecar. */}
                   JSON before forwarding to the sidecar. */}
+              {/* Bed-type patches curr_bed_type onto the resolved process
+                  JSON, which the embedded-settings path never sends — so it
+                  has no effect there and is disabled to avoid implying it
+                  does. */}
               <BedTypeDropdown
               <BedTypeDropdown
                 value={bedType}
                 value={bedType}
                 onChange={setBedType}
                 onChange={setBedType}
-                disabled={isEnqueuing}
+                disabled={isEnqueuing || useEmbedded}
               />
               />
               {/* Filament reqs may need a server-side preview-slice for
               {/* Filament reqs may need a server-side preview-slice for
                   unsliced project files (single-pass, then cached). Show a
                   unsliced project files (single-pass, then cached). Show a
@@ -775,7 +832,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                           return next;
                           return next;
                         })
                         })
                       }
                       }
-                      disabled={isEnqueuing || !isUsed}
+                      disabled={isEnqueuing || !isUsed || useEmbedded}
                       swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
                       swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
                       selectedPrinterName={selectedPrinterName}
                       selectedPrinterName={selectedPrinterName}
                       compatIndex={compatIndex}
                       compatIndex={compatIndex}

+ 25 - 4
frontend/src/contexts/ToastContext.tsx

@@ -316,14 +316,32 @@ export function ToastProvider({ children }: { children: ReactNode }) {
 
 
       {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
       {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
           The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
           The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
-          free of main-app notifications. */}
-      <div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
+          free of main-app notifications.
+          Position is set via safe-area-aware calc() rather than bottom-4/right-20 so an
+          installed PWA on a notched phone clears the home indicator / landscape notch
+          (#2612): the 5rem right offset keeps clearance for the bug bubble. */}
+      <div
+        data-testid="toast-viewport"
+        className={`fixed z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}
+        style={{
+          bottom: 'calc(1rem + env(safe-area-inset-bottom))',
+          right: 'calc(5rem + env(safe-area-inset-right))',
+        }}
+      >
         {toasts.map((toast) => (
         {toasts.map((toast) => (
           <div
           <div
             key={toast.id}
             key={toast.id}
             className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
             className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
               toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
               toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
             }`}
             }`}
+            // Cap width to the viewport so the fixed-width dispatch toast (420px)
+            // can't run off the left edge on a phone (#2612). At the cap the toast
+            // sits 1rem + safe-area from the left; on desktop the 420px wins. The
+            // 6rem = the 5rem right offset above + a 1rem left gutter.
+            style={{
+              maxWidth:
+                'calc(100vw - 6rem - env(safe-area-inset-left) - env(safe-area-inset-right))',
+            }}
             data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
             data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
           >
           >
             {toast.dispatchData ? (
             {toast.dispatchData ? (
@@ -389,11 +407,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
                           data-testid={`dispatch-toast-job-${job.jobId}`}
                           data-testid={`dispatch-toast-job-${job.jobId}`}
                         >
                         >
                           <div className="flex items-center justify-between gap-2">
                           <div className="flex items-center justify-between gap-2">
-                            <span className="text-xs text-white truncate" title={job.sourceName}>
+                            {/* min-w-0 + flex-1 lets truncate actually kick in
+                                when the toast is capped to a phone's width
+                                (#2612); the status chip stays put with shrink-0. */}
+                            <span className="text-xs text-white truncate min-w-0 flex-1" title={job.sourceName}>
                               {job.sourceName}
                               {job.sourceName}
                             </span>
                             </span>
                             <span
                             <span
-                              className="text-[11px] uppercase tracking-wide text-bambu-gray"
+                              className="text-[11px] uppercase tracking-wide text-bambu-gray shrink-0"
                               data-testid={`dispatch-toast-status-${job.jobId}`}
                               data-testid={`dispatch-toast-status-${job.jobId}`}
                             >
                             >
                               {t(`dispatchToast.status.${job.status}`)}
                               {t(`dispatchToast.status.${job.status}`)}

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -4032,6 +4032,8 @@ export default {
     refreshPresets: 'Aktualisieren',
     refreshPresets: 'Aktualisieren',
     refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
     refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
+    useEmbedded: 'Eingebettete Einstellungen der Datei verwenden',
+    useEmbeddedHint: 'So slicen, wie der Ersteller es angelegt hat (Wände, Füllung, Filament), statt mit den obigen Profilen. Verfügbar, weil dein Drucker zur Datei passt.',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     enqueuing: 'Slice-Auftrag wird übermittelt…',
     queued: 'In Warteschlange…',
     queued: 'In Warteschlange…',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
     failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -4066,6 +4066,8 @@ export default {
     refreshPresets: 'Refresh',
     refreshPresets: 'Refresh',
     refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
     refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
     allPresetsRequired: 'All presets must be selected',
     allPresetsRequired: 'All presets must be selected',
+    useEmbedded: "Use the file's built-in settings",
+    useEmbeddedHint: "Slice it the way the designer set it up (walls, infill, filament) instead of the profiles above. Offered because your printer matches the file's.",
     enqueuing: 'Submitting slice job…',
     enqueuing: 'Submitting slice job…',
     queued: 'Queued…',
     queued: 'Queued…',
     failed: 'Slicing failed. Check the slicer sidecar logs.',
     failed: 'Slicing failed. Check the slicer sidecar logs.',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -4035,6 +4035,8 @@ export default {
     refreshPresets: 'Actualizar',
     refreshPresets: 'Actualizar',
     refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
     refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
+    useEmbedded: 'Usar la configuración incorporada del archivo',
+    useEmbeddedHint: 'Laminar tal como lo configuró el diseñador (perímetros, relleno, filamento) en lugar de los perfiles de arriba. Disponible porque tu impresora coincide con la del archivo.',
     enqueuing: 'Enviando el trabajo de laminado…',
     enqueuing: 'Enviando el trabajo de laminado…',
     queued: 'En cola…',
     queued: 'En cola…',
     failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',
     failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -4021,6 +4021,8 @@ export default {
     refreshPresets: 'Actualiser',
     refreshPresets: 'Actualiser',
     refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
     refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
+    useEmbedded: 'Utiliser les réglages intégrés du fichier',
+    useEmbeddedHint: "Slicer tel que le concepteur l'a configuré (parois, remplissage, filament) au lieu des profils ci-dessus. Proposé car votre imprimante correspond à celle du fichier.",
     enqueuing: 'Envoi du travail de découpage…',
     enqueuing: 'Envoi du travail de découpage…',
     queued: 'En file d\'attente…',
     queued: 'En file d\'attente…',
     failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',
     failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: 'Aggiorna',
     refreshPresets: 'Aggiorna',
     refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
     refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
+    useEmbedded: 'Usa le impostazioni integrate del file',
+    useEmbeddedHint: 'Slicia come impostato dal designer (pareti, riempimento, filamento) invece dei profili sopra. Disponibile perché la tua stampante corrisponde a quella del file.',
     enqueuing: 'Invio lavoro di slicing…',
     enqueuing: 'Invio lavoro di slicing…',
     queued: 'In coda…',
     queued: 'In coda…',
     failed: 'Slicing fallito. Controlla i log del sidecar.',
     failed: 'Slicing fallito. Controlla i log del sidecar.',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -4032,6 +4032,8 @@ export default {
     refreshPresets: '再読み込み',
     refreshPresets: '再読み込み',
     refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
     refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
+    useEmbedded: 'ファイルに埋め込まれた設定を使用',
+    useEmbeddedHint: '上のプロファイルではなく、設計者が設定したとおり(ウォール、インフィル、フィラメント)にスライスします。お使いのプリンターがファイルと一致するため利用できます。',
     enqueuing: 'スライスジョブを送信中…',
     enqueuing: 'スライスジョブを送信中…',
     queued: '待機中…',
     queued: '待機中…',
     failed: 'スライスに失敗。サイドカーのログを確認してください。',
     failed: 'スライスに失敗。サイドカーのログを確認してください。',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -3821,6 +3821,8 @@ export default {
     refreshPresets: '새로 고침',
     refreshPresets: '새로 고침',
     refreshPresetsTitle: '프리셋 새로 고침 — 최신 클라우드 및 번들 목록 가져오기 (Bambu Studio 또는 Bambu Handy에서 프리셋 삭제 후 사용)',
     refreshPresetsTitle: '프리셋 새로 고침 — 최신 클라우드 및 번들 목록 가져오기 (Bambu Studio 또는 Bambu Handy에서 프리셋 삭제 후 사용)',
     allPresetsRequired: '모든 프리셋을 선택해야 합니다',
     allPresetsRequired: '모든 프리셋을 선택해야 합니다',
+    useEmbedded: '파일에 포함된 설정 사용',
+    useEmbeddedHint: '위 프로필 대신 디자이너가 설정한 대로(벽, 내부 채움, 필라멘트) 슬라이싱합니다. 프린터가 파일과 일치하여 사용할 수 있습니다.',
     enqueuing: '슬라이싱 작업 제출 중…',
     enqueuing: '슬라이싱 작업 제출 중…',
     queued: '대기 중…',
     queued: '대기 중…',
     failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',
     failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',

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

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: 'Atualizar',
     refreshPresets: 'Atualizar',
     refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
     refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
+    useEmbedded: 'Usar as configurações incorporadas do arquivo',
+    useEmbeddedHint: 'Fatiar como o designer configurou (paredes, preenchimento, filamento) em vez dos perfis acima. Disponível porque sua impressora corresponde à do arquivo.',
     enqueuing: 'Enviando trabalho de fatiamento…',
     enqueuing: 'Enviando trabalho de fatiamento…',
     queued: 'Na fila…',
     queued: 'Na fila…',
     failed: 'Falha ao fatiar. Verifique os logs do sidecar.',
     failed: 'Falha ao fatiar. Verifique os logs do sidecar.',

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -4022,6 +4022,8 @@ export default {
     refreshPresets: 'Yenile',
     refreshPresets: 'Yenile',
     refreshPresetsTitle: "Ön ayarları yenile — en güncel bulut ve paketli listeleri getir (Bambu Studio veya Bambu Handy'de bir ön ayar sildikten sonra kullanın)",
     refreshPresetsTitle: "Ön ayarları yenile — en güncel bulut ve paketli listeleri getir (Bambu Studio veya Bambu Handy'de bir ön ayar sildikten sonra kullanın)",
     allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
     allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
+    useEmbedded: 'Dosyanın yerleşik ayarlarını kullan',
+    useEmbeddedHint: 'Yukarıdaki profiller yerine tasarımcının ayarladığı gibi (duvarlar, dolgu, filament) dilimle. Yazıcınız dosyayla eşleştiği için sunuluyor.',
     enqueuing: 'Dilimleme işi gönderiliyor…',
     enqueuing: 'Dilimleme işi gönderiliyor…',
     queued: 'Kuyrukta…',
     queued: 'Kuyrukta…',
     failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',
     failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: '刷新',
     refreshPresets: '刷新',
     refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
     refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
     allPresetsRequired: '必须选择所有预设',
     allPresetsRequired: '必须选择所有预设',
+    useEmbedded: '使用文件的内置设置',
+    useEmbeddedHint: '按设计者的设置(壁、填充、耗材)切片,而非上方的配置文件。因您的打印机与文件匹配而可用。',
     enqueuing: '提交切片任务中…',
     enqueuing: '提交切片任务中…',
     queued: '已排队…',
     queued: '已排队…',
     failed: '切片失败。请检查切片器 sidecar 日志。',
     failed: '切片失败。请检查切片器 sidecar 日志。',

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

@@ -4020,6 +4020,8 @@ export default {
     refreshPresets: '重新整理',
     refreshPresets: '重新整理',
     refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
     refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
     allPresetsRequired: '必須選擇所有預設',
     allPresetsRequired: '必須選擇所有預設',
+    useEmbedded: '使用檔案的內建設定',
+    useEmbeddedHint: '依設計者的設定(外牆、填充、耗材)切片,而非上方的設定檔。因您的印表機與檔案相符而可用。',
     enqueuing: '提交切片任務中…',
     enqueuing: '提交切片任務中…',
     queued: '已排隊…',
     queued: '已排隊…',
     failed: '切片失敗。請檢查切片器 sidecar 日誌。',
     failed: '切片失敗。請檢查切片器 sidecar 日誌。',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CREN25a-.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bvvb3PBX.js"></script>
+    <script type="module" crossorigin src="/assets/index-CREN25a-.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов