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

feat(vp): per-VP "Save AMS mapping" toggle + reprint auto-apply

    Lets a reprint reuse the AMS slot the slicer itself picked, instead of
    re-deriving one from the file's static type/color.

    When a Print Queue VP has "Save AMS mapping" on, the slicer's own
    live-resolved ams_mapping (from the project_file MQTT command) is
    persisted onto the archive as extra_data.slicer_ams_mapping. A later
    reprint can reuse it via a new "Mapping" button in the filament-mapping
    panel — one click snaps every slot to the saved pick, click again
    reverts to auto-match. Archive cards and queue rows get an "AMS mapping
    saved" badge so it's visible beforehand. add_to_queue also falls back
    to the saved mapping automatically when the caller sends no explicit
    ams_mapping (e.g. a plain reprint with no per-slot edits).

    The queue item's own ams_mapping (used for that dispatch) is still
    captured unconditionally whenever the slicer provides it — that part is
    a correctness fix, not gated behind the toggle. Only the archive
    persistence for future reprints is opt-in.

    Split out from the original combined PR per review: this half is
    genuinely opt-in and low-risk (#2684). The dispatch-time validation
    gate that keeps a stored mapping honest (#1308) changes behaviour for
    every existing user and will land as its own PR.

    Review fixes applied:
    - _extract_slicer_ams_mapping_json: dropped the unreachable `v is None`
      arm and rejected bool explicitly (isinstance(v, int) accepts bool).
    - Translated the Russian docstring text to English.
    - save_ams_mapping's model comment moved to a trailing comment on the
      column line, matching the file's convention.
    - usingArchiveMapping now resets when the plate or archive changes, so
      the Mapping button can't read ON against a mapping it never applied.
    - Translated "Click to change slot assignment" and "Re-read".
    - add_to_queue's fallback is now called out explicitly in code comments
      and covered by three new integration tests (fallback fires, explicit
      mapping wins, unrelated extra_data doesn't false-trigger).
maziggy 1 месяц назад
Родитель
Сommit
a7b96ea9d6

+ 23 - 0
backend/app/api/routes/print_queue.py

@@ -250,6 +250,12 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             response.nozzle_diameter = item.archive.nozzle_diameter
             response.sliced_for_model = item.archive.sliced_for_model
             response.bed_type = item.archive.bed_type
+            # Marks history/reprint rows whose archive carries the slicer's own
+            # live-resolved AMS-slot pick (extra_data.slicer_ams_mapping) — see
+            # `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py.
+            response.archive_has_slicer_ams_mapping = bool(
+                item.archive.extra_data and item.archive.extra_data.get("slicer_ams_mapping")
+            )
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
@@ -643,6 +649,23 @@ async def add_to_queue(
             raise HTTPException(status_code=404, detail="Project not found")
 
     ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
+    # Reprint fallback: the caller didn't specify an explicit ams_mapping (no
+    # per-slot filament-mapping edit was made), but the archive carries the
+    # slicer's own live-resolved AMS-slot pick from the original print (see
+    # `extra_data.slicer_ams_mapping`, written by the VP-queue path via
+    # `_extract_slicer_ams_mapping_json`). Reuse it so the reprint dispatches
+    # to the exact same physical spool instead of the scheduler re-deriving a
+    # (possibly ambiguous) mapping from just the file's static type/color.
+    #
+    # Note this is unconditional — it applies regardless of whether the
+    # physical spool in that slot has changed since the original print. #1308
+    # covers re-verifying a stored mapping against live AMS state at dispatch
+    # time; that check is a separate PR and, once merged, will also catch a
+    # stale slot inherited through this fallback.
+    if ams_mapping_json is None and archive and archive.extra_data:
+        saved_mapping = archive.extra_data.get("slicer_ams_mapping")
+        if isinstance(saved_mapping, list) and saved_mapping:
+            ams_mapping_json = json.dumps(saved_mapping)
     items = []
     for i in range(quantity):
         item = PrintQueueItem(

+ 6 - 0
backend/app/api/routes/virtual_printers.py

@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool = True
     queue_force_color_match: bool = False
+    save_ams_mapping: bool = False
     gcode_injection: bool = False
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
@@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
     queue_force_color_match: bool | None = None
+    save_ams_mapping: bool | None = None
     gcode_injection: bool | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
@@ -109,6 +111,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
         "target_printer_id": vp.target_printer_id,
         "auto_dispatch": vp.auto_dispatch,
         "queue_force_color_match": vp.queue_force_color_match,
+        "save_ams_mapping": vp.save_ams_mapping,
         "gcode_injection": vp.gcode_injection,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
@@ -245,6 +248,7 @@ async def create_virtual_printer(
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
+        save_ams_mapping=body.save_ams_mapping,
         gcode_injection=body.gcode_injection,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
@@ -423,6 +427,8 @@ async def update_virtual_printer(
         vp.auto_dispatch = body.auto_dispatch
     if body.queue_force_color_match is not None:
         vp.queue_force_color_match = body.queue_force_color_match
+    if body.save_ams_mapping is not None:
+        vp.save_ams_mapping = body.save_ams_mapping
     if body.gcode_injection is not None:
         vp.gcode_injection = body.gcode_injection
     if body.bind_ip is not None:

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

@@ -1363,6 +1363,15 @@ async def run_migrations(conn):
             conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
         )
 
+    # Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
+    # when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
+    # onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
+    # Default false to preserve current behaviour for upgraders.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
+
     # Per-VP opt-in for auto-print G-code injection (#1516). Default false so
     # existing gcode_snippets users don't silently start injecting on VP/Studio
     # Send jobs after upgrading.

+ 8 - 0
backend/app/models/virtual_printer.py

@@ -49,6 +49,14 @@ class VirtualPrinter(Base):
     )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
+    save_ams_mapping: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # queue mode: persist the slicer's own live-resolved AMS-slot pick (the
+    # `ams_mapping` field on the MQTT `project_file` command) onto the
+    # archive's `extra_data.slicer_ams_mapping`, so a later reprint can reuse
+    # the exact physical spool instead of re-deriving one from the file's
+    # static type/color. Off by default — archives don't grow this field
+    # unless the user opts in per virtual printer.
     gcode_injection: Mapped[bool] = mapped_column(
         Boolean, server_default="false"
     )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet

+ 5 - 0
backend/app/schemas/print_queue.py

@@ -194,6 +194,11 @@ class PrintQueueItemResponse(BaseModel):
     # 3MFs: when `plate_id` is set, the value is the matching plate's
     # `curr_bed_type` rather than the archive-level first-plate default.
     bed_type: str | None = None
+    # True when the source archive carries the slicer's own live-resolved
+    # AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint of this
+    # archive reuses that exact physical spool instead of the scheduler
+    # re-deriving one from just the file's static type/color.
+    archive_has_slicer_ams_mapping: bool = False
 
     # User tracking (Issue #206)
     created_by_id: int | None = None

+ 12 - 0
backend/app/services/archive.py

@@ -1253,6 +1253,18 @@ class ArchiveService:
         # Merge with print data from MQTT
         if print_data:
             metadata["_print_data"] = print_data
+            # Promote the slicer's own live-resolved AMS-slot pick (captured
+            # from the project_file MQTT command by the VP-queue path — see
+            # `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py)
+            # to a stable top-level extra_data key. Lets a later reprint reuse
+            # the exact tray the user picked/BambuStudio auto-matched at slice
+            # time instead of the scheduler re-deriving one from just the
+            # file's static type/color, which can land on the wrong physical
+            # spool when that match isn't unique. Top-level (not nested under
+            # the `_print_data` diagnostic bag) so API consumers have a single
+            # stable path: `archive.extra_data.slicer_ams_mapping`.
+            if print_data.get("ams_mapping"):
+                metadata["slicer_ams_mapping"] = print_data["ams_mapping"]
 
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"

+ 107 - 4
backend/app/services/virtual_printer/manager.py

@@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
 """
 
 import asyncio
+import json
 import logging
 import time
 from collections.abc import Callable
@@ -154,6 +155,54 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
     return None
 
 
+def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
+    """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
+
+    BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
+    live, right before sending — either automatically or via the slicer's
+    manual per-filament AMS-slot assignment dialog — and embeds the result as
+    ``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
+    ID) directly in the MQTT ``project_file`` command. Confirmed by wire
+    capture: the field is present and already in the exact shape
+    ``PrintQueueItem.ams_mapping`` expects.
+
+    The VP-queue path previously never read this — every queued print had the
+    scheduler re-derive a mapping from just the 3MF's static type/color at
+    dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
+    the slicer's already-correct, live-resolved pick. That re-derivation can
+    land on the wrong physical spool whenever the file's type+color match
+    isn't unique (e.g. two spools of the same color) or the file's own
+    filament-slot color wasn't what the user actually intended for that
+    particular print. Capturing it here — mirroring the existing
+    ``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
+    scheduler's "already resolved, don't touch it" branch in
+    ``_ensure_ams_mapping`` use the slicer's own choice unchanged.
+
+    Returns ``None`` when the field is absent, unparsable, or the classic
+    "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
+    fresh live computation.
+    """
+    raw = data.get("ams_mapping")
+    if raw is None:
+        return None
+    if isinstance(raw, str):
+        try:
+            raw = json.loads(raw)
+        except json.JSONDecodeError:
+            logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
+            return None
+    # bool is a subclass of int in Python — isinstance(True, int) is True —
+    # so it must be excluded explicitly, or [True, False] would pass as a
+    # valid mapping.
+    if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
+        return None
+    if all(v < 0 for v in raw):
+        # #2589 sentinel — every slot unresolved. Let the scheduler compute a
+        # fresh mapping from live AMS state instead of trusting this.
+        return None
+    return json.dumps(raw)
+
+
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
     prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
@@ -181,6 +230,7 @@ class VirtualPrinterInstance:
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         queue_force_color_match: bool = False,
+        save_ams_mapping: bool = False,
         gcode_injection: bool = False,
         bind_ip: str = "",
         remote_interface_ip: str = "",
@@ -204,6 +254,7 @@ class VirtualPrinterInstance:
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.queue_force_color_match = queue_force_color_match
+        self.save_ams_mapping = save_ams_mapping
         self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
@@ -416,8 +467,9 @@ class VirtualPrinterInstance:
         row was already written with settings defaults. This method runs
         on the late MQTT path: it looks up the most recent queue items
         committed for this filename and patches in the slicer's
-        ``nozzle_mapping`` + workflow flags, but only while the items are
-        still ``pending`` (scheduler hasn't dispatched them yet).
+        ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
+        while the items are still ``pending`` (scheduler hasn't dispatched
+        them yet).
         """
         if not self._session_factory:
             return
@@ -469,12 +521,17 @@ class VirtualPrinterInstance:
             if raw is not None:
                 patch["nozzle_mapping"] = json.dumps(raw)
 
+        ams_mapping_json = _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
+        if ams_mapping_json is not None:
+            patch["ams_mapping"] = ams_mapping_json
+
         if not patch:
             self._recent_queue_items.pop(stash_key, None)
             return
 
         from sqlalchemy import select, update
 
+        from backend.app.models.archive import PrintArchive
         from backend.app.models.print_queue import PrintQueueItem
 
         try:
@@ -482,16 +539,36 @@ class VirtualPrinterInstance:
                 # Only stamp items still pending; once the scheduler has
                 # picked the row up we can't safely race the dispatcher.
                 result = await db.execute(
-                    select(PrintQueueItem.id).where(
+                    select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
                         PrintQueueItem.id.in_(queue_item_ids),
                         PrintQueueItem.status == "pending",
                     )
                 )
-                eligible_ids = [row[0] for row in result.all()]
+                rows = result.all()
+                eligible_ids = [row[0] for row in rows]
                 if not eligible_ids:
                     self._recent_queue_items.pop(stash_key, None)
                     return
                 await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+
+                # The archive was already created (with no slicer_ams_mapping)
+                # before this late MQTT arrived — see
+                # `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
+                # too so a reprint later still picks up the slicer's pick, and
+                # the "AMS mapping from slicer" badge reflects reality instead
+                # of staying stuck on the archive's initial (empty) snapshot.
+                # Opt-in per VP, same as the immediate path in
+                # `_add_to_print_queue` — only the archive persistence is
+                # gated, not the queue item's own `ams_mapping` patched above.
+                if ams_mapping_json is not None and self.save_ams_mapping:
+                    archive_ids = {row[1] for row in rows if row[1] is not None}
+                    if archive_ids:
+                        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
+                        for archive in archive_result.scalars().all():
+                            extra = dict(archive.extra_data or {})
+                            extra["slicer_ams_mapping"] = json.loads(ams_mapping_json)
+                            archive.extra_data = extra
+
                 await db.commit()
                 logger.info(
                     "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
@@ -834,6 +911,16 @@ class VirtualPrinterInstance:
                         if raw is not None:
                             nozzle_mapping_json = json.dumps(raw)
 
+                # Slicer's own live-resolved AMS-slot pick (see docstring on
+                # `_extract_slicer_ams_mapping_json`). Stamped onto every plate
+                # below, same treatment as nozzle_mapping_json above — when
+                # present it makes `_ensure_ams_mapping` skip its own
+                # type/color re-derivation entirely and dispatch use exactly
+                # the tray the slicer/user picked.
+                ams_mapping_json: str | None = None
+                if slicer_opts is not None:
+                    ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
+
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -842,6 +929,17 @@ class VirtualPrinterInstance:
                         "status": "archived",
                         "source": "virtual_printer",
                         "source_ip": source_ip,
+                        # Slicer's own live AMS-slot pick -- promoted to
+                        # `extra_data.slicer_ams_mapping` by archive_print() so
+                        # a later reprint can reuse it. Opt-in per VP
+                        # (`save_ams_mapping`) — only the archive persistence is
+                        # gated; the queue item's own `ams_mapping` (used for
+                        # *this* dispatch, below) is set unconditionally
+                        # whenever the slicer provides it, since that's a
+                        # correctness fix, not a feature toggle.
+                        "ams_mapping": (
+                            json.loads(ams_mapping_json) if ams_mapping_json and self.save_ams_mapping else None
+                        ),
                     },
                     prefer_filename_for_name=prefer_filename,
                 )
@@ -950,6 +1048,9 @@ class VirtualPrinterInstance:
                             # the same nozzle pick across plates rather than only the
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             nozzle_mapping=nozzle_mapping_json,
+                            # Slicer's own live AMS-slot pick, when present —
+                            # see `_extract_slicer_ams_mapping_json`.
+                            ams_mapping=ams_mapping_json,
                         )
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
@@ -1547,6 +1648,7 @@ class VirtualPrinterManager:
                 # instance silently keeps the old value until process
                 # restart (#1552 follow-up family).
                 or instance.queue_force_color_match != vp.queue_force_color_match
+                or instance.save_ams_mapping != vp.save_ams_mapping
                 or instance.gcode_injection != vp.gcode_injection
                 or proxy_target_changed
             )
@@ -1601,6 +1703,7 @@ class VirtualPrinterManager:
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     queue_force_color_match=vp.queue_force_color_match,
+                    save_ams_mapping=vp.save_ams_mapping,
                     gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",

+ 65 - 0
backend/tests/integration/test_print_queue_api.py

@@ -258,6 +258,71 @@ class TestPrintQueueAPI:
         assert result["archive_id"] == archive.id
         assert result["ams_mapping"] == [5, -1, 2, -1]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_falls_back_to_archive_slicer_ams_mapping_when_unset(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """When the caller sends no explicit ams_mapping, but the archive
+        carries the slicer's own saved pick (extra_data.slicer_ams_mapping,
+        written by a VP with "Save AMS mapping" on), the queue item should
+        inherit it — the same exact-physical-spool reuse the "Mapping"
+        button gives you, but automatic when nothing was hand-edited.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [5, -1, 2, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """An explicit ams_mapping in the request (e.g. from the filament
+        mapping panel) must take priority over the archive's saved slicer
+        pick — the fallback only fires when the caller sent nothing at all.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "ams_mapping": [9, -1, 1, -1],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [9, -1, 1, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_archive_extra_data_without_slicer_mapping_key_not_used(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """extra_data present but without a slicer_ams_mapping key (the
+        common case — most archives have other metadata but no saved slicer
+        mapping) must not accidentally trip the fallback."""
+        printer = await printer_factory()
+        archive = await archive_factory(extra_data={"filament_slots": []})
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_plate_id(

+ 250 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -1847,6 +1847,202 @@ class TestVirtualPrinterInstance:
         item = added_items[0]
         assert item.nozzle_mapping is None
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_captures_ams_mapping_on_queue_item_regardless_of_toggle(self, tmp_path):
+        """The slicer's live-resolved AMS-slot pick (`ams_mapping` in the
+        project_file MQTT command) must land on the queue item's own
+        `ams_mapping` column unconditionally — this is what THIS dispatch
+        uses, and is a correctness fix, not a feature. Only persisting it
+        onto the *archive* (for future reprints) is gated behind the
+        per-VP `save_ams_mapping` toggle — see the sibling test below.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=44,
+            name="AMSMappingOff",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800044",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=False,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ) as mock_archive_print,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        item = added_items[0]
+        assert item.ams_mapping is not None
+        assert _json.loads(item.ams_mapping) == [4, -1, 12, -1]
+
+        # Toggle is off: the archive's print_data must NOT carry the mapping.
+        print_data = mock_archive_print.await_args.kwargs["print_data"]
+        assert print_data["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path):
+        """With the per-VP `save_ams_mapping` toggle on, the slicer's AMS
+        pick must also be forwarded to `ArchiveService.archive_print` so it
+        gets promoted to `archive.extra_data.slicer_ams_mapping` and a later
+        reprint can reuse the exact physical spool.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=45,
+            name="AMSMappingOn",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800045",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ) as mock_archive_print,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1]
+
+        print_data = mock_archive_print.await_args.kwargs["print_data"]
+        assert print_data["ams_mapping"] == [4, -1, 12, -1]
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path):
+        """#2589: an all -1 `ams_mapping` means the slicer's own race lost —
+        every slot unresolved. Must be dropped (queue item AND archive),
+        never trusted over a fresh scheduler-computed mapping.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=46,
+            name="AMSMappingSentinel",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800046",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "ams_mapping": [-1, -1, -1, -1]},
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ) as mock_archive_print,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert added_items[0].ams_mapping is None
+        print_data = mock_archive_print.await_args.kwargs["print_data"]
+        assert print_data["ams_mapping"] is None
+
     @pytest.mark.asyncio
     async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
         """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
@@ -2185,6 +2381,59 @@ class TestVirtualPrinterInstance:
         assert "test.3mf" not in inst._recent_queue_items
 
 
+class TestExtractSlicerAmsMappingJson:
+    """Unit tests for `_extract_slicer_ams_mapping_json`, the pure-function
+    parser that pulls the slicer's own live-resolved AMS-slot pick out of a
+    captured project_file MQTT payload (see docstring in manager.py)."""
+
+    def _extract(self, data):
+        from backend.app.services.virtual_printer.manager import _extract_slicer_ams_mapping_json
+
+        return _extract_slicer_ams_mapping_json(data, "[test]")
+
+    def test_missing_field_returns_none(self):
+        assert self._extract({}) is None
+
+    def test_valid_int_list_returns_json(self):
+        import json as _json
+
+        result = self._extract({"ams_mapping": [4, -1, 12, -1]})
+        assert result is not None
+        assert _json.loads(result) == [4, -1, 12, -1]
+
+    def test_stringified_json_is_parsed(self):
+        import json as _json
+
+        result = self._extract({"ams_mapping": "[0, 1, 2]"})
+        assert result is not None
+        assert _json.loads(result) == [0, 1, 2]
+
+    def test_unparseable_string_returns_none(self):
+        assert self._extract({"ams_mapping": "not json"}) is None
+
+    def test_non_list_value_returns_none(self):
+        assert self._extract({"ams_mapping": 42}) is None
+
+    def test_empty_list_returns_none(self):
+        assert self._extract({"ams_mapping": []}) is None
+
+    def test_non_int_entries_return_none(self):
+        assert self._extract({"ams_mapping": [1, "two", 3]}) is None
+
+    def test_2589_all_unresolved_sentinel_returns_none(self):
+        """#2589: every slot -1 means the slicer's own resolution race
+        lost — never trust this over a fresh live computation."""
+        assert self._extract({"ams_mapping": [-1, -1, -1, -1]}) is None
+
+    def test_partially_resolved_mapping_is_kept(self):
+        import json as _json
+
+        # Only some slots resolved is still meaningful — keep it.
+        result = self._extract({"ams_mapping": [-1, 4, -1, -1]})
+        assert result is not None
+        assert _json.loads(result) == [-1, 4, -1, -1]
+
+
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""
 
@@ -2358,6 +2607,7 @@ class TestVirtualPrinterManager:
             "auto_dispatch": True,
             "tailscale_disabled": True,  # Opt-in default (#1070 UX fix)
             "queue_force_color_match": False,  # default — must be explicit so MagicMock truthiness doesn't trip the change detector
+            "save_ams_mapping": False,  # same reason as above
             "gcode_injection": False,  # same reason as above
             "position": 0,
         }

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

@@ -2210,6 +2210,10 @@ export interface PrintQueueItem {
   // start route when skip_filament_check=true, or at queue creation if
   // PrintModal's deficit warning was acknowledged.
   skip_filament_check: boolean;
+  // True when the source archive carries the slicer's own live-resolved
+  // AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint reuses that
+  // exact physical spool instead of re-deriving one from type/color.
+  archive_has_slicer_ams_mapping: boolean;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
@@ -7272,6 +7276,7 @@ export interface VirtualPrinterConfig {
   target_printer_id: number | null;
   auto_dispatch: boolean;
   queue_force_color_match: boolean;
+  save_ams_mapping: boolean;
   gcode_injection: boolean;
   tailscale_disabled: boolean;
   bind_ip: string | null;
@@ -7299,6 +7304,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
+    save_ams_mapping?: boolean;
     gcode_injection?: boolean;
     bind_ip?: string;
     remote_interface_ip?: string;
@@ -7317,6 +7323,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
+    save_ams_mapping?: boolean;
     gcode_injection?: boolean;
     tailscale_disabled?: boolean;
     bind_ip?: string;

+ 67 - 11
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
@@ -24,11 +24,50 @@ export function FilamentMapping({
   forceColorMatch,
   onForceColorMatchChange,
   plateLabel,
+  archiveAmsMapping,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+  // "Mapping" toggle (only shown when the archive has a saved slicer pick):
+  // ON selects every slot straight from `archiveAmsMapping`, bypassing the
+  // type/color auto-match entirely — same mechanism as a manual per-slot
+  // pick (`manualMappings`), just applied to every required slot at once.
+  // OFF removes exactly those overrides so the panel falls back to its
+  // normal auto-match, without touching any *other* manual picks the user
+  // made by hand.
+  const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
+
+  // Reset the toggle whenever the saved mapping it would apply changes — a
+  // different plate selection or a different archive entirely. Without this
+  // the button can read ON (green) from a previous archive/plate even though
+  // it was never pressed against the mapping currently in scope.
+  useEffect(() => {
+    setUsingArchiveMapping(false);
+  }, [archiveAmsMapping, plateLabel]);
+
+  const toggleArchiveMapping = () => {
+    if (!archiveAmsMapping || !filamentReqs?.filaments) return;
+    if (usingArchiveMapping) {
+      const next = { ...manualMappings };
+      for (const req of filamentReqs.filaments) {
+        if (req.slot_id > 0) delete next[req.slot_id];
+      }
+      onManualMappingChange(next);
+      setUsingArchiveMapping(false);
+      return;
+    }
+    const next = { ...manualMappings };
+    for (const req of filamentReqs.filaments) {
+      const idx = req.slot_id - 1;
+      if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
+        next[req.slot_id] = archiveAmsMapping[idx];
+      }
+    }
+    onManualMappingChange(next);
+    setUsingArchiveMapping(true);
+  };
 
   // Fetch printer status
   const { data: printerStatus } = useQuery({
@@ -211,16 +250,33 @@ export function FilamentMapping({
       {isExpanded && (
         <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
           <div className="flex items-center justify-between mb-2">
-            <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
-            <button
-              type="button"
-              onClick={handleRefresh}
-              className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-              disabled={isRefreshing}
-            >
-              <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-              <span>Re-read</span>
-            </button>
+            <span className="text-xs text-bambu-gray">{t('printModal.clickToChangeSlot')}</span>
+            <div className="flex items-center gap-1.5">
+              {archiveAmsMapping && (
+                <button
+                  type="button"
+                  onClick={toggleArchiveMapping}
+                  title={t('printModal.useArchiveMappingTooltip')}
+                  className={`flex items-center gap-1 px-2 py-0.5 text-xs rounded border transition-colors ${
+                    usingArchiveMapping
+                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                      : 'border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  <Check className="w-3 h-3" />
+                  <span>{t('printModal.useArchiveMapping')}</span>
+                </button>
+              )}
+              <button
+                type="button"
+                onClick={handleRefresh}
+                className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                disabled={isRefreshing}
+              >
+                <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+                <span>{t('printModal.reRead')}</span>
+              </button>
+            </div>
           </div>
           {filamentComparison.map((item, idx) => {
             // #1717: surface the same per-slot force-color-match checkbox here

+ 8 - 0
frontend/src/components/PrintModal/index.tsx

@@ -325,6 +325,13 @@ export function PrintModal({
   // Get sliced_for_model from archive or library file
   const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
 
+  // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
+  // mapping" virtual-printer setting) — undefined for library files or
+  // archives that predate the feature / had it off at print time.
+  const archiveSlicerAmsMapping = !isLibraryFile
+    ? (archiveDetails?.extra_data?.slicer_ams_mapping as number[] | undefined)
+    : undefined;
+
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
@@ -1407,6 +1414,7 @@ export function PrintModal({
                 onForceColorMatchChange={(slotId, value) =>
                   setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                 }
+                archiveAmsMapping={archiveSlicerAmsMapping}
               />
             )}
 

+ 8 - 0
frontend/src/components/PrintModal/types.ts

@@ -224,6 +224,14 @@ export interface FilamentMappingProps {
    *  plate. Each plate prints its own subset of the file's slots and gets its
    *  own AMS mapping, so the panels have to be told apart. */
   plateLabel?: string;
+  /** The archive's own saved AMS-slot pick from the slicer
+   *  (`extra_data.slicer_ams_mapping`, written when the source virtual
+   *  printer has "Save AMS mapping" enabled) — position = slot_id-1, value =
+   *  global tray ID. When present, a "Mapping" toggle next to "Re-read" lets
+   *  the user select every slot from this array instead of the type/color
+   *  auto-match. Undefined/omitted when the archive has no saved mapping —
+   *  the toggle is hidden and behaviour is unchanged. */
+  archiveAmsMapping?: number[];
 }
 
 /**

+ 32 - 0
frontend/src/components/VirtualPrinterCard.tsx

@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
+  const [localSaveAmsMapping, setLocalSaveAmsMapping] = useState(printer.save_ams_mapping ?? false);
   const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [showAccessCode, setShowAccessCode] = useState(false);
@@ -101,6 +102,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalModel(printer.model || '');
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
+      setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
       setLocalGcodeInjection(printer.gcode_injection ?? false);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
     }
@@ -439,6 +441,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
             )}
 
+            {/* Save-AMS-mapping toggle - only for queue mode */}
+            {localMode === 'queue' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <div className="text-white text-sm font-medium">{t('virtualPrinter.saveAmsMapping.title')}</div>
+                    <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.saveAmsMapping.description')}</div>
+                  </div>
+                  <button
+                    onClick={() => {
+                      const newVal = !localSaveAmsMapping;
+                      setLocalSaveAmsMapping(newVal);
+                      setPendingAction('saveAmsMapping');
+                      updateMutation.mutate({ save_ams_mapping: newVal });
+                    }}
+                    disabled={pendingAction === 'saveAmsMapping'}
+                    className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
+                      localSaveAmsMapping ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    } ${pendingAction === 'saveAmsMapping' ? 'opacity-50' : ''}`}
+                  >
+                    <span
+                      className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+                        localSaveAmsMapping ? 'translate-x-5' : ''
+                      }`}
+                    />
+                  </button>
+                </div>
+              </div>
+            )}
+
             {/* G-code injection toggle - only for queue mode (#1516) */}
             {localMode === 'queue' && (
               <div className="pt-2 border-t border-bambu-dark-tertiary">

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Hochgeladen von',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
       noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',
+      slicerAmsMapping: 'AMS-Zuordnung gespeichert',
+      slicerAmsMappingTooltip: 'Für dieses Archiv ist eine gespeicherte AMS-Zuordnung vom Slicer vorhanden',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
       openInBambuStudio: 'Im Slicer öffnen',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'unbekannt',
       printAnyway: 'Trotzdem drucken',
     },
+    slicerAmsMapping: {
+      rowBadge: 'AMS-Steckplatz vom Slicer gespeichert',
+      rowTooltip: 'Dieses Archiv enthält den genauen AMS-Steckplatz, den der Slicer beim Slicen/Senden ausgewählt hat. Ein erneuter Druck verwendet diese physische Spule, statt sie erneut anhand von Typ/Farbe zu erraten.',
+    },
     title: 'Druckwarteschlange',
     subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
     // Print modal
@@ -4619,6 +4625,10 @@ export default {
     selectPrinter: 'Drucker auswählen',
     selectPlate: 'Platte auswählen',
     filamentMapping: 'Filamentzuordnung',
+    useArchiveMapping: 'Zuordnung',
+    useArchiveMappingTooltip: 'Jeden Steckplatz aus der mit diesem Archiv gespeicherten AMS-Zuordnung (vom Slicer) auswählen, anstatt nach Typ/Farbe abzugleichen.',
+    clickToChangeSlot: 'Klicken, um die Steckplatzzuweisung zu ändern',
+    reRead: 'Neu einlesen',
     plateN: 'Platte {{n}}',
     plateFilamentsUnreadable: 'Die Filamente einer ausgewählten Platte konnten nicht gelesen werden, sie lässt sich daher nicht zuordnen. Wähle sie ab, um die anderen einzureihen.',
     totalCost: 'Gesamtkosten:',
@@ -5140,6 +5150,10 @@ export default {
       title: 'Farbabgleich erzwingen',
       description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
     },
+    saveAmsMapping: {
+      title: 'AMS-Zuordnung speichern',
+      description: 'Speichert die vom Slicer selbst gewählte AMS-Steckplatz-Zuordnung (aus dem MQTT-Befehl project_file) im Archiv, sodass ein späterer erneuter Druck dieselbe physische Spule verwendet, anstatt sie erneut aus Typ/Farbe der Datei abzuleiten. Standardmäßig deaktiviert.',
+    },
     gcodeInjection: {
       title: 'G-code-Injektion',
       description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.',

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

@@ -939,6 +939,8 @@ export default {
       uploadedBy: 'Uploaded By',
       noPermissionReprint: 'You do not have permission to reprint',
       noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',
+      slicerAmsMapping: 'AMS mapping saved',
+      slicerAmsMappingTooltip: 'This archive has a saved AMS slot mapping from the slicer',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
       openInBambuStudio: 'Open in Slicer',
@@ -1155,6 +1157,10 @@ export default {
       unknown: 'unknown',
       printAnyway: 'Print Anyway',
     },
+    slicerAmsMapping: {
+      rowBadge: 'AMS slot saved from slicer',
+      rowTooltip: 'This archive carries the exact AMS slot the slicer picked when it was sliced/sent. A reprint reuses that physical spool instead of re-guessing from type/color.',
+    },
     // Print modal
     editQueueItem: 'Edit Queue Item',
     selectAllPlates: 'Select All {{count}} Plates',
@@ -4662,6 +4668,10 @@ export default {
     selectPrinter: 'Select Printer',
     selectPlate: 'Select Plate',
     filamentMapping: 'Filament Mapping',
+    useArchiveMapping: 'Mapping',
+    useArchiveMappingTooltip: 'Select every slot from the AMS mapping saved with this archive (from the slicer), instead of matching by type/color.',
+    clickToChangeSlot: 'Click to change slot assignment',
+    reRead: 'Re-read',
     plateN: 'Plate {{n}}',
     plateFilamentsUnreadable: 'The filaments of a selected plate could not be read, so it can\'t be mapped. Deselect it to queue the others.',
     totalCost: 'Total cost:',
@@ -5184,6 +5194,10 @@ export default {
       title: 'Force color match',
       description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
+    saveAmsMapping: {
+      title: 'Save AMS mapping',
+      description: 'Persist the slicer\'s own AMS-slot pick (from the project_file MQTT command) onto the archive, so a later reprint reuses the exact physical spool instead of re-deriving one from the file\'s type/color. Off by default.',
+    },
     gcodeInjection: {
       title: 'G-code injection',
       description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Subido por',
       noPermissionReprint: 'No tiene permiso para reimprimir',
       noFileForReprint: 'No hay archivo 3MF disponible — no se pudo descargar el archivo de la impresora cuando se registró la impresión',
+      slicerAmsMapping: 'Mapeo de AMS guardado',
+      slicerAmsMappingTooltip: 'Este archivo tiene un mapeo de ranuras AMS guardado desde el slicer',
       noPermissionEdit: 'No tiene permiso para editar archivos',
       noPermissionDelete: 'No tiene permiso para eliminar archivos',
       openInBambuStudio: 'Abrir en el laminador',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'desconocido',
       printAnyway: 'Imprimir de todos modos',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Ranura AMS guardada desde el slicer',
+      rowTooltip: 'Este archivo conserva la ranura AMS exacta que eligió el slicer al laminar/enviar. Una reimpresión reutiliza ese carrete físico en lugar de volver a adivinar por tipo/color.',
+    },
     title: 'Cola de impresión',
     subtitle: 'Programe y gestione sus trabajos de impresión',
     // Print modal
@@ -4627,6 +4633,10 @@ export default {
     selectPrinter: 'Seleccionar impresora',
     selectPlate: 'Seleccionar cama',
     filamentMapping: 'Mapeo de filamentos',
+    useArchiveMapping: 'Mapeo',
+    useArchiveMappingTooltip: 'Selecciona todas las ranuras a partir del mapeo de AMS guardado con este archivo (del slicer), en lugar de emparejar por tipo/color.',
+    clickToChangeSlot: 'Haga clic para cambiar la asignación de ranura',
+    reRead: 'Releer',
     plateN: 'Cama {{n}}',
     plateFilamentsUnreadable: 'No se han podido leer los filamentos de una cama seleccionada, por lo que no se puede asignar. Deselecciónala para encolar las demás.',
     totalCost: 'Coste total:',
@@ -5149,6 +5159,10 @@ export default {
       title: 'Forzar la coincidencia de color',
       description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.',
     },
+    saveAmsMapping: {
+      title: 'Guardar mapeo de AMS',
+      description: 'Guarda en el archivo la selección de ranura AMS propia del slicer (del comando MQTT project_file), de modo que una reimpresión posterior reutilice el mismo carrete físico en lugar de volver a derivarlo del tipo/color del archivo. Desactivado de forma predeterminada.',
+    },
     gcodeInjection: {
       title: 'Inyección de G-code',
       description: 'Aplica los fragmentos de G-code configurados por modelo en Ajustes a los trabajos de esta IV. Desactivado de forma predeterminada.',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Téléversé par',
       noPermissionReprint: 'Pas d\'autorisation de réimpression',
       noFileForReprint: 'Aucun fichier 3MF disponible — le fichier n\'a pas pu être téléchargé depuis l\'imprimante lors de l\'enregistrement',
+      slicerAmsMapping: 'Mappage AMS enregistré',
+      slicerAmsMappingTooltip: 'Cette archive dispose d\'un mappage des emplacements AMS enregistré depuis le slicer',
       noPermissionEdit: 'Pas d\'autorisation de modification',
       noPermissionDelete: 'Pas d\'autorisation de suppression',
       openInBambuStudio: 'Ouvrir dans le Slicer',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'inconnu',
       printAnyway: 'Imprimer quand meme',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Emplacement AMS enregistré depuis le slicer',
+      rowTooltip: 'Cette archive conserve l\'emplacement AMS exact choisi par le slicer lors du tranchage/envoi. Une réimpression réutilise cette bobine physique au lieu de deviner à nouveau par type/couleur.',
+    },
     title: 'File d\'attente',
     subtitle: 'Gérez vos travaux d\'impression',
     // Print modal
@@ -4608,6 +4614,10 @@ export default {
     selectPrinter: 'Choisir l\'imprimante',
     selectPlate: 'Choisir le plateau',
     filamentMapping: 'Mapping Filament',
+    useArchiveMapping: 'Mappage',
+    useArchiveMappingTooltip: 'Sélectionner tous les emplacements à partir du mappage AMS enregistré avec cette archive (depuis le slicer), au lieu de faire correspondre par type/couleur.',
+    clickToChangeSlot: 'Cliquez pour modifier l\'attribution de l\'emplacement',
+    reRead: 'Relire',
     plateN: 'Plateau {{n}}',
     plateFilamentsUnreadable: 'Les filaments d\'un plateau sélectionné n\'ont pas pu être lus, il est donc impossible de l\'affecter. Désélectionnez-le pour mettre les autres en file.',
     totalCost: 'Coût total :',
@@ -5130,6 +5140,10 @@ export default {
       title: 'Forcer la correspondance des couleurs',
       description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.',
     },
+    saveAmsMapping: {
+      title: 'Enregistrer le mappage AMS',
+      description: 'Conserve dans l\'archive le choix d\'emplacement AMS propre au slicer (depuis la commande MQTT project_file), afin qu\'une réimpression ultérieure réutilise la même bobine physique au lieu de la redéduire à partir du type/de la couleur du fichier. Désactivé par défaut.',
+    },
     gcodeInjection: {
       title: 'Injection G-code',
       description: 'Applique les extraits de G-code configurés par modèle dans les Paramètres aux travaux de ce VP. Désactivé par défaut.',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Caricato da',
       noPermissionReprint: 'Non hai il permesso di ristampare',
       noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
+      slicerAmsMapping: 'Mappatura AMS salvata',
+      slicerAmsMappingTooltip: 'Questo archivio ha una mappatura degli slot AMS salvata dallo slicer',
       noPermissionEdit: 'Non hai il permesso di modificare archivi',
       noPermissionDelete: 'Non hai il permesso di eliminare archivi',
       openInBambuStudio: 'Apri nello slicer',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'sconosciuto',
       printAnyway: 'Stampa comunque',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Slot AMS salvato dallo slicer',
+      rowTooltip: 'Questo archivio conserva lo slot AMS esatto scelto dallo slicer al momento dello slicing/invio. Una ristampa riutilizza quella bobina fisica invece di indovinare di nuovo in base a tipo/colore.',
+    },
     title: 'Coda di stampa',
     subtitle: 'Programma e gestisci i tuoi lavori di stampa',
     // Print modal
@@ -4607,6 +4613,10 @@ export default {
     selectPrinter: 'Seleziona stampante',
     selectPlate: 'Seleziona piatto',
     filamentMapping: 'Mappatura filamento',
+    useArchiveMapping: 'Mappatura',
+    useArchiveMappingTooltip: 'Seleziona ogni slot dalla mappatura AMS salvata con questo archivio (dallo slicer), invece di abbinare per tipo/colore.',
+    clickToChangeSlot: 'Fai clic per modificare l\'assegnazione dello slot',
+    reRead: 'Rileggi',
     plateN: 'Piatto {{n}}',
     plateFilamentsUnreadable: 'Non è stato possibile leggere i filamenti di un piatto selezionato, quindi non può essere assegnato. Deselezionalo per accodare gli altri.',
     totalCost: 'Costo totale:',
@@ -5129,6 +5139,10 @@ export default {
       title: 'Forza corrispondenza colori',
       description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
     },
+    saveAmsMapping: {
+      title: 'Salva mappatura AMS',
+      description: 'Salva nell\'archivio la scelta dello slot AMS effettuata dallo slicer stesso (dal comando MQTT project_file), in modo che una ristampa successiva riutilizzi la stessa bobina fisica invece di ricavarla di nuovo dal tipo/colore del file. Disattivato per impostazione predefinita.',
+    },
     gcodeInjection: {
       title: 'Iniezione G-code',
       description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.',

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

@@ -934,6 +934,8 @@ export default {
       uploadedBy: 'アップロード者',
       noPermissionReprint: '再印刷する権限がありません',
       noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
+      slicerAmsMapping: 'AMSマッピングを保存しました',
+      slicerAmsMappingTooltip: 'このアーカイブにはスライサーから保存されたAMSスロットマッピングがあります',
       noPermissionEdit: 'プロファイルを編集する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
       openInBambuStudio: 'スライサーで開く',
@@ -1143,6 +1145,10 @@ export default {
       unknown: '不明',
       printAnyway: 'それでも印刷',
     },
+    slicerAmsMapping: {
+      rowBadge: 'スライサーから保存されたAMSスロット',
+      rowTooltip: 'このアーカイブには、スライス/送信時にスライサーが選択した正確なAMSスロットが保持されています。再印刷では、タイプ/色から再推測するのではなく、その物理スプールが再利用されます。',
+    },
     title: '印刷キュー',
     subtitle: '印刷ジョブのスケジュールと管理',
     // Print modal
@@ -4619,6 +4625,10 @@ export default {
     selectPrinter: 'プリンターを選択',
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',
+    useArchiveMapping: 'マッピング',
+    useArchiveMappingTooltip: 'タイプ/色で照合する代わりに、このアーカイブに保存されたAMSマッピング(スライサーから)からすべてのスロットを選択します。',
+    clickToChangeSlot: 'クリックしてスロットの割り当てを変更',
+    reRead: '再読み込み',
     plateN: 'プレート {{n}}',
     plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
     totalCost: '合計コスト:',
@@ -5141,6 +5151,10 @@ export default {
       title: '色の一致を強制',
       description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
     },
+    saveAmsMapping: {
+      title: 'AMSマッピングを保存',
+      description: 'スライサー自身が選択したAMSスロット(project_file MQTTコマンドから)をアーカイブに保存し、後で再印刷する際にファイルのタイプ/色から再導出するのではなく、同じ物理スプールを再利用できるようにします。デフォルトはオフです。',
+    },
     gcodeInjection: {
       title: 'G-codeインジェクション',
       description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',

+ 15 - 1
frontend/src/i18n/locales/ko.ts

@@ -890,6 +890,8 @@ export default {
       uploadedBy: '업로드한 사용자',
       noPermissionReprint: '재인쇄 권한이 없습니다',
       noFileForReprint: '3MF 파일 없음 — 인쇄 기록 시 프린터에서 파일을 다운로드할 수 없었습니다',
+      slicerAmsMapping: 'AMS 매핑 저장됨',
+      slicerAmsMappingTooltip: '이 아카이브에는 슬라이서에서 저장된 AMS 슬롯 매핑이 있습니다',
       noPermissionEdit: '아카이브를 편집할 권한이 없습니다',
       noPermissionDelete: '아카이브를 삭제할 권한이 없습니다',
       openInBambuStudio: '슬라이서에서 열기',
@@ -1349,7 +1351,11 @@ export default {
       lineItem: '슬롯 {{slot}}: {{required}}g 필요, {{remaining}}g 남음',
       unknown: '알 수 없음',
       printAnyway: '그냥 인쇄'
-    }
+    },
+    slicerAmsMapping: {
+      rowBadge: '슬라이서에서 저장된 AMS 슬롯',
+      rowTooltip: '이 아카이브에는 슬라이싱/전송 시 슬라이서가 선택한 정확한 AMS 슬롯이 보존되어 있습니다. 재인쇄 시 유형/색상으로 다시 추측하는 대신 해당 실물 스풀을 재사용합니다.',
+    },
   },
   stats: {
     title: '대시보드',
@@ -4391,6 +4397,10 @@ export default {
     selectPrinter: '프린터 선택',
     selectPlate: '플레이트 선택',
     filamentMapping: '필라멘트 매핑',
+    useArchiveMapping: '매핑',
+    useArchiveMappingTooltip: '유형/색상으로 매칭하는 대신, 이 아카이브에 저장된 AMS 매핑(슬라이서 제공)에서 모든 슬롯을 선택합니다.',
+    clickToChangeSlot: '클릭하여 슬롯 할당 변경',
+    reRead: '다시 읽기',
     plateN: '플레이트 {{n}}',
     plateFilamentsUnreadable: '선택한 플레이트의 필라멘트를 읽을 수 없어 매핑할 수 없습니다. 해당 플레이트를 선택 해제하면 나머지를 대기열에 추가할 수 있습니다.',
     totalCost: '총 비용:',
@@ -4878,6 +4888,10 @@ export default {
       title: '색상 일치 강제',
       description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
     },
+    saveAmsMapping: {
+      title: 'AMS 매핑 저장',
+      description: '슬라이서가 직접 선택한 AMS 슬롯(project_file MQTT 명령에서)을 아카이브에 저장하여, 이후 재인쇄 시 파일의 유형/색상에서 다시 유추하지 않고 동일한 실물 스풀을 재사용하도록 합니다. 기본값은 꺼짐입니다.',
+    },
     gcodeInjection: {
       title: 'G-code 주입',
       description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Enviado por',
       noPermissionReprint: 'Você não tem permissão para reimprimir',
       noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',
+      slicerAmsMapping: 'Mapeamento de AMS salvo',
+      slicerAmsMappingTooltip: 'Este arquivo tem um mapeamento de slots AMS salvo do fatiador',
       noPermissionEdit: 'Você não tem permissão para editar arquivos',
       noPermissionDelete: 'Você não tem permissão para excluir arquivos',
       openInBambuStudio: 'Abrir no Bambu Studio',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'desconhecido',
       printAnyway: 'Imprimir mesmo assim',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Slot AMS salvo do fatiador',
+      rowTooltip: 'Este arquivo mantém o slot AMS exato escolhido pelo fatiador ao fatiar/enviar. Uma reimpressão reutiliza esse carretel físico em vez de adivinhar novamente pelo tipo/cor.',
+    },
     title: 'Fila de Impressão',
     subtitle: 'Agende e gerencie seus trabalhos de impressão',
     // Print modal
@@ -4607,6 +4613,10 @@ export default {
     selectPrinter: 'Selecionar Impressora',
     selectPlate: 'Selecionar Placa',
     filamentMapping: 'Mapeamento de Filamento',
+    useArchiveMapping: 'Mapeamento',
+    useArchiveMappingTooltip: 'Selecionar todos os slots a partir do mapeamento de AMS salvo com este arquivo (do fatiador), em vez de corresponder por tipo/cor.',
+    clickToChangeSlot: 'Clique para alterar a atribuição do slot',
+    reRead: 'Reler',
     plateN: 'Placa {{n}}',
     plateFilamentsUnreadable: 'Não foi possível ler os filamentos de uma placa selecionada, portanto ela não pode ser mapeada. Desmarque-a para enfileirar as demais.',
     totalCost: 'Custo total:',
@@ -5129,6 +5139,10 @@ export default {
       title: 'Forçar correspondência de cor',
       description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.',
     },
+    saveAmsMapping: {
+      title: 'Salvar mapeamento de AMS',
+      description: 'Persiste a escolha de slot AMS feita pelo próprio fatiador (do comando MQTT project_file) no arquivo, para que uma reimpressão posterior reutilize o mesmo carretel físico em vez de derivá-lo novamente do tipo/cor do arquivo. Desativado por padrão.',
+    },
     gcodeInjection: {
       title: 'Injeção de G-code',
       description: 'Aplica os trechos de G-code configurados por modelo nas Configurações aos trabalhos deste VP. Desativado por padrão.',

+ 14 - 0
frontend/src/i18n/locales/ru.ts

@@ -891,6 +891,8 @@ export default {
       uploadedBy: "Загрузил",
       noPermissionReprint: "У вас нет разрешения на повторную печать",
       noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
+      slicerAmsMapping: "Маппинг AMS сохранён",
+      slicerAmsMappingTooltip: "У этого архива сохранён маппинг AMS-ячеек от слайсера",
       noPermissionEdit: "У вас нет разрешения на изменение архива",
       noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
       openInBambuStudio: "Открыть в слайсере",
@@ -1096,6 +1098,10 @@ export default {
       unknown: "неизвестно",
       printAnyway: "Всё равно печатать",
     },
+    slicerAmsMapping: {
+      rowBadge: "Ячейка AMS сохранена от слайсера",
+      rowTooltip: "У этого архива сохранена точная ячейка AMS, которую выбрал слайсер при нарезке/отправке. Повторная печать использует именно эту физическую катушку, а не подбор заново по типу/цвету.",
+    },
     editQueueItem: "Изменить задание в очереди",
     selectAllPlates: "Выбрать все пластины ({{count}})",
     deselectAll: "Снять выделение",
@@ -4380,6 +4386,10 @@ export default {
     selectPrinter: "Выберите принтер",
     selectPlate: "Выберите пластину",
     filamentMapping: "Сопоставление филаментов",
+    useArchiveMapping: "Маппинг",
+    useArchiveMappingTooltip: "Выбрать все ячейки из маппинга AMS, сохранённого с этим архивом (от слайсера), вместо подбора по типу/цвету.",
+    clickToChangeSlot: "Нажмите, чтобы изменить назначение ячейки",
+    reRead: "Перечитать",
     plateN: "Пластина {{n}}",
     plateFilamentsUnreadable: "Не удалось определить филаменты выбранной пластины, поэтому их невозможно сопоставить. Снимите выбор с этой пластины, чтобы добавить остальные в очередь.",
     totalCost: "Общая стоимость:",
@@ -4866,6 +4876,10 @@ export default {
       title: "Требовать совпадения цвета",
       description: "Не назначать задание принтеру, если загружены филамент другого типа или другого цвета. По умолчанию выключено: без этой проверки очередь сопоставляет только модель принтера и может выбрать принтер с неподходящим цветом.",
     },
+    saveAmsMapping: {
+      title: "Сохранять маппинг AMS",
+      description: "Сохранять выбор AMS-ячейки, который сделал слайсер (из MQTT-команды project_file), в архив — тогда повторная печать использует ту же физическую катушку вместо повторного подбора по типу/цвету файла. По умолчанию выключено.",
+    },
     gcodeInjection: {
       title: "Вставка G-code",
       description: "Применять к заданиям этого виртуального принтера фрагменты G-code для соответствующей модели, заданные в настройках. По умолчанию выключено.",

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Yükleyen',
       noPermissionReprint: 'Yeniden yazdırma izniniz yok',
       noFileForReprint: 'Kullanılabilir 3MF dosyası yok — baskı kaydedildiğinde dosya yazıcıdan indirilemedi',
+      slicerAmsMapping: 'AMS eşlemesi kaydedildi',
+      slicerAmsMappingTooltip: 'Bu arşivde dilimleyiciden kaydedilmiş bir AMS yuva eşlemesi var',
       noPermissionEdit: 'Arşivleri düzenleme izniniz yok',
       noPermissionDelete: 'Arşivleri silme izniniz yok',
       openInBambuStudio: 'Dilimleyicide Aç',
@@ -1146,6 +1148,10 @@ export default {
       unknown: 'bilinmiyor',
       printAnyway: 'Yine de Yazdır',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Dilimleyiciden kaydedilen AMS yuvası',
+      rowTooltip: 'Bu arşiv, dilimleme/gönderme sırasında dilimleyicinin seçtiği tam AMS yuvasını taşır. Yeniden yazdırma, tür/renkten yeniden tahmin etmek yerine o fiziksel makarayı yeniden kullanır.',
+    },
     // Baskı modali
     editQueueItem: 'Kuyruk Öğesini Düzenle',
     selectAllPlates: 'Tüm {{count}} Plakayı Seç',
@@ -4597,6 +4603,10 @@ export default {
     selectPrinter: 'Yazıcı Seç',
     selectPlate: 'Plaka Seç',
     filamentMapping: 'Filament Eşlemesi',
+    useArchiveMapping: 'Eşleme',
+    useArchiveMappingTooltip: 'Tür/renge göre eşleştirmek yerine, bu arşivle kaydedilen AMS eşlemesindeki (dilimleyiciden) her yuvayı seç.',
+    clickToChangeSlot: 'Yuva atamasını değiştirmek için tıklayın',
+    reRead: 'Yeniden oku',
     plateN: 'Plaka {{n}}',
     plateFilamentsUnreadable: 'Seçili bir plakanın filamentleri okunamadı, bu yüzden eşleştirilemiyor. Diğerlerini kuyruğa almak için o plakanın seçimini kaldırın.',
     totalCost: 'Toplam maliyet:',
@@ -5105,6 +5115,10 @@ export default {
       title: 'Renk eşleşmesini zorla',
       description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.',
     },
+    saveAmsMapping: {
+      title: 'AMS eşlemesini kaydet',
+      description: 'Dilimleyicinin kendi seçtiği AMS yuvasını (project_file MQTT komutundan) arşive kalıcı olarak kaydeder, böylece daha sonraki bir yeniden yazdırma dosyanın türünden/renginden yeniden türetmek yerine tam olarak aynı fiziksel makarayı yeniden kullanır. Varsayılan olarak kapalı.',
+    },
     gcodeInjection: {
       title: 'G-code enjeksiyonu',
       description: "Ayarlar'da model bazında yapılandırılan G-code parçacıklarını bu VP'nin işlerine uygular. Varsayılan olarak kapalı.",

+ 14 - 0
frontend/src/i18n/locales/uk.ts

@@ -939,6 +939,8 @@ export default {
       uploadedBy: "Вивантажив",
       noPermissionReprint: "Ви не маєте дозволу на передрук",
       noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
+      slicerAmsMapping: "Зіставлення AMS збережено",
+      slicerAmsMappingTooltip: "Для цього запису збережено зіставлення слотів AMS зі слайсера",
       noPermissionEdit: "Ви не маєте прав на редагування архівів",
       noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
       openInBambuStudio: "Відкрити у слайсері",
@@ -1155,6 +1157,10 @@ export default {
       unknown: "невідомо",
       printAnyway: "Усе одно друкувати",
     },
+    slicerAmsMapping: {
+      rowBadge: "Слот AMS збережено зі слайсера",
+      rowTooltip: "Цей запис містить точний слот AMS, який вибрав слайсер під час нарізання/надсилання. Повторний друк використає саме цю фізичну котушку замість повторного добору за типом/кольором.",
+    },
     // Print modal
     editQueueItem: "Редагувати елемент черги",
     selectAllPlates: "Вибрати всі пластини ({{count}})",
@@ -4662,6 +4668,10 @@ export default {
     selectPrinter: "Вибрати принтер",
     selectPlate: "Вибрати пластину",
     filamentMapping: "Зіставлення філаментів",
+    useArchiveMapping: "Зіставлення",
+    useArchiveMappingTooltip: "Вибрати всі слоти зі зіставлення AMS, збереженого з цим записом (зі слайсера), замість добору за типом/кольором.",
+    clickToChangeSlot: "Натисніть, щоб змінити призначення слота",
+    reRead: "Перечитати",
     plateN: "Пластина {{n}}",
     plateFilamentsUnreadable: "Не вдалося прочитати філаменти вибраної пластини, тому їх неможливо зіставити. Зніміть вибір із цієї пластини, щоб додати решту до черги.",
     totalCost: "Загальна вартість:",
@@ -5184,6 +5194,10 @@ export default {
       title: "Примусовий збіг кольорів",
       description: "Не надсилати завдання на принтер без філаменту точного типу й кольору. Типово вимкнено: без цього черга зіставляє лише модель і може вибрати принтер із філаментом іншого кольору.",
     },
+    saveAmsMapping: {
+      title: "Зберігати зіставлення AMS",
+      description: "Зберігати в записі вибір слота AMS, зроблений самим слайсером (з MQTT-команди project_file), щоб пізніший повторний друк використав ту саму фізичну котушку замість повторного визначення за типом/кольором файлу. Типово вимкнено.",
+    },
     gcodeInjection: {
       title: "Вставлення G-коду",
       description: "Застосовувати до завдань цього віртуального принтера фрагменти G-коду, налаштовані для кожної моделі. Типово вимкнено.",

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: '上传者',
       noPermissionReprint: '您没有重新打印的权限',
       noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',
+      slicerAmsMapping: '已保存 AMS 映射',
+      slicerAmsMappingTooltip: '此存档已保存来自切片软件的 AMS 槽位映射',
       noPermissionEdit: '您没有编辑归档的权限',
       noPermissionDelete: '您没有删除归档的权限',
       openInBambuStudio: '在切片软件中打开',
@@ -1144,6 +1146,10 @@ export default {
       unknown: '未知',
       printAnyway: '仍要打印',
     },
+    slicerAmsMapping: {
+      rowBadge: '已保存来自切片软件的 AMS 槽位',
+      rowTooltip: '此存档保留了切片/发送时切片软件选择的确切 AMS 槽位。重新打印会复用该实体线材,而不是根据类型/颜色重新猜测。',
+    },
     title: '打印队列',
     subtitle: '排程和管理您的打印任务',
     // Print modal
@@ -4607,6 +4613,10 @@ export default {
     selectPrinter: '选择打印机',
     selectPlate: '选择板',
     filamentMapping: '耗材映射',
+    useArchiveMapping: '映射',
+    useArchiveMappingTooltip: '从此存档保存的 AMS 映射(来自切片软件)中选择所有槽位,而不是按类型/颜色匹配。',
+    clickToChangeSlot: '点击更改槽位分配',
+    reRead: '重新读取',
     plateN: '板 {{n}}',
     plateFilamentsUnreadable: '无法读取所选盘的耗材信息,因此无法进行映射。取消选择该盘即可将其余盘加入队列。',
     totalCost: '总成本:',
@@ -5129,6 +5139,10 @@ export default {
       title: '强制颜色匹配',
       description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
     },
+    saveAmsMapping: {
+      title: '保存 AMS 映射',
+      description: '将切片软件自身选择的 AMS 槽位(来自 project_file MQTT 命令)保存到存档中,以便之后重新打印时复用同一卷实体线材,而不是根据文件的类型/颜色重新推导。默认关闭。',
+    },
     gcodeInjection: {
       title: 'G-code 注入',
       description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: '上傳者',
       noPermissionReprint: '您沒有重新列印的權限',
       noFileForReprint: '無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案',
+      slicerAmsMapping: '已儲存 AMS 對應',
+      slicerAmsMappingTooltip: '此封存已儲存來自切片軟體的 AMS 槽位對應',
       noPermissionEdit: '您沒有編輯歸檔的權限',
       noPermissionDelete: '您沒有刪除歸檔的權限',
       openInBambuStudio: '在切片軟體中開啟',
@@ -1144,6 +1146,10 @@ export default {
       unknown: '不明',
       printAnyway: '仍要列印',
     },
+    slicerAmsMapping: {
+      rowBadge: '已儲存來自切片軟體的 AMS 槽位',
+      rowTooltip: '此封存保留了切片/傳送時切片軟體選擇的確切 AMS 槽位。重新列印會重複使用該實體線材,而不是依類型/顏色重新猜測。',
+    },
     title: '列印佇列',
     subtitle: '排程和管理您的列印任務',
     // Print modal
@@ -4607,6 +4613,10 @@ export default {
     selectPrinter: '選擇印表機',
     selectPlate: '選擇板',
     filamentMapping: '耗材對應',
+    useArchiveMapping: '對應',
+    useArchiveMappingTooltip: '從此封存儲存的 AMS 對應(來自切片軟體)中選取所有槽位,而不是依類型/顏色比對。',
+    clickToChangeSlot: '點擊更改槽位分配',
+    reRead: '重新讀取',
     plateN: '板 {{n}}',
     plateFilamentsUnreadable: '無法讀取所選盤的耗材資訊,因此無法進行對應。取消選取該盤即可將其餘盤加入佇列。',
     totalCost: '總成本:',
@@ -5129,6 +5139,10 @@ export default {
       title: '強制顏色匹配',
       description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
     },
+    saveAmsMapping: {
+      title: '儲存 AMS 對應',
+      description: '將切片軟體自行選擇的 AMS 槽位(來自 project_file MQTT 指令)儲存到封存中,讓之後的重新列印能重複使用同一捲實體線材,而不是依檔案的類型/顏色重新推導。預設關閉。',
+    },
     gcodeInjection: {
       title: 'G-code 注入',
       description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。',

+ 11 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -56,6 +56,7 @@ import {
   Cog,
   Archive as ArchiveIcon,
   History,
+  CheckCircle2,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
@@ -1109,6 +1110,16 @@ function ArchiveCard({
           )}
         </div>
 
+        {/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting) —
+            a reprint reuses this exact physical spool instead of re-deriving
+            one from the file's type/color. */}
+        {Array.isArray((archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping) && (
+          <div className="flex items-center gap-1.5 text-bambu-green text-xs mb-3" title={t('archives.card.slicerAmsMappingTooltip')}>
+            <CheckCircle2 className="w-3.5 h-3.5" />
+            {t('archives.card.slicerAmsMapping')}
+          </div>
+        )}
+
         {/* Tags & Notes */}
         {(archive.tags || archive.notes) && (
           <div className="flex flex-wrap items-center gap-1.5 mb-3">

+ 13 - 0
frontend/src/pages/QueuePage.tsx

@@ -731,6 +731,19 @@ function SortableQueueItem({
             </p>
           )}
 
+          {/* Archive carries the slicer's own live-resolved AMS-slot pick
+              (extra_data.slicer_ams_mapping) — reprints of this archive reuse
+              the exact physical spool instead of re-deriving one. */}
+          {item.archive_has_slicer_ams_mapping && (
+            <p
+              className="text-[10px] sm:text-xs text-green-700 dark:text-green-400 mt-1.5 sm:mt-2 flex items-start gap-1"
+              title={t('queue.slicerAmsMapping.rowTooltip')}
+            >
+              <Check className="w-3 h-3 mt-0.5 flex-shrink-0" />
+              <span>{t('queue.slicerAmsMapping.rowBadge')}</span>
+            </p>
+          )}
+
           {/* Error message */}
           {item.error_message && (
             <p className="text-[10px] sm:text-xs text-red-700 dark:text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">