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

fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)

Round-3 review of the "Save AMS mapping" PR.

The queue item's ams_mapping was set unconditionally, on the reasoning that
honouring the slicer's own pick is a correctness fix rather than a feature.
It is both. Storing a resolved mapping makes _ensure_ams_mapping return
early, so _compute_ams_mapping_for_printer never runs — and that function is
where prefer_lowest_filament lives, along with the AMS-filament-backup gate
that qualifies it (#1766), the inventory-remain overrides, and the per-slot
force-colour overrides. Every existing queue-mode VP pointed at a printer
would have quietly lost all of it on upgrade, without a setting to turn it
back on.

So save_ams_mapping now gates the queue item too, not just the archive
persistence. Off is exactly the old behaviour. The correctness case the PR
was written for — two spools of the same red PLA, and the slot the user
picked in the slicer thrown away — is still fixed, for anyone who asks for
it.

Force color match wins over it when both are on. Its only effect on a
fixed-printer item is the filament_overrides written onto the queue item,
and those are read inside the function a stored mapping skips, so the two
toggles sitting next to each other on the same card silently cancelled. The
dispatch now matches strictly, as asked, while the slicer's pick is still
saved onto the archive — that is what the toggle's name promises, and a
later reprint is a separate decision from this print. The queue-add fallback
applies the same rule to a request that carries force-colour overrides.

A mapping shorter than a plate's highest slot id cannot address that plate's
own slots, and _ensure_ams_mapping would have kept it anyway, since it only
rejects an all-unresolved one. Each plate now checks the length it needs and
falls back to a computed mapping if the array does not reach. Bambu Studio
sends a file-global array, so this normally never fires; it also means a
multi-plate Send All degrades safely if that ever stops being true.

The badges claimed more than they delivered. Both rendered whenever a saved
mapping existed, ignoring which printer it belonged to, while the tooltips
promised the reprint would reuse those exact spools — true only on the
printer the trays were resolved against. The queue row's flag is now
computed against that row's own printer, which is precisely when dispatch
reuses the mapping, and the archive card names the printer instead of
implying any of them will do. It hides itself when that printer no longer
exists. Retranslated in all 13 locales.

Frontend tests, which the PR had none of. The printer-scoping rule is now a
pure function rather than an inline expression, covered for the mismatched
printer, the no-printer-selected case that would otherwise compare undefined
against undefined, and malformed extra_data. The toggle's undo bookkeeping
is covered for unresolved slots, short mappings, and hand-made picks —
preserved when the toggle never wrote that slot, replaced when it did, which
is behaviour worth pinning either way.

Also reverts all three queue-mode switches when a save fails, not just the
new one; without it the card shows a setting the server rejected.
maziggy 1 месяц назад
Родитель
Сommit
4f2c073a34

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it.
 - **Live print progress in the browser tab (#2693, contributor @Chachigo, requested in #1041)** — Watching a print meant keeping the Bambuddy tab in view, or switching back to it every few minutes. Enable **Print progress in tab** under Settings → Appearance and the tab title becomes `42% · Bambuddy` while the favicon turns into a progress ring in your theme accent colour, both updating live over the WebSocket the rest of the UI already uses. With several printers running, the tab follows the one finishing soonest, tie-broken by highest progress; title and favicon return to their defaults as soon as nothing is printing or the toggle goes off. Off by default, and stored per browser (like the light/dark toggle) so a wall-mounted dashboard and a laptop can each have their own setting. Translated in all locales; wiki updated. Covered by frontend tests.
 - **Telegram notifications can target a forum topic (#1518, reporter @vmhomelab)** — Telegram groups with Topics enabled always received Bambuddy's notifications in the **General** topic, because only Bot Token and Chat ID were configurable. Getting a per-printer split therefore meant creating a separate chat per printer. The Telegram provider now takes an optional **Forum Topic ID** — the last number in a topic's link (`t.me/c/1234567890/25`) — and routes its messages into that topic, so a single group can carry one topic per printer. Left empty, the behaviour is unchanged. The ID is sent on both the plain-text and the thumbnail code paths, and is validated as a number in the form and again server-side, so a typo is reported instead of silently breaking only text notifications. Translated in all locales; wiki updated. Covered by backend and frontend tests.

+ 31 - 3
backend/app/api/routes/print_queue.py

@@ -253,8 +253,20 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             # 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")
+            #
+            # Only when the saved mapping was resolved against *this* row's
+            # printer: a global tray ID means nothing on another printer, so
+            # that's the exact condition under which the mapping is reused. A
+            # badge on a row where nothing gets reused would be a lie (#2700
+            # review). Model-based rows (printer_id None) never match, which is
+            # correct — the mapping is not reused there either.
+            extra = item.archive.extra_data if isinstance(item.archive.extra_data, dict) else {}
+            saved_mapping = extra.get("slicer_ams_mapping")
+            response.archive_has_slicer_ams_mapping = (
+                isinstance(saved_mapping, dict)
+                and isinstance(saved_mapping.get("mapping"), list)
+                and item.printer_id is not None
+                and saved_mapping.get("printer_id") == item.printer_id
             )
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
@@ -664,12 +676,28 @@ async def add_to_queue(
     # at a different printer, where the same tray number can hold a
     # completely different spool (#2700 review).
     #
+    # It also stands down when the request carries force-color-match overrides:
+    # those are the caller asking the scheduler to match strictly against the
+    # printer's live trays, and they are only ever applied inside
+    # `_compute_ams_mapping_for_printer` — the function a stored mapping makes
+    # the scheduler skip. Same precedence as the VP-side toggle pair (#2700
+    # review).
+    #
     # Note this is otherwise 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 and data.printer_id is not None:
+    wants_live_color_match = any(
+        isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
+    )
+    if (
+        ams_mapping_json is None
+        and not wants_live_color_match
+        and archive
+        and archive.extra_data
+        and data.printer_id is not None
+    ):
         saved = archive.extra_data.get("slicer_ams_mapping")
         if (
             isinstance(saved, dict)

+ 10 - 6
backend/app/models/virtual_printer.py

@@ -51,12 +51,16 @@ class VirtualPrinter(Base):
     # 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.
+    )  # queue mode: keep the slicer's own live-resolved AMS-slot pick (the
+    # `ams_mapping` field on the MQTT `project_file` command) instead of
+    # re-deriving one from the file's static type/color. Stamps it on the queue
+    # item so THIS print dispatches to those trays, and onto the archive's
+    # `extra_data.slicer_ams_mapping` so a later reprint can reuse the same
+    # physical spools. Off by default: taking the slicer's pick makes the
+    # scheduler skip `_compute_ams_mapping_for_printer`, and with it
+    # `prefer_lowest_filament`, its AMS-backup gate (#1766) and the
+    # inventory-remain overrides — so it stays opt-in per virtual printer
+    # rather than changing behaviour for upgraders (#2700).
     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

+ 4 - 3
backend/app/schemas/print_queue.py

@@ -195,9 +195,10 @@ class PrintQueueItemResponse(BaseModel):
     # `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.
+    # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
+    # against this row's own printer — the only case where dispatch actually
+    # reuses that exact physical spool instead of the scheduler re-deriving one
+    # from the file's static type/color.
     archive_has_slicer_ams_mapping: bool = False
 
     # User tracking (Issue #206)

+ 106 - 34
backend/app/services/virtual_printer/manager.py

@@ -178,6 +178,12 @@ def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
     scheduler's "already resolved, don't touch it" branch in
     ``_ensure_ams_mapping`` use the slicer's own choice unchanged.
 
+    That branch skipping ``_compute_ams_mapping_for_printer`` is also what
+    makes this a trade rather than a pure win: ``prefer_lowest_filament``, its
+    AMS-filament-backup gate (#1766), the inventory-remain overrides and the
+    per-slot force-color overrides all live inside that function. Callers are
+    responsible for the gating — this parser only says what the slicer sent.
+
     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.
@@ -521,19 +527,28 @@ class VirtualPrinterInstance:
             if raw is not None:
                 patch["nozzle_mapping"] = json.dumps(raw)
 
-        # Same target_printer_id gate as the immediate path in
-        # _add_to_print_queue — a model-based VP has no MQTT bridge to a real
-        # printer, so there's no live AMS layout for the slicer to have
-        # resolved tray IDs against.
+        # Same two gates as the immediate path in `_add_to_print_queue`: a
+        # model-based VP has no live AMS layout for the slicer to have resolved
+        # tray IDs against, and taking the slicer's pick at all is the per-VP
+        # `save_ams_mapping` opt-in (it makes the scheduler skip
+        # `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
+        # #1766 backup gate).
         ams_mapping_json = (
             _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
-            if self.target_printer_id is not None
+            if self.target_printer_id is not None and self.save_ams_mapping
             else None
         )
-        if ams_mapping_json is not None:
+        # `Force color match` still wins for this dispatch — see the same
+        # decision in `_add_to_print_queue`. The archive patch below is
+        # deliberately not gated on it: persisting the pick for later reprints
+        # is exactly what the toggle promises.
+        if ams_mapping_json is not None and not self.queue_force_color_match:
             patch["ams_mapping"] = ams_mapping_json
 
-        if not patch:
+        # `ams_mapping_json` alone is enough to keep going even when `patch` is
+        # empty: with `Force color match` on it never reaches the queue item,
+        # but it still has to be written onto the archive below.
+        if not patch and ams_mapping_json is None:
             self._recent_queue_items.pop(stash_key, None)
             return
 
@@ -557,7 +572,8 @@ class VirtualPrinterInstance:
                 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))
+                if patch:
+                    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
@@ -565,10 +581,11 @@ class VirtualPrinterInstance:
                 # 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:
+                # Already gated on `save_ams_mapping` above, and deliberately
+                # NOT on `queue_force_color_match`: that toggle decides how
+                # *this* print is matched, not whether the pick is worth
+                # keeping for a later reprint.
+                if ams_mapping_json is not None:
                     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)))
@@ -582,11 +599,12 @@ class VirtualPrinterInstance:
 
                 await db.commit()
                 logger.info(
-                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
+                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s%s",
                     self.name,
                     stash_key,
                     sorted(patch.keys()),
                     eligible_ids,
+                    " and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
                 )
         except Exception as e:
             logger.error(
@@ -929,18 +947,53 @@ class VirtualPrinterInstance:
                 # type/color re-derivation entirely and dispatch use exactly
                 # the tray the slicer/user picked.
                 #
-                # Only trust it when this VP targets one fixed printer. A
-                # model-based ("Any <model>") VP has no MQTT bridge to a real
-                # printer, so the slicer has no live AMS layout to resolve
-                # tray IDs against — whatever it sends here is meaningless
-                # (or, worse, coincidentally valid for the wrong printer once
-                # the scheduler later picks one). Leaving it unset lets the
-                # scheduler's normal type/color re-derivation run against
-                # whichever printer actually gets the job.
+                # Two gates, both required:
+                #
+                # 1. This VP must target one fixed printer. A model-based
+                #    ("Any <model>") VP has no MQTT bridge to a real printer,
+                #    so the slicer has no live AMS layout to resolve tray IDs
+                #    against — whatever it sends here is meaningless (or,
+                #    worse, coincidentally valid for the wrong printer once
+                #    the scheduler later picks one).
+                # 2. The per-VP `save_ams_mapping` opt-in must be on. Taking
+                #    the slicer's pick means `_ensure_ams_mapping` returns
+                #    early and `_compute_ams_mapping_for_printer` never runs —
+                #    and that function is where `prefer_lowest_filament`, its
+                #    AMS-filament-backup gate (#1766) and the inventory-remain
+                #    overrides live. Honouring the slicer unconditionally would
+                #    silently retire all of that for every existing queue-mode
+                #    VP on upgrade, so it's opt-in like every other queue-mode
+                #    behaviour toggle (#2700 review).
+                #
+                # Either gate failing leaves it unset, and the scheduler's
+                # normal type/color re-derivation runs against whichever
+                # printer actually gets the job.
                 ams_mapping_json: str | None = None
-                if slicer_opts is not None and self.target_printer_id is not None:
+                if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
                     ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
 
+                # `Force color match` is the user asking Bambuddy to do the
+                # matching strictly, against the printer's live trays. Its only
+                # effect on a fixed-printer item is via the per-slot
+                # `filament_overrides` written below, which are consumed inside
+                # `_compute_ams_mapping_for_printer` — the exact function a
+                # stored mapping skips. So when both toggles are on, the
+                # explicit strictness wins for *this* dispatch and the slicer's
+                # pick is still persisted onto the archive for later reprints,
+                # which is what `Save AMS mapping` actually promises (#2700
+                # review).
+                queue_ams_mapping_json = ams_mapping_json
+                if queue_ams_mapping_json is not None and self.queue_force_color_match:
+                    logger.info(
+                        "[VP %s] Saved the slicer's AMS pick to the archive but not onto the queue item(s): "
+                        "'Force color match' is on, so the scheduler matches against live trays for this print.",
+                        self.name,
+                    )
+                    queue_ams_mapping_json = None
+
+                # Parsed once for the per-plate length check in the loop below.
+                queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
+
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -953,17 +1006,11 @@ class VirtualPrinterInstance:
                     prefer_filename_for_name=prefer_filename,
                     # 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. Tagged with the printer it was
-                    # resolved against so a later reprint on a *different*
-                    # printer knows not to reuse it (#2700 review).
-                    slicer_ams_mapping=(
-                        json.loads(ams_mapping_json) if ams_mapping_json and self.save_ams_mapping else None
-                    ),
+                    # later reprint can reuse it. Already gated on the per-VP
+                    # `save_ams_mapping` opt-in above. Tagged with the printer
+                    # it was resolved against so a later reprint on a
+                    # *different* printer knows not to reuse it (#2700 review).
+                    slicer_ams_mapping=(json.loads(ams_mapping_json) if ams_mapping_json else None),
                     slicer_ams_mapping_printer_id=self.target_printer_id,
                 )
                 if archive:
@@ -1046,6 +1093,31 @@ class VirtualPrinterInstance:
                                 if overrides:
                                     filament_overrides_json = json.dumps(overrides)
 
+                        # The slicer's mapping is indexed by the 3MF's own
+                        # file-global slot ids (position = slot_id - 1), so one
+                        # array covers every plate of a multi-plate Send All —
+                        # each plate just reads the entries for the slots it
+                        # actually prints. What must be checked is that it
+                        # reaches that far: a mapping shorter than this plate's
+                        # highest slot id can't address the plate's own slots,
+                        # and `_ensure_ams_mapping` would keep it anyway
+                        # because it only rejects an all-unresolved mapping. Fall
+                        # back to a computed mapping for that plate instead
+                        # (#2700 review).
+                        plate_ams_mapping_json = queue_ams_mapping_json
+                        if queue_ams_mapping is not None and requirements:
+                            max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
+                            if max_slot_id > len(queue_ams_mapping):
+                                logger.warning(
+                                    "[VP %s] Slicer ams_mapping has %d entries but plate %s needs slot %d; "
+                                    "dropping it for this plate so the scheduler computes one from live AMS state.",
+                                    self.name,
+                                    len(queue_ams_mapping),
+                                    plate_id,
+                                    max_slot_id,
+                                )
+                                plate_ams_mapping_json = None
+
                         queue_item = PrintQueueItem(
                             printer_id=self.target_printer_id,
                             target_model=target_model,
@@ -1073,7 +1145,7 @@ class VirtualPrinterInstance:
                             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,
+                            ams_mapping=plate_ams_mapping_json,
                         )
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging

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

@@ -376,6 +376,93 @@ class TestPrintQueueAPI:
         result = response.json()
         assert result["ams_mapping"] is None
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_force_color_match_overrides_beat_the_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Force-color-match overrides are the caller asking the scheduler to
+        match strictly against the printer's live trays, and they are only ever
+        applied inside `_compute_ams_mapping_for_printer` — the function a
+        stored mapping makes the scheduler skip. Inheriting the saved mapping
+        here would silently retire the strictness that was just requested
+        (#2700 review).
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [
+                {"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True},
+            ],
+        }
+        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_plain_overrides_still_allow_the_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Only force_color_match stands the fallback down. A plain preference
+        override is a filament swap, not a request for live colour matching, so
+        the saved mapping is still the best starting point.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}],
+        }
+        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_queue_response_flags_saved_mapping_only_for_its_own_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """`archive_has_slicer_ams_mapping` drives a badge that claims the
+        print reuses the slicer's exact trays. Global tray IDs mean nothing on
+        another printer, so the flag must be false for a row targeting one —
+        otherwise the badge is there while nothing is reused (#2700 review).
+        """
+        origin_printer = await printer_factory()
+        other_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        own = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": origin_printer.id, "archive_id": archive.id}
+        )
+        assert own.status_code == 200
+        assert own.json()["archive_has_slicer_ams_mapping"] is True
+
+        foreign = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": other_printer.id, "archive_id": archive.id}
+        )
+        assert foreign.status_code == 200
+        assert foreign.json()["archive_has_slicer_ams_mapping"] is False
+
+        # Model-based: the scheduler hasn't picked a printer yet, so the
+        # mapping is not reused there either.
+        model_based = await async_client.post("/api/v1/queue/", json={"target_model": "X1C", "archive_id": archive.id})
+        assert model_based.status_code == 200
+        assert model_based.json()["archive_has_slicer_ams_mapping"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_plate_id(

+ 366 - 15
backend/tests/unit/services/test_virtual_printer.py

@@ -1848,16 +1848,18 @@ class TestVirtualPrinterInstance:
         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.
+    async def test_add_to_print_queue_ignores_ams_mapping_when_toggle_off(self, tmp_path):
+        """With the per-VP `save_ams_mapping` toggle off, the slicer's
+        AMS-slot pick must be ignored entirely — neither stamped on the queue
+        item nor persisted to the archive.
+
+        Taking the slicer's pick makes `_ensure_ams_mapping` return early, so
+        `_compute_ams_mapping_for_printer` never runs — and that's where
+        `prefer_lowest_filament`, its AMS-filament-backup gate (#1766) and the
+        inventory-remain overrides live. Honouring it unconditionally would
+        retire all of that for every existing queue-mode VP on upgrade, so it
+        is opt-in like every other queue-mode behaviour toggle (#2700 review).
         """
-        import json as _json
-
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
         added_items = []
@@ -1910,11 +1912,7 @@ class TestVirtualPrinterInstance:
             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: archive_print must not be told to persist a mapping.
+        assert added_items[0].ams_mapping is None
         assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
 
     @pytest.mark.asyncio
@@ -2111,6 +2109,172 @@ class TestVirtualPrinterInstance:
         assert added_items[0].ams_mapping is None
         assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_force_color_match_keeps_mapping_off_the_queue_item(self, tmp_path):
+        """`Force color match` and `Save AMS mapping` both on: the archive
+        still gets the slicer's pick (that's what the toggle promises for
+        later reprints), but THIS dispatch does not.
+
+        Force color match's only effect on a fixed-printer item is via the
+        per-slot `filament_overrides`, which are consumed inside
+        `_compute_ams_mapping_for_printer` — the exact function a stored
+        mapping makes `_ensure_ams_mapping` skip. Leaving the mapping on the
+        item would silently retire the user's explicit strictness (#2700
+        review).
+        """
+        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.flush = AsyncMock()
+        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=47,
+            name="AMSMappingVsForceColor",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800047",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+            queue_force_color_match=True,
+            target_printer_id=7,
+        )
+
+        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 added_items[0].ams_mapping is None
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] == [4, -1, 12, -1]
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] == 7
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_drops_ams_mapping_too_short_for_the_plate(self, tmp_path, monkeypatch):
+        """A slicer mapping shorter than the plate's highest slot id can't
+        address that plate's own slots, and `_ensure_ams_mapping` would keep it
+        anyway (it only rejects an all-unresolved mapping). Drop it for that
+        plate so the scheduler computes one from live AMS state instead.
+
+        The mapping is indexed by the 3MF's file-global slot ids, so the plate
+        that fits keeps it — only the one that doesn't falls back (#2700
+        review).
+        """
+        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.flush = AsyncMock()
+        mock_db.commit = AsyncMock()
+        mock_db.execute = AsyncMock()
+        mock_db.execute.return_value.scalar.return_value = None
+        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=48,
+            name="AMSMappingShort",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800048",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+            target_printer_id=7,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2])
+
+        # Plate 1 prints slots 1-2 (fits the 2-entry mapping); plate 2 also
+        # prints slot 4, which the mapping cannot address.
+        def fake_reqs(_path, plate_id):
+            if plate_id == 1:
+                return [
+                    {"slot_id": 1, "type": "PLA", "color": "#FF0000"},
+                    {"slot_id": 2, "type": "PLA", "color": "#00FF00"},
+                ]
+            return [
+                {"slot_id": 1, "type": "PLA", "color": "#FF0000"},
+                {"slot_id": 4, "type": "PLA", "color": "#0000FF"},
+            ]
+
+        monkeypatch.setattr(
+            "backend.app.services.filament_requirements.extract_filament_requirements",
+            fake_reqs,
+        )
+
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "ams_mapping": [4, 12]},
+        )
+
+        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) == 2
+        assert _json.loads(added_items[0].ams_mapping) == [4, 12]
+        assert added_items[1].ams_mapping is None
+
+        # The archive keeps the mapping either way — it is the file's, not one
+        # plate's, and a reprint of the plate that fits still wants it.
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] == [4, 12]
+
     @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
@@ -2329,7 +2493,9 @@ class TestVirtualPrinterInstance:
         select_pending_result.all = MagicMock(return_value=[(101, 55)])
         update_result = MagicMock()
         select_archives_result = MagicMock()
-        select_archives_result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row])))
+        select_archives_result.scalars = MagicMock(
+            return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row]))
+        )
         mock_db.execute = AsyncMock(
             side_effect=[position_max_result, select_pending_result, update_result, select_archives_result]
         )
@@ -2495,6 +2661,191 @@ class TestVirtualPrinterInstance:
         assert "ams_mapping" not in dict(compiled.params)
         assert dict(compiled.params)["timelapse"] is True
 
+    @pytest.mark.asyncio
+    async def test_on_print_command_late_mqtt_force_color_match_archives_but_does_not_stamp(self, tmp_path):
+        """The late path splits the same way the immediate one does when
+        `Force color match` is on: the archive still records the slicer's pick
+        for later reprints, but it must not reach the queue item, or the
+        overrides the user asked for would never be applied (#2700 review).
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(
+            side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0]
+        )
+        mock_db.flush = AsyncMock()
+        mock_db.commit = AsyncMock()
+
+        mock_archive_row = MagicMock()
+        mock_archive_row.id = 75
+        mock_archive_row.extra_data = None
+
+        position_max_result = MagicMock()
+        position_max_result.scalar = MagicMock(return_value=None)
+        select_pending_result = MagicMock()
+        select_pending_result.all = MagicMock(return_value=[(301, 75)])
+        update_result = MagicMock()
+        select_archives_result = MagicMock()
+        select_archives_result.scalars = MagicMock(
+            return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row]))
+        )
+        mock_db.execute = AsyncMock(
+            side_effect=[position_max_result, select_pending_result, update_result, select_archives_result]
+        )
+
+        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=96,
+            name="LateMQTTForceColor",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800096",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+            queue_force_color_match=True,
+            target_printer_id=7,
+        )
+        inst._mqtt = MagicMock()
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 75
+        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,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
+                0.05,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "file": file_path.name,
+                "ams_mapping": [4, -1, 12, -1],
+                # Keeps the UPDATE non-empty so the assertion below is "the
+                # mapping was excluded", not "nothing ran".
+                "timelapse": True,
+            },
+        )
+
+        update_call = mock_db.execute.await_args_list[2]
+        compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
+        assert "ams_mapping" not in dict(compiled.params)
+        assert dict(compiled.params)["timelapse"] is True
+
+        assert mock_archive_row.extra_data == {
+            "slicer_ams_mapping": {"mapping": [4, -1, 12, -1], "printer_id": 7},
+        }
+
+    @pytest.mark.asyncio
+    async def test_on_print_command_late_mqtt_ignores_ams_mapping_when_toggle_off(self, tmp_path):
+        """Toggle off: the late path must not stamp the queue item either, or
+        the opt-in would leak in through the #1780 race window.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(
+            side_effect=lambda item: (added_items.append(item), setattr(item, "id", 400 + len(added_items)))[0]
+        )
+        mock_db.flush = AsyncMock()
+        mock_db.commit = AsyncMock()
+
+        position_max_result = MagicMock()
+        position_max_result.scalar = MagicMock(return_value=None)
+        select_pending_result = MagicMock()
+        select_pending_result.all = MagicMock(return_value=[(401, 85)])
+        update_result = MagicMock()
+        # No 4th execute(): with nothing to save there is no archive SELECT.
+        mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
+
+        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=95,
+            name="LateMQTTToggleOff",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800095",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=False,
+            target_printer_id=7,
+        )
+        inst._mqtt = MagicMock()
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 85
+        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,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
+                0.05,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "file": file_path.name,
+                "ams_mapping": [4, -1, 12, -1],
+                "timelapse": True,
+            },
+        )
+
+        assert mock_db.execute.await_count == 3
+        update_call = mock_db.execute.await_args_list[2]
+        compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
+        assert "ams_mapping" not in dict(compiled.params)
+        assert dict(compiled.params)["timelapse"] is True
+
     @pytest.mark.asyncio
     async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
         """The actual race-window scenario: wait_for times out, then MQTT

+ 195 - 0
frontend/src/__tests__/components/FilamentMappingArchivePick.test.tsx

@@ -0,0 +1,195 @@
+/**
+ * Tests for the FilamentMapping "Mapping" toggle (#2700).
+ *
+ * When the archive carries the slicer's own saved AMS-slot pick, a toggle next
+ * to "Re-read" selects every slot straight from it, bypassing the type/color
+ * auto-match. Turning it back off has to undo exactly what it did and leave
+ * hand-made picks alone — that bookkeeping is what these tests pin.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { useState } from 'react';
+import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+// Two-slot print. Slot 1 wants red PLA, slot 2 wants green PETG.
+const TWO_SLOT_REQS = {
+  filaments: [
+    { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, used_meters: 3 },
+    { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, used_meters: 3 },
+  ],
+};
+
+// One AMS, four trays -> global tray IDs 0..3. Trays 0 and 2 both hold red PLA,
+// which is exactly the ambiguity the saved slicer pick exists to resolve: the
+// auto-match has no way to tell which red spool the user meant.
+function createStatus(): PrinterStatus {
+  return {
+    id: 1,
+    name: 'X1C',
+    connected: true,
+    state: 'IDLE',
+    ams: [
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red A' },
+          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Green' },
+          { id: 2, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red B' },
+          { id: 3, tray_type: 'PLA', tray_color: '0000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'Blue' },
+        ],
+      },
+    ],
+    vt_tray: [],
+    ams_extruder_map: {},
+    fila_switch: null,
+  } as unknown as PrinterStatus;
+}
+
+/** Holds `manualMappings` the way PrintModal does, so an OFF click sees the
+ *  state the ON click produced rather than the initial prop. */
+function Harness({
+  archiveAmsMapping,
+  initialManualMappings = {},
+  onChange,
+}: {
+  archiveAmsMapping?: number[];
+  initialManualMappings?: Record<number, number>;
+  onChange?: (m: Record<number, number>) => void;
+}) {
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>(initialManualMappings);
+  return (
+    <FilamentMapping
+      printerId={1}
+      filamentReqs={TWO_SLOT_REQS}
+      manualMappings={manualMappings}
+      onManualMappingChange={(m) => {
+        setManualMappings(m);
+        onChange?.(m);
+      }}
+      currencySymbol="$"
+      defaultCostPerKg={0}
+      defaultExpanded
+      archiveAmsMapping={archiveAmsMapping}
+    />
+  );
+}
+
+/** The panel only finishes mounting once printer status has loaded. */
+async function waitForPanel() {
+  await waitFor(() => {
+    expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
+  });
+}
+
+beforeEach(() => {
+  server.use(
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus())),
+    http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+  );
+});
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FilamentMapping — saved slicer AMS pick', () => {
+  it('hides the toggle when the archive has no saved mapping', async () => {
+    // Every archive predating the feature, every library file, and every
+    // reprint aimed at a printer other than the one the mapping came from.
+    render(<Harness />);
+    await waitForPanel();
+    expect(screen.queryByRole('button', { name: 'Mapping' })).not.toBeInTheDocument();
+  });
+
+  it('selects every slot from the saved mapping when switched on', async () => {
+    // Saved pick says slot 1 -> tray 2 (the *second* red spool) and slot 2 ->
+    // tray 1. Auto-match would have taken tray 0 for slot 1, so this is a
+    // visible, load-bearing difference.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, 1]} onChange={onChange} />);
+    await waitForPanel();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
+
+    expect(onChange).toHaveBeenCalledTimes(1);
+    expect(onChange).toHaveBeenCalledWith({ 1: 2, 2: 1 });
+  });
+
+  it('skips slots the slicer left unresolved', async () => {
+    // -1 is the slicer saying "no AMS tray for this filament" (external spool,
+    // or it simply didn't resolve). Overriding that slot with -1 would be
+    // worse than leaving it to the auto-match.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[-1, 1]} onChange={onChange} />);
+    await waitForPanel();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
+
+    expect(onChange).toHaveBeenCalledWith({ 2: 1 });
+  });
+
+  it('skips slots the saved mapping is too short to address', async () => {
+    // A mapping with fewer entries than the plate has slots can't say anything
+    // about the missing ones; reading past the end would write `undefined`.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2]} onChange={onChange} />);
+    await waitForPanel();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
+
+    expect(onChange).toHaveBeenCalledWith({ 1: 2 });
+  });
+
+  it('undoes exactly its own picks when switched off', async () => {
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, 1]} onChange={onChange} />);
+    await waitForPanel();
+
+    const toggle = screen.getByRole('button', { name: 'Mapping' });
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 });
+
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({});
+  });
+
+  it('leaves a hand-made pick untouched when switched off', async () => {
+    // The user picked slot 2 by hand first, then switched the toggle on, which
+    // overwrote it as part of applying the whole saved mapping. Switching off
+    // removes both, since both are now the toggle's own picks — the earlier
+    // hand pick is not restored, and slot 2 falls back to the auto-match. That
+    // is the documented behaviour, not an accident; the next test covers the
+    // case where the hand pick does survive.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, 1]} initialManualMappings={{ 2: 3 }} onChange={onChange} />);
+    await waitForPanel();
+
+    const toggle = screen.getByRole('button', { name: 'Mapping' });
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 });
+
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({});
+  });
+
+  it('keeps hand-made picks for slots the saved mapping never touched', async () => {
+    // Saved mapping only resolves slot 1, so slot 2's hand-made pick was never
+    // one of "its own" and has to survive the round trip.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, -1]} initialManualMappings={{ 2: 3 }} onChange={onChange} />);
+    await waitForPanel();
+
+    const toggle = screen.getByRole('button', { name: 'Mapping' });
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 3 });
+
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 2: 3 });
+  });
+});

+ 72 - 0
frontend/src/__tests__/components/archiveAmsMapping.test.ts

@@ -0,0 +1,72 @@
+/**
+ * Tests for `resolveArchiveSlicerAmsMapping` (#2700).
+ *
+ * This is the gate between an archive's saved slicer AMS pick and the print
+ * modal offering it. The saved tray IDs are *global tray IDs*, which only mean
+ * something against the AMS layout of the one printer they were resolved
+ * against — tray 5 on printer A can hold a completely different spool than
+ * tray 5 on printer B. Everything here exists to make sure the mapping is only
+ * ever offered for its own printer.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { resolveArchiveSlicerAmsMapping } from '../../components/PrintModal/archiveAmsMapping';
+
+const SAVED = { slicer_ams_mapping: { mapping: [4, -1, 12, -1], printer_id: 7 } };
+
+describe('resolveArchiveSlicerAmsMapping', () => {
+  it('returns the mapping when the selected printer is the one it was saved for', () => {
+    expect(resolveArchiveSlicerAmsMapping(SAVED, 7)).toEqual([4, -1, 12, -1]);
+  });
+
+  it('refuses the mapping on a different printer', () => {
+    // The whole point of storing printer_id. Tray 4 on printer 9 is not the
+    // spool the slicer picked on printer 7.
+    expect(resolveArchiveSlicerAmsMapping(SAVED, 9)).toBeUndefined();
+  });
+
+  it('refuses the mapping when no printer is selected yet', () => {
+    // Guards the `undefined === undefined` reading as a match: with no printer
+    // chosen there is nothing to compare against, so nothing may be offered.
+    expect(resolveArchiveSlicerAmsMapping(SAVED, null)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping(SAVED, undefined)).toBeUndefined();
+  });
+
+  it('returns undefined for archives with no extra_data at all', () => {
+    expect(resolveArchiveSlicerAmsMapping(null, 7)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping(undefined, 7)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping({}, 7)).toBeUndefined();
+  });
+
+  it('ignores unrelated extra_data keys', () => {
+    // The common case: archives carry plenty of metadata but no saved mapping.
+    expect(resolveArchiveSlicerAmsMapping({ printable_objects: { '1': 'part' } }, 7)).toBeUndefined();
+  });
+
+  it('rejects a stored value that is missing its printer_id', () => {
+    // A mapping saved without knowing which printer it came from can't be
+    // safely reused on any printer, including the one it actually came from —
+    // there'd be no way to tell.
+    expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [4, 12] } }, 7)).toBeUndefined();
+  });
+
+  it('rejects malformed stored values instead of throwing', () => {
+    // extra_data is free-form JSON off the wire; a bad shape must degrade to
+    // "no saved mapping", never crash the modal.
+    expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: 'nope' }, 7)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: null }, 7)).toBeUndefined();
+    expect(
+      resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: 'nope', printer_id: 7 } }, 7),
+    ).toBeUndefined();
+    expect(
+      resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [], printer_id: 7 } }, 7),
+    ).toBeUndefined();
+  });
+
+  it('does not coerce printer ids', () => {
+    // A string "7" from a hand-edited record is not printer 7.
+    expect(
+      resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [4], printer_id: '7' } }, 7),
+    ).toBeUndefined();
+  });
+});

+ 46 - 0
frontend/src/components/PrintModal/archiveAmsMapping.ts

@@ -0,0 +1,46 @@
+/**
+ * Reading the archive's saved slicer AMS-slot pick back out of `extra_data`.
+ *
+ * A virtual printer with "Save AMS mapping" on stores the slicer's own
+ * live-resolved tray choice on the archive as
+ * `extra_data.slicer_ams_mapping = { mapping, printer_id }` (written by
+ * `ArchiveService.archive_print`). The tray IDs in `mapping` are global tray
+ * IDs, which only mean something against the AMS layout of the one printer
+ * they were resolved against — slot 3 on another printer can hold a completely
+ * different spool. `printer_id` records which printer that was.
+ *
+ * Lives here rather than inline in the modal so the printer-scoping rule can
+ * be tested on its own: it is the only thing standing between a saved mapping
+ * and the wrong physical spool.
+ */
+
+/** Shape of `extra_data.slicer_ams_mapping`. Every field optional — this is
+ *  free-form JSON off the wire, and older archives predate the key entirely. */
+export interface SavedSlicerAmsMapping {
+  mapping?: number[];
+  printer_id?: number;
+}
+
+/**
+ * The saved mapping, but only when it is safe to apply to `printerId`.
+ *
+ * Returns `undefined` — meaning "no saved mapping in scope, behave as before" —
+ * when the archive has none, when the stored value is malformed, when no
+ * printer is selected yet, or when the selected printer is not the one the
+ * mapping was resolved against.
+ */
+export function resolveArchiveSlicerAmsMapping(
+  extraData: Record<string, unknown> | null | undefined,
+  printerId: number | null | undefined,
+): number[] | undefined {
+  // No printer selected means there is nothing to compare against. Bailing
+  // here also stops `undefined === undefined` from reading as a match below.
+  if (printerId == null) return undefined;
+
+  const saved = extraData?.slicer_ams_mapping as SavedSlicerAmsMapping | undefined;
+  if (!saved || typeof saved !== 'object') return undefined;
+  if (saved.printer_id !== printerId) return undefined;
+  if (!Array.isArray(saved.mapping) || saved.mapping.length === 0) return undefined;
+
+  return saved.mapping;
+}

+ 11 - 15
frontend/src/components/PrintModal/index.tsx

@@ -22,6 +22,7 @@ import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
+import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
 import { PlateSelector } from './PlateSelector';
@@ -327,21 +328,16 @@ export function PrintModal({
 
   // 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. Stored as
-  // `{mapping, printer_id}`: the tray IDs are only meaningful against the
-  // specific printer's AMS layout they were resolved from, so only surface
-  // them when reprinting on that same printer — a different printer's slot 3
-  // can hold a completely different spool.
-  const archiveSlicerAmsMappingRaw = !isLibraryFile
-    ? (archiveDetails?.extra_data?.slicer_ams_mapping as
-        | { mapping?: number[]; printer_id?: number }
-        | undefined)
-    : undefined;
-  const archiveSlicerAmsMapping =
-    archiveSlicerAmsMappingRaw?.printer_id === effectivePrinterId &&
-    Array.isArray(archiveSlicerAmsMappingRaw.mapping)
-      ? archiveSlicerAmsMappingRaw.mapping
-      : undefined;
+  // archives that predate the feature / had it off at print time, and
+  // deliberately undefined unless the selected printer is the one the mapping
+  // was resolved against. See `resolveArchiveSlicerAmsMapping`.
+  const archiveSlicerAmsMapping = useMemo(
+    () =>
+      isLibraryFile
+        ? undefined
+        : resolveArchiveSlicerAmsMapping(archiveDetails?.extra_data, effectivePrinterId),
+    [isLibraryFile, archiveDetails?.extra_data, effectivePrinterId],
+  );
 
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({

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

@@ -135,6 +135,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
+      // Queue-mode behaviour toggles. Without these the switch stays visually
+      // flipped after a failed save, so the card claims a setting the server
+      // never accepted.
+      setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
+      setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
+      setLocalGcodeInjection(printer.gcode_injection ?? false);
       setPendingAction(null);
     },
   });

+ 4 - 4
frontend/src/i18n/locales/de.ts

@@ -935,8 +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',
+      slicerAmsMapping: 'AMS-Zuordnung gespeichert ({{printer}})',
+      slicerAmsMappingTooltip: 'Die AMS-Steckplatzwahl des Slicers wurde für {{printer}} gespeichert. Steckplatznummern gelten nur für diesen Drucker, daher wird sie bei einem erneuten Druck nur wiederverwendet, wenn er wieder an {{printer}} geht.',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
       openInBambuStudio: 'Im Slicer öffnen',
@@ -1147,8 +1147,8 @@ export default {
       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.',
+      rowBadge: 'AMS-Steckplätze für diesen Drucker gespeichert',
+      rowTooltip: 'Dieses Archiv enthält die genauen AMS-Steckplätze, die der Slicer ausgewählt hat, gespeichert für den Drucker dieses Eintrags. Ein erneuter Druck darauf kann diese Fächer wiederverwenden, statt erneut nach Typ und Farbe zuzuordnen.',
     },
     title: 'Druckwarteschlange',
     subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',

+ 4 - 4
frontend/src/i18n/locales/en.ts

@@ -939,8 +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',
+      slicerAmsMapping: 'AMS mapping saved ({{printer}})',
+      slicerAmsMappingTooltip: 'The slicer\'s own AMS slot choice was saved for {{printer}}. Tray numbers only mean something on that printer, so a reprint reuses them only when it targets {{printer}} again.',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
       openInBambuStudio: 'Open in Slicer',
@@ -1158,8 +1158,8 @@ export default {
       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.',
+      rowBadge: 'AMS slots saved for this printer',
+      rowTooltip: 'This archive carries the exact AMS slots the slicer picked, saved for the printer this item targets. A reprint on it can reuse those trays instead of matching again by type and colour.',
     },
     // Print modal
     editQueueItem: 'Edit Queue Item',

+ 4 - 4
frontend/src/i18n/locales/es.ts

@@ -935,8 +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',
+      slicerAmsMapping: 'Mapeo de AMS guardado ({{printer}})',
+      slicerAmsMappingTooltip: 'La elección de ranura AMS del slicer se guardó para {{printer}}. Los números de ranura solo significan algo en esa impresora, así que una reimpresión los reutiliza únicamente si vuelve a dirigirse a {{printer}}.',
       noPermissionEdit: 'No tiene permiso para editar archivos',
       noPermissionDelete: 'No tiene permiso para eliminar archivos',
       openInBambuStudio: 'Abrir en el laminador',
@@ -1147,8 +1147,8 @@ export default {
       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.',
+      rowBadge: 'Ranuras AMS guardadas para esta impresora',
+      rowTooltip: 'Este archivo conserva las ranuras AMS exactas que eligió el slicer, guardadas para la impresora de este elemento. Una reimpresión en ella puede reutilizar esas bobinas en lugar de volver a emparejar por tipo y color.',
     },
     title: 'Cola de impresión',
     subtitle: 'Programe y gestione sus trabajos de impresión',

+ 4 - 4
frontend/src/i18n/locales/fr.ts

@@ -935,8 +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',
+      slicerAmsMapping: 'Mappage AMS enregistré ({{printer}})',
+      slicerAmsMappingTooltip: 'Le choix d\'emplacement AMS du slicer a été enregistré pour {{printer}}. Les numéros d\'emplacement n\'ont de sens que sur cette imprimante : une réimpression ne les réutilise donc que si elle vise à nouveau {{printer}}.',
       noPermissionEdit: 'Pas d\'autorisation de modification',
       noPermissionDelete: 'Pas d\'autorisation de suppression',
       openInBambuStudio: 'Ouvrir dans le Slicer',
@@ -1147,8 +1147,8 @@ export default {
       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.',
+      rowBadge: 'Emplacements AMS enregistrés pour cette imprimante',
+      rowTooltip: 'Cette archive conserve les emplacements AMS exacts choisis par le slicer, enregistrés pour l\'imprimante de cet élément. Une réimpression dessus peut réutiliser ces bobines au lieu de refaire la correspondance par type et couleur.',
     },
     title: 'File d\'attente',
     subtitle: 'Gérez vos travaux d\'impression',

+ 4 - 4
frontend/src/i18n/locales/it.ts

@@ -935,8 +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',
+      slicerAmsMapping: 'Mappatura AMS salvata ({{printer}})',
+      slicerAmsMappingTooltip: 'La scelta dello slot AMS fatta dallo slicer è stata salvata per {{printer}}. I numeri di slot hanno senso solo su quella stampante, quindi una ristampa li riutilizza solo se torna su {{printer}}.',
       noPermissionEdit: 'Non hai il permesso di modificare archivi',
       noPermissionDelete: 'Non hai il permesso di eliminare archivi',
       openInBambuStudio: 'Apri nello slicer',
@@ -1147,8 +1147,8 @@ export default {
       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.',
+      rowBadge: 'Slot AMS salvati per questa stampante',
+      rowTooltip: 'Questo archivio conserva gli slot AMS esatti scelti dallo slicer, salvati per la stampante di questo elemento. Una ristampa su di essa può riutilizzare quelle bobine invece di rifare l\'abbinamento per tipo e colore.',
     },
     title: 'Coda di stampa',
     subtitle: 'Programma e gestisci i tuoi lavori di stampa',

+ 4 - 4
frontend/src/i18n/locales/ja.ts

@@ -934,8 +934,8 @@ export default {
       uploadedBy: 'アップロード者',
       noPermissionReprint: '再印刷する権限がありません',
       noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
-      slicerAmsMapping: 'AMSマッピングを保存しました',
-      slicerAmsMappingTooltip: 'このアーカイブにはスライサーから保存されたAMSスロットマッピングがあります',
+      slicerAmsMapping: 'AMSマッピングを保存済み({{printer}})',
+      slicerAmsMappingTooltip: 'スライサーが選んだAMSスロットは{{printer}}用に保存されています。スロット番号はそのプリンターでのみ意味を持つため、再印刷で再利用されるのは再び{{printer}}に送る場合だけです。',
       noPermissionEdit: 'プロファイルを編集する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
       openInBambuStudio: 'スライサーで開く',
@@ -1146,8 +1146,8 @@ export default {
       printAnyway: 'それでも印刷',
     },
     slicerAmsMapping: {
-      rowBadge: 'スライサーから保存されたAMSスロット',
-      rowTooltip: 'このアーカイブには、スライス/送信時にスライサーが選択した正確なAMSスロットが保持されています。再印刷では、タイプ/色から再推測するのではなく、その物理スプールが再利用されます。',
+      rowBadge: 'このプリンター用に保存されたAMSスロット',
+      rowTooltip: 'このアーカイブには、スライサーが選択した正確なAMSスロットが、この項目の対象プリンター用に保存されています。そのプリンターでの再印刷では、タイプと色で照合し直す代わりにそれらのトレイを再利用できます。',
     },
     title: '印刷キュー',
     subtitle: '印刷ジョブのスケジュールと管理',

+ 4 - 4
frontend/src/i18n/locales/ko.ts

@@ -890,8 +890,8 @@ export default {
       uploadedBy: '업로드한 사용자',
       noPermissionReprint: '재인쇄 권한이 없습니다',
       noFileForReprint: '3MF 파일 없음 — 인쇄 기록 시 프린터에서 파일을 다운로드할 수 없었습니다',
-      slicerAmsMapping: 'AMS 매핑 저장됨',
-      slicerAmsMappingTooltip: '이 아카이브에는 슬라이서에서 저장된 AMS 슬롯 매핑이 있습니다',
+      slicerAmsMapping: 'AMS 매핑 저장됨({{printer}})',
+      slicerAmsMappingTooltip: '슬라이서가 선택한 AMS 슬롯이 {{printer}}용으로 저장되었습니다. 슬롯 번호는 해당 프린터에서만 의미가 있으므로, 다시 {{printer}}로 보낼 때만 재인쇄에서 재사용됩니다.',
       noPermissionEdit: '아카이브를 편집할 권한이 없습니다',
       noPermissionDelete: '아카이브를 삭제할 권한이 없습니다',
       openInBambuStudio: '슬라이서에서 열기',
@@ -1353,8 +1353,8 @@ export default {
       printAnyway: '그냥 인쇄'
     },
     slicerAmsMapping: {
-      rowBadge: '슬라이서에서 저장된 AMS 슬롯',
-      rowTooltip: '이 아카이브에는 슬라이싱/전송 시 슬라이서가 선택한 정확한 AMS 슬롯이 보존되어 있습니다. 재인쇄 시 유형/색상으로 다시 추측하는 대신 해당 실물 스풀을 재사용합니다.',
+      rowBadge: '이 프린터용으로 저장된 AMS 슬롯',
+      rowTooltip: '이 아카이브에는 슬라이서가 선택한 정확한 AMS 슬롯이 이 항목의 대상 프린터용으로 저장되어 있습니다. 해당 프린터에서 재인쇄하면 유형과 색상으로 다시 맞추는 대신 그 트레이를 재사용할 수 있습니다.',
     },
   },
   stats: {

+ 4 - 4
frontend/src/i18n/locales/pt-BR.ts

@@ -935,8 +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',
+      slicerAmsMapping: 'Mapeamento de AMS salvo ({{printer}})',
+      slicerAmsMappingTooltip: 'A escolha de slot AMS do fatiador foi salva para {{printer}}. Os números de slot só significam algo naquela impressora, portanto uma reimpressão só os reutiliza se voltar a mirar {{printer}}.',
       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',
@@ -1147,8 +1147,8 @@ export default {
       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.',
+      rowBadge: 'Slots AMS salvos para esta impressora',
+      rowTooltip: 'Este arquivo mantém os slots AMS exatos escolhidos pelo fatiador, salvos para a impressora deste item. Uma reimpressão nela pode reutilizar esses carretéis em vez de casar novamente por tipo e cor.',
     },
     title: 'Fila de Impressão',
     subtitle: 'Agende e gerencie seus trabalhos de impressão',

+ 4 - 4
frontend/src/i18n/locales/ru.ts

@@ -891,8 +891,8 @@ export default {
       uploadedBy: "Загрузил",
       noPermissionReprint: "У вас нет разрешения на повторную печать",
       noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
-      slicerAmsMapping: "Маппинг AMS сохранён",
-      slicerAmsMappingTooltip: "У этого архива сохранён маппинг AMS-ячеек от слайсера",
+      slicerAmsMapping: "Маппинг AMS сохранён ({{printer}})",
+      slicerAmsMappingTooltip: "Выбор ячеек AMS, сделанный слайсером, сохранён для принтера {{printer}}. Номера ячеек имеют смысл только на нём, поэтому повторная печать использует их только при отправке снова на {{printer}}.",
       noPermissionEdit: "У вас нет разрешения на изменение архива",
       noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
       openInBambuStudio: "Открыть в слайсере",
@@ -1099,8 +1099,8 @@ export default {
       printAnyway: "Всё равно печатать",
     },
     slicerAmsMapping: {
-      rowBadge: "Ячейка AMS сохранена от слайсера",
-      rowTooltip: "У этого архива сохранена точная ячейка AMS, которую выбрал слайсер при нарезке/отправке. Повторная печать использует именно эту физическую катушку, а не подбор заново по типу/цвету.",
+      rowBadge: "Ячейки AMS сохранены для этого принтера",
+      rowTooltip: "У этого архива сохранены точные ячейки AMS, выбранные слайсером, — для принтера, на который нацелено это задание. Повторная печать на нём может использовать те же катушки вместо повторного подбора по типу и цвету.",
     },
     editQueueItem: "Изменить задание в очереди",
     selectAllPlates: "Выбрать все пластины ({{count}})",

+ 4 - 4
frontend/src/i18n/locales/tr.ts

@@ -935,8 +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',
+      slicerAmsMapping: 'AMS eşlemesi kaydedildi ({{printer}})',
+      slicerAmsMappingTooltip: 'Dilimleyicinin AMS yuva seçimi {{printer}} için kaydedildi. Yuva numaraları yalnızca o yazıcıda anlamlıdır; bu nedenle yeniden yazdırma bunları yalnızca yine {{printer}} hedeflendiğinde kullanır.',
       noPermissionEdit: 'Arşivleri düzenleme izniniz yok',
       noPermissionDelete: 'Arşivleri silme izniniz yok',
       openInBambuStudio: 'Dilimleyicide Aç',
@@ -1149,8 +1149,8 @@ export default {
       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.',
+      rowBadge: 'Bu yazıcı için kaydedilen AMS yuvaları',
+      rowTooltip: 'Bu arşiv, dilimleyicinin seçtiği tam AMS yuvalarını bu öğenin hedeflediği yazıcı için saklar. O yazıcıda yeniden yazdırma, tür ve renge göre yeniden eşleştirmek yerine bu makaraları yeniden kullanabilir.',
     },
     // Baskı modali
     editQueueItem: 'Kuyruk Öğesini Düzenle',

+ 4 - 4
frontend/src/i18n/locales/uk.ts

@@ -939,8 +939,8 @@ export default {
       uploadedBy: "Вивантажив",
       noPermissionReprint: "Ви не маєте дозволу на передрук",
       noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
-      slicerAmsMapping: "Зіставлення AMS збережено",
-      slicerAmsMappingTooltip: "Для цього запису збережено зіставлення слотів AMS зі слайсера",
+      slicerAmsMapping: "Зіставлення AMS збережено ({{printer}})",
+      slicerAmsMappingTooltip: "Вибір слотів AMS, зроблений слайсером, збережено для принтера {{printer}}. Номери слотів мають сенс лише на ньому, тож повторний друк використає їх, тільки якщо знову спрямований на {{printer}}.",
       noPermissionEdit: "Ви не маєте прав на редагування архівів",
       noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
       openInBambuStudio: "Відкрити у слайсері",
@@ -1158,8 +1158,8 @@ export default {
       printAnyway: "Усе одно друкувати",
     },
     slicerAmsMapping: {
-      rowBadge: "Слот AMS збережено зі слайсера",
-      rowTooltip: "Цей запис містить точний слот AMS, який вибрав слайсер під час нарізання/надсилання. Повторний друк використає саме цю фізичну котушку замість повторного добору за типом/кольором.",
+      rowBadge: "Слоти AMS збережено для цього принтера",
+      rowTooltip: "Цей запис містить точні слоти AMS, вибрані слайсером, збережені для принтера, на який націлено це завдання. Повторний друк на ньому може використати ті самі котушки замість повторного добору за типом і кольором.",
     },
     // Print modal
     editQueueItem: "Редагувати елемент черги",

+ 4 - 4
frontend/src/i18n/locales/zh-CN.ts

@@ -935,8 +935,8 @@ export default {
       uploadedBy: '上传者',
       noPermissionReprint: '您没有重新打印的权限',
       noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',
-      slicerAmsMapping: '已保存 AMS 映射',
-      slicerAmsMappingTooltip: '此存档已保存来自切片软件的 AMS 槽位映射',
+      slicerAmsMapping: '已保存 AMS 映射({{printer}})',
+      slicerAmsMappingTooltip: '切片软件选择的 AMS 槽位已为 {{printer}} 保存。槽位编号只在该打印机上有意义,因此只有再次发往 {{printer}} 时,重新打印才会复用它们。',
       noPermissionEdit: '您没有编辑归档的权限',
       noPermissionDelete: '您没有删除归档的权限',
       openInBambuStudio: '在切片软件中打开',
@@ -1147,8 +1147,8 @@ export default {
       printAnyway: '仍要打印',
     },
     slicerAmsMapping: {
-      rowBadge: '已保存来自切片软件的 AMS 槽位',
-      rowTooltip: '此存档保留了切片/发送时切片软件选择的确切 AMS 槽位。重新打印会复用该实体线材,而不是根据类型/颜色重新猜测。',
+      rowBadge: '已为此打印机保存 AMS 槽位',
+      rowTooltip: '此存档保留了切片软件选择的确切 AMS 槽位,并为该项目的目标打印机保存。在该打印机上重新打印时,可复用这些料盘,而不必再按类型和颜色重新匹配。',
     },
     title: '打印队列',
     subtitle: '排程和管理您的打印任务',

+ 4 - 4
frontend/src/i18n/locales/zh-TW.ts

@@ -935,8 +935,8 @@ export default {
       uploadedBy: '上傳者',
       noPermissionReprint: '您沒有重新列印的權限',
       noFileForReprint: '無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案',
-      slicerAmsMapping: '已儲存 AMS 對應',
-      slicerAmsMappingTooltip: '此封存已儲存來自切片軟體的 AMS 槽位對應',
+      slicerAmsMapping: '已儲存 AMS 對應({{printer}})',
+      slicerAmsMappingTooltip: '切片軟體選擇的 AMS 槽位已為 {{printer}} 儲存。槽位編號僅在該印表機上有意義,因此只有再次傳送至 {{printer}} 時,重新列印才會重複使用。',
       noPermissionEdit: '您沒有編輯歸檔的權限',
       noPermissionDelete: '您沒有刪除歸檔的權限',
       openInBambuStudio: '在切片軟體中開啟',
@@ -1147,8 +1147,8 @@ export default {
       printAnyway: '仍要列印',
     },
     slicerAmsMapping: {
-      rowBadge: '已儲存來自切片軟體的 AMS 槽位',
-      rowTooltip: '此封存保留了切片/傳送時切片軟體選擇的確切 AMS 槽位。重新列印會重複使用該實體線材,而不是依類型/顏色重新猜測。',
+      rowBadge: '已為此印表機儲存 AMS 槽位',
+      rowTooltip: '此封存保留了切片軟體選擇的確切 AMS 槽位,並為此項目的目標印表機儲存。在該印表機上重新列印時,可重複使用這些料盤,而不必再依類型與顏色重新比對。',
     },
     title: '列印佇列',
     subtitle: '排程和管理您的列印任務',

+ 34 - 14
frontend/src/pages/ArchivesPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect, useCallback } from 'react';
+import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
 import { Link, useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -148,6 +148,7 @@ async function openInSlicerWithToken(
 function ArchiveCard({
   archive,
   printerName,
+  printerMap,
   isSelected,
   onSelect,
   selectionMode,
@@ -173,6 +174,10 @@ function ArchiveCard({
   currency: string;
   t: TFunction;
   onNavigateToArchive?: (archiveId: number) => void;
+  /** Printer id -> name, for naming the printer a saved slicer AMS mapping
+   *  belongs to. The card can't know which printer a reprint will target, so
+   *  the badge names the one the mapping is actually good for. */
+  printerMap: Map<number, string>;
 }) {
   // Debug: log when card is highlighted
   if (isHighlighted) {
@@ -184,6 +189,17 @@ function ArchiveCard({
   const { hasPermission, canModify } = useAuth();
   const isMobile = useIsMobile();
   const navigate = useNavigate();
+  // Name of the printer this archive's saved slicer AMS mapping was resolved
+  // against, or undefined when there is none. Undefined also when the printer
+  // has since been deleted — a mapping whose printer is gone can never be
+  // reused, so the badge stays off rather than naming a ghost.
+  const savedSlicerAmsMappingPrinter = useMemo(() => {
+    const saved = (archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping as
+      | { mapping?: unknown; printer_id?: number }
+      | undefined;
+    if (!saved || !Array.isArray(saved.mapping) || saved.printer_id == null) return undefined;
+    return printerMap.get(saved.printer_id);
+  }, [archive.extra_data, printerMap]);
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showRunPipeline, setShowRunPipeline] = useState(false);
@@ -1112,19 +1128,17 @@ 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 as
-              | { mapping?: unknown }
-              | undefined
-          )?.mapping,
-        ) && (
-          <div className="flex items-center gap-1.5 text-bambu-green text-xs mb-3" title={t('archives.card.slicerAmsMappingTooltip')}>
+        {/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting).
+            Named with the printer it was resolved against: global tray IDs mean
+            nothing on any other printer, so a reprint only reuses these exact
+            spools when it targets that same printer. */}
+        {savedSlicerAmsMappingPrinter && (
+          <div
+            className="flex items-center gap-1.5 text-bambu-green text-xs mb-3"
+            title={t('archives.card.slicerAmsMappingTooltip', { printer: savedSlicerAmsMappingPrinter })}
+          >
             <CheckCircle2 className="w-3.5 h-3.5" />
-            {t('archives.card.slicerAmsMapping')}
+            {t('archives.card.slicerAmsMapping', { printer: savedSlicerAmsMappingPrinter })}
           </div>
         )}
 
@@ -2977,7 +2991,12 @@ export function ArchivesPage() {
     localStorage.setItem('logPageSize', logPageSize.toString());
   }, [logPageSize]);
 
-  const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
+  // Memoised: it's handed to every ArchiveCard as a prop, and a fresh Map each
+  // render would re-run their lookups for no reason.
+  const printerMap = useMemo(
+    () => new Map<number, string>(printers?.map((p) => [p.id, p.name]) || []),
+    [printers],
+  );
 
   // Extract unique materials and colors from archives
   const uniqueMaterials = [...new Set(
@@ -3731,6 +3750,7 @@ export function ArchivesPage() {
                 key={archive.id}
                 archive={archive}
                 printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model || 'No Printer')}
+                printerMap={printerMap}
                 isSelected={selectedIds.has(archive.id)}
                 onSelect={toggleSelect}
                 selectionMode={selectionMode}

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


+ 1 - 1
static/index.html

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

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