Przeglądaj źródła

Merge pull request #2700 from Striker72rus/feature/save-ams-mapping-toggle

Feature/save ams mapping toggle
MartinNYHC 1 miesiąc temu
rodzic
commit
11bad70a6b
35 zmienionych plików z 2111 dodań i 66 usunięć
  1. 1 0
      CHANGELOG.md
  2. 63 0
      backend/app/api/routes/print_queue.py
  3. 6 0
      backend/app/api/routes/virtual_printers.py
  4. 9 0
      backend/app/core/database.py
  5. 12 0
      backend/app/models/virtual_printer.py
  6. 6 0
      backend/app/schemas/print_queue.py
  7. 36 0
      backend/app/services/archive.py
  8. 205 7
      backend/app/services/virtual_printer/manager.py
  9. 205 0
      backend/tests/integration/test_print_queue_api.py
  10. 871 44
      backend/tests/unit/services/test_virtual_printer.py
  11. 195 0
      frontend/src/__tests__/components/FilamentMappingArchivePick.test.tsx
  12. 72 0
      frontend/src/__tests__/components/archiveAmsMapping.test.ts
  13. 7 0
      frontend/src/api/client.ts
  14. 79 11
      frontend/src/components/PrintModal/FilamentMapping.tsx
  15. 46 0
      frontend/src/components/PrintModal/archiveAmsMapping.ts
  16. 16 0
      frontend/src/components/PrintModal/index.tsx
  17. 8 0
      frontend/src/components/PrintModal/types.ts
  18. 38 0
      frontend/src/components/VirtualPrinterCard.tsx
  19. 14 0
      frontend/src/i18n/locales/de.ts
  20. 14 0
      frontend/src/i18n/locales/en.ts
  21. 14 0
      frontend/src/i18n/locales/es.ts
  22. 14 0
      frontend/src/i18n/locales/fr.ts
  23. 14 0
      frontend/src/i18n/locales/it.ts
  24. 14 0
      frontend/src/i18n/locales/ja.ts
  25. 15 1
      frontend/src/i18n/locales/ko.ts
  26. 14 0
      frontend/src/i18n/locales/pt-BR.ts
  27. 14 0
      frontend/src/i18n/locales/ru.ts
  28. 14 0
      frontend/src/i18n/locales/tr.ts
  29. 14 0
      frontend/src/i18n/locales/uk.ts
  30. 14 0
      frontend/src/i18n/locales/zh-CN.ts
  31. 14 0
      frontend/src/i18n/locales/zh-TW.ts
  32. 39 2
      frontend/src/pages/ArchivesPage.tsx
  33. 13 0
      frontend/src/pages/QueuePage.tsx
  34. 0 0
      static/assets/index-Css9_XII.js
  35. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### 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.
 - **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.
 - **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.
 - **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.

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

@@ -250,6 +250,24 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             response.nozzle_diameter = item.archive.nozzle_diameter
             response.nozzle_diameter = item.archive.nozzle_diameter
             response.sliced_for_model = item.archive.sliced_for_model
             response.sliced_for_model = item.archive.sliced_for_model
             response.bed_type = item.archive.bed_type
             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.
+            #
+            # 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:
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
                 if archive_path.exists():
@@ -643,6 +661,51 @@ async def add_to_queue(
             raise HTTPException(status_code=404, detail="Project not found")
             raise HTTPException(status_code=404, detail="Project not found")
 
 
     ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
     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.
+    #
+    # Global tray IDs only mean something relative to the specific printer
+    # they were resolved against, so this only fires when the reprint targets
+    # that exact printer (`extra_data.slicer_ams_mapping.printer_id`) — never
+    # for a model-based dispatch (data.printer_id is None) or a reprint aimed
+    # 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.
+    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)
+            and saved.get("printer_id") == data.printer_id
+            and isinstance(saved.get("mapping"), list)
+            and saved["mapping"]
+        ):
+            ams_mapping_json = json.dumps(saved["mapping"])
     items = []
     items = []
     for i in range(quantity):
     for i in range(quantity):
         item = PrintQueueItem(
         item = PrintQueueItem(

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

@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
     target_printer_id: int | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool = True
     auto_dispatch: bool = True
     queue_force_color_match: bool = False
     queue_force_color_match: bool = False
+    save_ams_mapping: bool = False
     gcode_injection: bool = False
     gcode_injection: bool = False
     bind_ip: str | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
     remote_interface_ip: str | None = None
@@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
     target_printer_id: int | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
     auto_dispatch: bool | None = None
     queue_force_color_match: bool | None = None
     queue_force_color_match: bool | None = None
+    save_ams_mapping: bool | None = None
     gcode_injection: bool | None = None
     gcode_injection: bool | None = None
     bind_ip: str | None = None
     bind_ip: str | None = None
     remote_interface_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,
         "target_printer_id": vp.target_printer_id,
         "auto_dispatch": vp.auto_dispatch,
         "auto_dispatch": vp.auto_dispatch,
         "queue_force_color_match": vp.queue_force_color_match,
         "queue_force_color_match": vp.queue_force_color_match,
+        "save_ams_mapping": vp.save_ams_mapping,
         "gcode_injection": vp.gcode_injection,
         "gcode_injection": vp.gcode_injection,
         "bind_ip": vp.bind_ip,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "remote_interface_ip": vp.remote_interface_ip,
@@ -245,6 +248,7 @@ async def create_virtual_printer(
         target_printer_id=body.target_printer_id,
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
         queue_force_color_match=body.queue_force_color_match,
+        save_ams_mapping=body.save_ams_mapping,
         gcode_injection=body.gcode_injection,
         gcode_injection=body.gcode_injection,
         bind_ip=body.bind_ip,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
         remote_interface_ip=body.remote_interface_ip,
@@ -423,6 +427,8 @@ async def update_virtual_printer(
         vp.auto_dispatch = body.auto_dispatch
         vp.auto_dispatch = body.auto_dispatch
     if body.queue_force_color_match is not None:
     if body.queue_force_color_match is not None:
         vp.queue_force_color_match = body.queue_force_color_match
         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:
     if body.gcode_injection is not None:
         vp.gcode_injection = body.gcode_injection
         vp.gcode_injection = body.gcode_injection
     if body.bind_ip is not None:
     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"
             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
     # 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
     # existing gcode_snippets users don't silently start injecting on VP/Studio
     # Send jobs after upgrading.
     # Send jobs after upgrading.

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

@@ -49,6 +49,18 @@ class VirtualPrinter(Base):
     )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     )  # 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
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
     # filament loaded (#1188).
+    save_ams_mapping: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # 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(
     gcode_injection: Mapped[bool] = mapped_column(
         Boolean, server_default="false"
         Boolean, server_default="false"
     )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
     )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet

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

@@ -194,6 +194,12 @@ class PrintQueueItemResponse(BaseModel):
     # 3MFs: when `plate_id` is set, the value is the matching plate's
     # 3MFs: when `plate_id` is set, the value is the matching plate's
     # `curr_bed_type` rather than the archive-level first-plate default.
     # `curr_bed_type` rather than the archive-level first-plate default.
     bed_type: str | None = None
     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) *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)
     # User tracking (Issue #206)
     created_by_id: int | None = None
     created_by_id: int | None = None

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

@@ -1144,6 +1144,8 @@ class ArchiveService:
         prefer_filename_for_name: bool = False,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
         plate_id: int | None = None,
         library_file_id: int | None = None,
         library_file_id: int | None = None,
+        slicer_ams_mapping: list[int] | None = None,
+        slicer_ams_mapping_printer_id: int | None = None,
     ) -> PrintArchive | None:
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
         """Archive a 3MF file with metadata.
 
 
@@ -1166,6 +1168,21 @@ class ArchiveService:
                 metadata. Used by virtual-printer flows so users who rename a job in
                 metadata. Used by virtual-printer flows so users who rename a job in
                 BambuStudio's "send to printer" dialog see that name instead of the
                 BambuStudio's "send to printer" dialog see that name instead of the
                 creator-baked title (#1152).
                 creator-baked title (#1152).
+            slicer_ams_mapping: The slicer's own live-resolved AMS-slot pick, to persist
+                onto `extra_data.slicer_ams_mapping` for a later reprint to reuse. Deliberately
+                a distinct parameter, not read off `print_data["ams_mapping"]` — that key is
+                populated on every MQTT print-start callback regardless of source (bambu_mqtt's
+                request-topic interception captures it for slicer-direct LAN prints too), so
+                promoting it unconditionally would stamp every archive on installs with no
+                virtual printer at all. Callers that gate this behind an opt-in (the VP-queue
+                "Save AMS mapping" toggle) pass it explicitly; everyone else leaves it unset.
+            slicer_ams_mapping_printer_id: The printer `slicer_ams_mapping`'s tray IDs were
+                resolved against. Required alongside `slicer_ams_mapping` — a global tray ID
+                only means something relative to one printer's specific AMS layout, so a
+                mapping saved without knowing which printer it came from can't be safely
+                reused later on any printer, including the same one (there'd be no way to
+                tell). A model-based VP with no fixed target printer has no valid value to
+                pass here and must leave both params unset.
         """
         """
         # Verify printer exists if specified
         # Verify printer exists if specified
         if printer_id is not None:
         if printer_id is not None:
@@ -1254,6 +1271,25 @@ class ArchiveService:
         if print_data:
         if print_data:
             metadata["_print_data"] = print_data
             metadata["_print_data"] = print_data
 
 
+        # Promote the slicer's own live-resolved AMS-slot pick, when the caller
+        # explicitly opted in (see the `slicer_ams_mapping` param docstring for
+        # why this is NOT read off `print_data["ams_mapping"]`), 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`. Stored together with the
+        # printer it was resolved against — see `slicer_ams_mapping_printer_id`
+        # param docstring — so a later reprint can tell whether it's even
+        # applicable before trying to reuse it.
+        if slicer_ams_mapping and slicer_ams_mapping_printer_id is not None:
+            metadata["slicer_ams_mapping"] = {
+                "mapping": slicer_ams_mapping,
+                "printer_id": slicer_ams_mapping_printer_id,
+            }
+
         # Determine status and timestamps
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"
         status = print_data.get("status", "completed") if print_data else "archived"
         started_at = datetime.now(timezone.utc) if status == "printing" else None
         started_at = datetime.now(timezone.utc) if status == "printing" else None

+ 205 - 7
backend/app/services/virtual_printer/manager.py

@@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
 """
 """
 
 
 import asyncio
 import asyncio
+import json
 import logging
 import logging
 import time
 import time
 from collections.abc import Callable
 from collections.abc import Callable
@@ -154,6 +155,60 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
     return None
     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.
+
+    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.
+    """
+    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:
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
     """Get serial number for the given model and suffix."""
     prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
     prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
@@ -181,6 +236,7 @@ class VirtualPrinterInstance:
         target_printer_id: int | None = None,
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         auto_dispatch: bool = True,
         queue_force_color_match: bool = False,
         queue_force_color_match: bool = False,
+        save_ams_mapping: bool = False,
         gcode_injection: bool = False,
         gcode_injection: bool = False,
         bind_ip: str = "",
         bind_ip: str = "",
         remote_interface_ip: str = "",
         remote_interface_ip: str = "",
@@ -204,6 +260,7 @@ class VirtualPrinterInstance:
         self.target_printer_id = target_printer_id
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.auto_dispatch = auto_dispatch
         self.queue_force_color_match = queue_force_color_match
         self.queue_force_color_match = queue_force_color_match
+        self.save_ams_mapping = save_ams_mapping
         self.gcode_injection = gcode_injection
         self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
         self.remote_interface_ip = remote_interface_ip
@@ -416,8 +473,9 @@ class VirtualPrinterInstance:
         row was already written with settings defaults. This method runs
         row was already written with settings defaults. This method runs
         on the late MQTT path: it looks up the most recent queue items
         on the late MQTT path: it looks up the most recent queue items
         committed for this filename and patches in the slicer's
         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:
         if not self._session_factory:
             return
             return
@@ -469,12 +527,34 @@ class VirtualPrinterInstance:
             if raw is not None:
             if raw is not None:
                 patch["nozzle_mapping"] = json.dumps(raw)
                 patch["nozzle_mapping"] = json.dumps(raw)
 
 
-        if not patch:
+        # 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 and self.save_ams_mapping
+            else 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
+
+        # `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)
             self._recent_queue_items.pop(stash_key, None)
             return
             return
 
 
         from sqlalchemy import select, update
         from sqlalchemy import select, update
 
 
+        from backend.app.models.archive import PrintArchive
         from backend.app.models.print_queue import PrintQueueItem
         from backend.app.models.print_queue import PrintQueueItem
 
 
         try:
         try:
@@ -482,23 +562,49 @@ class VirtualPrinterInstance:
                 # Only stamp items still pending; once the scheduler has
                 # Only stamp items still pending; once the scheduler has
                 # picked the row up we can't safely race the dispatcher.
                 # picked the row up we can't safely race the dispatcher.
                 result = await db.execute(
                 result = await db.execute(
-                    select(PrintQueueItem.id).where(
+                    select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
                         PrintQueueItem.id.in_(queue_item_ids),
                         PrintQueueItem.id.in_(queue_item_ids),
                         PrintQueueItem.status == "pending",
                         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:
                 if not eligible_ids:
                     self._recent_queue_items.pop(stash_key, None)
                     self._recent_queue_items.pop(stash_key, None)
                     return
                     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
+                # `_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.
+                # 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)))
+                        for archive in archive_result.scalars().all():
+                            extra = dict(archive.extra_data or {})
+                            extra["slicer_ams_mapping"] = {
+                                "mapping": json.loads(ams_mapping_json),
+                                "printer_id": self.target_printer_id,
+                            }
+                            archive.extra_data = extra
+
                 await db.commit()
                 await db.commit()
                 logger.info(
                 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,
                     self.name,
                     stash_key,
                     stash_key,
                     sorted(patch.keys()),
                     sorted(patch.keys()),
                     eligible_ids,
                     eligible_ids,
+                    " and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
                 )
                 )
         except Exception as e:
         except Exception as e:
             logger.error(
             logger.error(
@@ -834,6 +940,60 @@ class VirtualPrinterInstance:
                         if raw is not None:
                         if raw is not None:
                             nozzle_mapping_json = json.dumps(raw)
                             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.
+                #
+                # 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 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)
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                 archive = await service.archive_print(
                     printer_id=None,
                     printer_id=None,
@@ -844,6 +1004,14 @@ class VirtualPrinterInstance:
                         "source_ip": source_ip,
                         "source_ip": source_ip,
                     },
                     },
                     prefer_filename_for_name=prefer_filename,
                     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. 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:
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
@@ -925,6 +1093,31 @@ class VirtualPrinterInstance:
                                 if overrides:
                                 if overrides:
                                     filament_overrides_json = json.dumps(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(
                         queue_item = PrintQueueItem(
                             printer_id=self.target_printer_id,
                             printer_id=self.target_printer_id,
                             target_model=target_model,
                             target_model=target_model,
@@ -950,6 +1143,9 @@ class VirtualPrinterInstance:
                             # the same nozzle pick across plates rather than only the
                             # the same nozzle pick across plates rather than only the
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             nozzle_mapping=nozzle_mapping_json,
                             nozzle_mapping=nozzle_mapping_json,
+                            # Slicer's own live AMS-slot pick, when present —
+                            # see `_extract_slicer_ams_mapping_json`.
+                            ams_mapping=plate_ams_mapping_json,
                         )
                         )
                         db.add(queue_item)
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
                         await db.flush()  # populate queue_item.id before logging
@@ -1547,6 +1743,7 @@ class VirtualPrinterManager:
                 # instance silently keeps the old value until process
                 # instance silently keeps the old value until process
                 # restart (#1552 follow-up family).
                 # restart (#1552 follow-up family).
                 or instance.queue_force_color_match != vp.queue_force_color_match
                 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 instance.gcode_injection != vp.gcode_injection
                 or proxy_target_changed
                 or proxy_target_changed
             )
             )
@@ -1601,6 +1798,7 @@ class VirtualPrinterManager:
                     target_printer_id=vp.target_printer_id,
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     auto_dispatch=vp.auto_dispatch,
                     queue_force_color_match=vp.queue_force_color_match,
                     queue_force_color_match=vp.queue_force_color_match,
+                    save_ams_mapping=vp.save_ams_mapping,
                     gcode_injection=vp.gcode_injection,
                     gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",

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

@@ -258,6 +258,211 @@ class TestPrintQueueAPI:
         assert result["archive_id"] == archive.id
         assert result["archive_id"] == archive.id
         assert result["ams_mapping"] == [5, -1, 2, -1]
         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 for this exact printer
+        (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": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        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_ignores_archive_slicer_ams_mapping_for_different_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A saved mapping's tray IDs only mean something relative to the
+        printer they were resolved against. Reprinting the same archive on a
+        *different* printer must not inherit it — tray 5 on printer A can
+        hold a completely different spool than tray 5 on printer B.
+        """
+        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}}
+        )
+
+        data = {
+            "printer_id": other_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_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A model-based item (no fixed printer_id) can't know in advance
+        which printer the scheduler will pick, so a saved mapping resolved
+        against one specific printer must never be inherited here either.
+        """
+        origin_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        data = {
+            "target_model": "X1C",
+            "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_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": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        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_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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_with_plate_id(
     async def test_add_to_queue_with_plate_id(

Plik diff jest za duży
+ 871 - 44
backend/tests/unit/services/test_virtual_printer.py


+ 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();
+  });
+});

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

@@ -2216,6 +2216,10 @@ export interface PrintQueueItem {
   // start route when skip_filament_check=true, or at queue creation if
   // start route when skip_filament_check=true, or at queue creation if
   // PrintModal's deficit warning was acknowledged.
   // PrintModal's deficit warning was acknowledged.
   skip_filament_check: boolean;
   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
   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
   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
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
@@ -7283,6 +7287,7 @@ export interface VirtualPrinterConfig {
   target_printer_id: number | null;
   target_printer_id: number | null;
   auto_dispatch: boolean;
   auto_dispatch: boolean;
   queue_force_color_match: boolean;
   queue_force_color_match: boolean;
+  save_ams_mapping: boolean;
   gcode_injection: boolean;
   gcode_injection: boolean;
   tailscale_disabled: boolean;
   tailscale_disabled: boolean;
   bind_ip: string | null;
   bind_ip: string | null;
@@ -7310,6 +7315,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     target_printer_id?: number;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
     queue_force_color_match?: boolean;
+    save_ams_mapping?: boolean;
     gcode_injection?: boolean;
     gcode_injection?: boolean;
     bind_ip?: string;
     bind_ip?: string;
     remote_interface_ip?: string;
     remote_interface_ip?: string;
@@ -7328,6 +7334,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     target_printer_id?: number;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
     queue_force_color_match?: boolean;
+    save_ams_mapping?: boolean;
     gcode_injection?: boolean;
     gcode_injection?: boolean;
     tailscale_disabled?: boolean;
     tailscale_disabled?: boolean;
     bind_ip?: string;
     bind_ip?: string;

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

@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
@@ -24,11 +24,62 @@ export function FilamentMapping({
   forceColorMatch,
   forceColorMatch,
   onForceColorMatchChange,
   onForceColorMatchChange,
   plateLabel,
   plateLabel,
+  archiveAmsMapping,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isExpanded, setIsExpanded] = useState(defaultExpanded);
   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);
+  // Which slot IDs the ON branch below actually wrote into manualMappings —
+  // so OFF can undo exactly those and leave any *other* manual pick the user
+  // made by hand (before or after pressing the button) untouched.
+  const appliedSlotIdsRef = useRef<number[]>([]);
+
+  // Reset the toggle whenever the saved mapping it would apply changes — a
+  // different printer, plate selection, or archive entirely. Without this
+  // the button can read ON (green) from a previous printer/archive/plate
+  // even though it was never pressed against the mapping currently in scope.
+  useEffect(() => {
+    setUsingArchiveMapping(false);
+    appliedSlotIdsRef.current = [];
+  }, [archiveAmsMapping, plateLabel, printerId]);
+
+  const toggleArchiveMapping = () => {
+    if (!archiveAmsMapping || !filamentReqs?.filaments) return;
+    if (usingArchiveMapping) {
+      const next = { ...manualMappings };
+      for (const slotId of appliedSlotIdsRef.current) {
+        delete next[slotId];
+      }
+      onManualMappingChange(next);
+      appliedSlotIdsRef.current = [];
+      setUsingArchiveMapping(false);
+      return;
+    }
+    const next = { ...manualMappings };
+    const appliedSlotIds: number[] = [];
+    for (const req of filamentReqs.filaments) {
+      const idx = req.slot_id - 1;
+      // A negative value (e.g. the external spool sentinel) means the
+      // slicer didn't resolve this filament to an AMS tray — leave that
+      // slot's existing auto-match/manual pick alone rather than clearing it.
+      if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
+        next[req.slot_id] = archiveAmsMapping[idx];
+        appliedSlotIds.push(req.slot_id);
+      }
+    }
+    onManualMappingChange(next);
+    appliedSlotIdsRef.current = appliedSlotIds;
+    setUsingArchiveMapping(true);
+  };
 
 
   // Fetch printer status
   // Fetch printer status
   const { data: printerStatus } = useQuery({
   const { data: printerStatus } = useQuery({
@@ -211,16 +262,33 @@ export function FilamentMapping({
       {isExpanded && (
       {isExpanded && (
         <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
         <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
           <div className="flex items-center justify-between mb-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>
           </div>
           {filamentComparison.map((item, idx) => {
           {filamentComparison.map((item, idx) => {
             // #1717: surface the same per-slot force-color-match checkbox here
             // #1717: surface the same per-slot force-color-match checkbox here

+ 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;
+}

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

@@ -22,6 +22,7 @@ import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
 import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
+import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
 import { FilamentOverride } from './FilamentOverride';
 import { PlateSelector } from './PlateSelector';
 import { PlateSelector } from './PlateSelector';
@@ -325,6 +326,19 @@ export function PrintModal({
   // Get sliced_for_model from archive or library file
   // Get sliced_for_model from archive or library file
   const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
   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, 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
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
     queryKey: ['archive-plates', archiveId],
@@ -1407,6 +1421,7 @@ export function PrintModal({
                 onForceColorMatchChange={(slotId, value) =>
                 onForceColorMatchChange={(slotId, value) =>
                   setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                   setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                 }
                 }
+                archiveAmsMapping={archiveSlicerAmsMapping}
               />
               />
             )}
             )}
 
 
@@ -1433,6 +1448,7 @@ export function PrintModal({
                   onForceColorMatchChange={(slotId, value) =>
                   onForceColorMatchChange={(slotId, value) =>
                     setForceColorMatch((prev) => ({ ...prev, [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
    *  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. */
    *  own AMS mapping, so the panels have to be told apart. */
   plateLabel?: string;
   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[];
 }
 }
 
 
 /**
 /**

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

@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
   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 [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [showAccessCode, setShowAccessCode] = useState(false);
@@ -101,6 +102,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalModel(printer.model || '');
       setLocalModel(printer.model || '');
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
+      setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
       setLocalGcodeInjection(printer.gcode_injection ?? false);
       setLocalGcodeInjection(printer.gcode_injection ?? false);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
     }
     }
@@ -133,6 +135,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
       setLocalBindIp(printer.bind_ip || '');
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
       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);
       setPendingAction(null);
     },
     },
   });
   });
@@ -439,6 +447,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
               </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) */}
             {/* G-code injection toggle - only for queue mode (#1516) */}
             {localMode === 'queue' && (
             {localMode === 'queue' && (
               <div className="pt-2 border-t border-bambu-dark-tertiary">
               <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',
       uploadedBy: 'Hochgeladen von',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
       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',
       noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',
+      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',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
       openInBambuStudio: 'Im Slicer öffnen',
       openInBambuStudio: 'Im Slicer öffnen',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'unbekannt',
       unknown: 'unbekannt',
       printAnyway: 'Trotzdem drucken',
       printAnyway: 'Trotzdem drucken',
     },
     },
+    slicerAmsMapping: {
+      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',
     title: 'Druckwarteschlange',
     subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
     subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
     // Print modal
     // Print modal
@@ -4624,6 +4630,10 @@ export default {
     selectPrinter: 'Drucker auswählen',
     selectPrinter: 'Drucker auswählen',
     selectPlate: 'Platte auswählen',
     selectPlate: 'Platte auswählen',
     filamentMapping: 'Filamentzuordnung',
     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}}',
     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.',
     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:',
     totalCost: 'Gesamtkosten:',
@@ -5145,6 +5155,10 @@ export default {
       title: 'Farbabgleich erzwingen',
       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.',
       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: {
     gcodeInjection: {
       title: 'G-code-Injektion',
       title: 'G-code-Injektion',
       description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.',
       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',
       uploadedBy: 'Uploaded By',
       noPermissionReprint: 'You do not have permission to reprint',
       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',
       noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',
+      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',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
       noPermissionDelete: 'You do not have permission to delete archives',
       openInBambuStudio: 'Open in Slicer',
       openInBambuStudio: 'Open in Slicer',
@@ -1155,6 +1157,10 @@ export default {
       unknown: 'unknown',
       unknown: 'unknown',
       printAnyway: 'Print Anyway',
       printAnyway: 'Print Anyway',
     },
     },
+    slicerAmsMapping: {
+      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
     // Print modal
     editQueueItem: 'Edit Queue Item',
     editQueueItem: 'Edit Queue Item',
     selectAllPlates: 'Select All {{count}} Plates',
     selectAllPlates: 'Select All {{count}} Plates',
@@ -4667,6 +4673,10 @@ export default {
     selectPrinter: 'Select Printer',
     selectPrinter: 'Select Printer',
     selectPlate: 'Select Plate',
     selectPlate: 'Select Plate',
     filamentMapping: 'Filament Mapping',
     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}}',
     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.',
     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:',
     totalCost: 'Total cost:',
@@ -5189,6 +5199,10 @@ export default {
       title: 'Force color match',
       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.',
       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: {
     gcodeInjection: {
       title: 'G-code injection',
       title: 'G-code injection',
       description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.',
       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',
       uploadedBy: 'Subido por',
       noPermissionReprint: 'No tiene permiso para reimprimir',
       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',
       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 ({{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',
       noPermissionEdit: 'No tiene permiso para editar archivos',
       noPermissionDelete: 'No tiene permiso para eliminar archivos',
       noPermissionDelete: 'No tiene permiso para eliminar archivos',
       openInBambuStudio: 'Abrir en el laminador',
       openInBambuStudio: 'Abrir en el laminador',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'desconocido',
       unknown: 'desconocido',
       printAnyway: 'Imprimir de todos modos',
       printAnyway: 'Imprimir de todos modos',
     },
     },
+    slicerAmsMapping: {
+      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',
     title: 'Cola de impresión',
     subtitle: 'Programe y gestione sus trabajos de impresión',
     subtitle: 'Programe y gestione sus trabajos de impresión',
     // Print modal
     // Print modal
@@ -4632,6 +4638,10 @@ export default {
     selectPrinter: 'Seleccionar impresora',
     selectPrinter: 'Seleccionar impresora',
     selectPlate: 'Seleccionar cama',
     selectPlate: 'Seleccionar cama',
     filamentMapping: 'Mapeo de filamentos',
     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}}',
     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.',
     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:',
     totalCost: 'Coste total:',
@@ -5154,6 +5164,10 @@ export default {
       title: 'Forzar la coincidencia de color',
       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.',
       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: {
     gcodeInjection: {
       title: 'Inyección de G-code',
       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.',
       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',
       uploadedBy: 'Téléversé par',
       noPermissionReprint: 'Pas d\'autorisation de réimpression',
       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',
       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é ({{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',
       noPermissionEdit: 'Pas d\'autorisation de modification',
       noPermissionDelete: 'Pas d\'autorisation de suppression',
       noPermissionDelete: 'Pas d\'autorisation de suppression',
       openInBambuStudio: 'Ouvrir dans le Slicer',
       openInBambuStudio: 'Ouvrir dans le Slicer',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'inconnu',
       unknown: 'inconnu',
       printAnyway: 'Imprimer quand meme',
       printAnyway: 'Imprimer quand meme',
     },
     },
+    slicerAmsMapping: {
+      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',
     title: 'File d\'attente',
     subtitle: 'Gérez vos travaux d\'impression',
     subtitle: 'Gérez vos travaux d\'impression',
     // Print modal
     // Print modal
@@ -4613,6 +4619,10 @@ export default {
     selectPrinter: 'Choisir l\'imprimante',
     selectPrinter: 'Choisir l\'imprimante',
     selectPlate: 'Choisir le plateau',
     selectPlate: 'Choisir le plateau',
     filamentMapping: 'Mapping Filament',
     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}}',
     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.',
     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 :',
     totalCost: 'Coût total :',
@@ -5135,6 +5145,10 @@ export default {
       title: 'Forcer la correspondance des couleurs',
       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.',
       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: {
     gcodeInjection: {
       title: 'Injection G-code',
       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.',
       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',
       uploadedBy: 'Caricato da',
       noPermissionReprint: 'Non hai il permesso di ristampare',
       noPermissionReprint: 'Non hai il permesso di ristampare',
       noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
       noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
+      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',
       noPermissionEdit: 'Non hai il permesso di modificare archivi',
       noPermissionDelete: 'Non hai il permesso di eliminare archivi',
       noPermissionDelete: 'Non hai il permesso di eliminare archivi',
       openInBambuStudio: 'Apri nello slicer',
       openInBambuStudio: 'Apri nello slicer',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'sconosciuto',
       unknown: 'sconosciuto',
       printAnyway: 'Stampa comunque',
       printAnyway: 'Stampa comunque',
     },
     },
+    slicerAmsMapping: {
+      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',
     title: 'Coda di stampa',
     subtitle: 'Programma e gestisci i tuoi lavori di stampa',
     subtitle: 'Programma e gestisci i tuoi lavori di stampa',
     // Print modal
     // Print modal
@@ -4612,6 +4618,10 @@ export default {
     selectPrinter: 'Seleziona stampante',
     selectPrinter: 'Seleziona stampante',
     selectPlate: 'Seleziona piatto',
     selectPlate: 'Seleziona piatto',
     filamentMapping: 'Mappatura filamento',
     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}}',
     plateN: 'Piatto {{n}}',
     plateFilamentsUnreadable: 'Non è stato possibile leggere i filamenti di un piatto selezionato, quindi non può essere assegnato. Deselezionalo per accodare gli altri.',
     plateFilamentsUnreadable: 'Non è stato possibile leggere i filamenti di un piatto selezionato, quindi non può essere assegnato. Deselezionalo per accodare gli altri.',
     totalCost: 'Costo totale:',
     totalCost: 'Costo totale:',
@@ -5134,6 +5144,10 @@ export default {
       title: 'Forza corrispondenza colori',
       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.',
       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: {
     gcodeInjection: {
       title: 'Iniezione G-code',
       title: 'Iniezione G-code',
       description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.',
       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: 'アップロード者',
       uploadedBy: 'アップロード者',
       noPermissionReprint: '再印刷する権限がありません',
       noPermissionReprint: '再印刷する権限がありません',
       noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
       noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
+      slicerAmsMapping: 'AMSマッピングを保存済み({{printer}})',
+      slicerAmsMappingTooltip: 'スライサーが選んだAMSスロットは{{printer}}用に保存されています。スロット番号はそのプリンターでのみ意味を持つため、再印刷で再利用されるのは再び{{printer}}に送る場合だけです。',
       noPermissionEdit: 'プロファイルを編集する権限がありません',
       noPermissionEdit: 'プロファイルを編集する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
       openInBambuStudio: 'スライサーで開く',
       openInBambuStudio: 'スライサーで開く',
@@ -1143,6 +1145,10 @@ export default {
       unknown: '不明',
       unknown: '不明',
       printAnyway: 'それでも印刷',
       printAnyway: 'それでも印刷',
     },
     },
+    slicerAmsMapping: {
+      rowBadge: 'このプリンター用に保存されたAMSスロット',
+      rowTooltip: 'このアーカイブには、スライサーが選択した正確なAMSスロットが、この項目の対象プリンター用に保存されています。そのプリンターでの再印刷では、タイプと色で照合し直す代わりにそれらのトレイを再利用できます。',
+    },
     title: '印刷キュー',
     title: '印刷キュー',
     subtitle: '印刷ジョブのスケジュールと管理',
     subtitle: '印刷ジョブのスケジュールと管理',
     // Print modal
     // Print modal
@@ -4624,6 +4630,10 @@ export default {
     selectPrinter: 'プリンターを選択',
     selectPrinter: 'プリンターを選択',
     selectPlate: 'プレートを選択',
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',
     filamentMapping: 'フィラメントマッピング',
+    useArchiveMapping: 'マッピング',
+    useArchiveMappingTooltip: 'タイプ/色で照合する代わりに、このアーカイブに保存されたAMSマッピング(スライサーから)からすべてのスロットを選択します。',
+    clickToChangeSlot: 'クリックしてスロットの割り当てを変更',
+    reRead: '再読み込み',
     plateN: 'プレート {{n}}',
     plateN: 'プレート {{n}}',
     plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
     plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
     totalCost: '合計コスト:',
     totalCost: '合計コスト:',
@@ -5146,6 +5156,10 @@ export default {
       title: '色の一致を強制',
       title: '色の一致を強制',
       description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
       description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
     },
     },
+    saveAmsMapping: {
+      title: 'AMSマッピングを保存',
+      description: 'スライサー自身が選択したAMSスロット(project_file MQTTコマンドから)をアーカイブに保存し、後で再印刷する際にファイルのタイプ/色から再導出するのではなく、同じ物理スプールを再利用できるようにします。デフォルトはオフです。',
+    },
     gcodeInjection: {
     gcodeInjection: {
       title: 'G-codeインジェクション',
       title: 'G-codeインジェクション',
       description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',
       description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',

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

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

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Enviado por',
       uploadedBy: 'Enviado por',
       noPermissionReprint: 'Você não tem permissão para reimprimir',
       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',
       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 ({{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',
       noPermissionEdit: 'Você não tem permissão para editar arquivos',
       noPermissionDelete: 'Você não tem permissão para excluir arquivos',
       noPermissionDelete: 'Você não tem permissão para excluir arquivos',
       openInBambuStudio: 'Abrir no Bambu Studio',
       openInBambuStudio: 'Abrir no Bambu Studio',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'desconhecido',
       unknown: 'desconhecido',
       printAnyway: 'Imprimir mesmo assim',
       printAnyway: 'Imprimir mesmo assim',
     },
     },
+    slicerAmsMapping: {
+      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',
     title: 'Fila de Impressão',
     subtitle: 'Agende e gerencie seus trabalhos de impressão',
     subtitle: 'Agende e gerencie seus trabalhos de impressão',
     // Print modal
     // Print modal
@@ -4612,6 +4618,10 @@ export default {
     selectPrinter: 'Selecionar Impressora',
     selectPrinter: 'Selecionar Impressora',
     selectPlate: 'Selecionar Placa',
     selectPlate: 'Selecionar Placa',
     filamentMapping: 'Mapeamento de Filamento',
     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}}',
     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.',
     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:',
     totalCost: 'Custo total:',
@@ -5134,6 +5144,10 @@ export default {
       title: 'Forçar correspondência de cor',
       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.',
       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: {
     gcodeInjection: {
       title: 'Injeção de G-code',
       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.',
       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: "Загрузил",
       uploadedBy: "Загрузил",
       noPermissionReprint: "У вас нет разрешения на повторную печать",
       noPermissionReprint: "У вас нет разрешения на повторную печать",
       noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
       noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
+      slicerAmsMapping: "Маппинг AMS сохранён ({{printer}})",
+      slicerAmsMappingTooltip: "Выбор ячеек AMS, сделанный слайсером, сохранён для принтера {{printer}}. Номера ячеек имеют смысл только на нём, поэтому повторная печать использует их только при отправке снова на {{printer}}.",
       noPermissionEdit: "У вас нет разрешения на изменение архива",
       noPermissionEdit: "У вас нет разрешения на изменение архива",
       noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
       noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
       openInBambuStudio: "Открыть в слайсере",
       openInBambuStudio: "Открыть в слайсере",
@@ -1096,6 +1098,10 @@ export default {
       unknown: "неизвестно",
       unknown: "неизвестно",
       printAnyway: "Всё равно печатать",
       printAnyway: "Всё равно печатать",
     },
     },
+    slicerAmsMapping: {
+      rowBadge: "Ячейки AMS сохранены для этого принтера",
+      rowTooltip: "У этого архива сохранены точные ячейки AMS, выбранные слайсером, — для принтера, на который нацелено это задание. Повторная печать на нём может использовать те же катушки вместо повторного подбора по типу и цвету.",
+    },
     editQueueItem: "Изменить задание в очереди",
     editQueueItem: "Изменить задание в очереди",
     selectAllPlates: "Выбрать все пластины ({{count}})",
     selectAllPlates: "Выбрать все пластины ({{count}})",
     deselectAll: "Снять выделение",
     deselectAll: "Снять выделение",
@@ -4385,6 +4391,10 @@ export default {
     selectPrinter: "Выберите принтер",
     selectPrinter: "Выберите принтер",
     selectPlate: "Выберите пластину",
     selectPlate: "Выберите пластину",
     filamentMapping: "Сопоставление филаментов",
     filamentMapping: "Сопоставление филаментов",
+    useArchiveMapping: "Маппинг",
+    useArchiveMappingTooltip: "Выбрать все ячейки из маппинга AMS, сохранённого с этим архивом (от слайсера), вместо подбора по типу/цвету.",
+    clickToChangeSlot: "Нажмите, чтобы изменить назначение ячейки",
+    reRead: "Перечитать",
     plateN: "Пластина {{n}}",
     plateN: "Пластина {{n}}",
     plateFilamentsUnreadable: "Не удалось определить филаменты выбранной пластины, поэтому их невозможно сопоставить. Снимите выбор с этой пластины, чтобы добавить остальные в очередь.",
     plateFilamentsUnreadable: "Не удалось определить филаменты выбранной пластины, поэтому их невозможно сопоставить. Снимите выбор с этой пластины, чтобы добавить остальные в очередь.",
     totalCost: "Общая стоимость:",
     totalCost: "Общая стоимость:",
@@ -4871,6 +4881,10 @@ export default {
       title: "Требовать совпадения цвета",
       title: "Требовать совпадения цвета",
       description: "Не назначать задание принтеру, если загружены филамент другого типа или другого цвета. По умолчанию выключено: без этой проверки очередь сопоставляет только модель принтера и может выбрать принтер с неподходящим цветом.",
       description: "Не назначать задание принтеру, если загружены филамент другого типа или другого цвета. По умолчанию выключено: без этой проверки очередь сопоставляет только модель принтера и может выбрать принтер с неподходящим цветом.",
     },
     },
+    saveAmsMapping: {
+      title: "Сохранять маппинг AMS",
+      description: "Сохранять выбор AMS-ячейки, который сделал слайсер (из MQTT-команды project_file), в архив — тогда повторная печать использует ту же физическую катушку вместо повторного подбора по типу/цвету файла. По умолчанию выключено.",
+    },
     gcodeInjection: {
     gcodeInjection: {
       title: "Вставка G-code",
       title: "Вставка G-code",
       description: "Применять к заданиям этого виртуального принтера фрагменты G-code для соответствующей модели, заданные в настройках. По умолчанию выключено.",
       description: "Применять к заданиям этого виртуального принтера фрагменты G-code для соответствующей модели, заданные в настройках. По умолчанию выключено.",

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Yükleyen',
       uploadedBy: 'Yükleyen',
       noPermissionReprint: 'Yeniden yazdırma izniniz yok',
       noPermissionReprint: 'Yeniden yazdırma izniniz yok',
       noFileForReprint: 'Kullanılabilir 3MF dosyası yok — baskı kaydedildiğinde dosya yazıcıdan indirilemedi',
       noFileForReprint: 'Kullanılabilir 3MF dosyası yok — baskı kaydedildiğinde dosya yazıcıdan indirilemedi',
+      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',
       noPermissionEdit: 'Arşivleri düzenleme izniniz yok',
       noPermissionDelete: 'Arşivleri silme izniniz yok',
       noPermissionDelete: 'Arşivleri silme izniniz yok',
       openInBambuStudio: 'Dilimleyicide Aç',
       openInBambuStudio: 'Dilimleyicide Aç',
@@ -1146,6 +1148,10 @@ export default {
       unknown: 'bilinmiyor',
       unknown: 'bilinmiyor',
       printAnyway: 'Yine de Yazdır',
       printAnyway: 'Yine de Yazdır',
     },
     },
+    slicerAmsMapping: {
+      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
     // Baskı modali
     editQueueItem: 'Kuyruk Öğesini Düzenle',
     editQueueItem: 'Kuyruk Öğesini Düzenle',
     selectAllPlates: 'Tüm {{count}} Plakayı Seç',
     selectAllPlates: 'Tüm {{count}} Plakayı Seç',
@@ -4602,6 +4608,10 @@ export default {
     selectPrinter: 'Yazıcı Seç',
     selectPrinter: 'Yazıcı Seç',
     selectPlate: 'Plaka Seç',
     selectPlate: 'Plaka Seç',
     filamentMapping: 'Filament Eşlemesi',
     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}}',
     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.',
     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:',
     totalCost: 'Toplam maliyet:',
@@ -5110,6 +5120,10 @@ export default {
       title: 'Renk eşleşmesini zorla',
       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.',
       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: {
     gcodeInjection: {
       title: 'G-code enjeksiyonu',
       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ı.",
       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: "Вивантажив",
       uploadedBy: "Вивантажив",
       noPermissionReprint: "Ви не маєте дозволу на передрук",
       noPermissionReprint: "Ви не маєте дозволу на передрук",
       noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
       noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
+      slicerAmsMapping: "Зіставлення AMS збережено ({{printer}})",
+      slicerAmsMappingTooltip: "Вибір слотів AMS, зроблений слайсером, збережено для принтера {{printer}}. Номери слотів мають сенс лише на ньому, тож повторний друк використає їх, тільки якщо знову спрямований на {{printer}}.",
       noPermissionEdit: "Ви не маєте прав на редагування архівів",
       noPermissionEdit: "Ви не маєте прав на редагування архівів",
       noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
       noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
       openInBambuStudio: "Відкрити у слайсері",
       openInBambuStudio: "Відкрити у слайсері",
@@ -1155,6 +1157,10 @@ export default {
       unknown: "невідомо",
       unknown: "невідомо",
       printAnyway: "Усе одно друкувати",
       printAnyway: "Усе одно друкувати",
     },
     },
+    slicerAmsMapping: {
+      rowBadge: "Слоти AMS збережено для цього принтера",
+      rowTooltip: "Цей запис містить точні слоти AMS, вибрані слайсером, збережені для принтера, на який націлено це завдання. Повторний друк на ньому може використати ті самі котушки замість повторного добору за типом і кольором.",
+    },
     // Print modal
     // Print modal
     editQueueItem: "Редагувати елемент черги",
     editQueueItem: "Редагувати елемент черги",
     selectAllPlates: "Вибрати всі пластини ({{count}})",
     selectAllPlates: "Вибрати всі пластини ({{count}})",
@@ -4667,6 +4673,10 @@ export default {
     selectPrinter: "Вибрати принтер",
     selectPrinter: "Вибрати принтер",
     selectPlate: "Вибрати пластину",
     selectPlate: "Вибрати пластину",
     filamentMapping: "Зіставлення філаментів",
     filamentMapping: "Зіставлення філаментів",
+    useArchiveMapping: "Зіставлення",
+    useArchiveMappingTooltip: "Вибрати всі слоти зі зіставлення AMS, збереженого з цим записом (зі слайсера), замість добору за типом/кольором.",
+    clickToChangeSlot: "Натисніть, щоб змінити призначення слота",
+    reRead: "Перечитати",
     plateN: "Пластина {{n}}",
     plateN: "Пластина {{n}}",
     plateFilamentsUnreadable: "Не вдалося прочитати філаменти вибраної пластини, тому їх неможливо зіставити. Зніміть вибір із цієї пластини, щоб додати решту до черги.",
     plateFilamentsUnreadable: "Не вдалося прочитати філаменти вибраної пластини, тому їх неможливо зіставити. Зніміть вибір із цієї пластини, щоб додати решту до черги.",
     totalCost: "Загальна вартість:",
     totalCost: "Загальна вартість:",
@@ -5189,6 +5199,10 @@ export default {
       title: "Примусовий збіг кольорів",
       title: "Примусовий збіг кольорів",
       description: "Не надсилати завдання на принтер без філаменту точного типу й кольору. Типово вимкнено: без цього черга зіставляє лише модель і може вибрати принтер із філаментом іншого кольору.",
       description: "Не надсилати завдання на принтер без філаменту точного типу й кольору. Типово вимкнено: без цього черга зіставляє лише модель і може вибрати принтер із філаментом іншого кольору.",
     },
     },
+    saveAmsMapping: {
+      title: "Зберігати зіставлення AMS",
+      description: "Зберігати в записі вибір слота AMS, зроблений самим слайсером (з MQTT-команди project_file), щоб пізніший повторний друк використав ту саму фізичну котушку замість повторного визначення за типом/кольором файлу. Типово вимкнено.",
+    },
     gcodeInjection: {
     gcodeInjection: {
       title: "Вставлення G-коду",
       title: "Вставлення G-коду",
       description: "Застосовувати до завдань цього віртуального принтера фрагменти G-коду, налаштовані для кожної моделі. Типово вимкнено.",
       description: "Застосовувати до завдань цього віртуального принтера фрагменти G-коду, налаштовані для кожної моделі. Типово вимкнено.",

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

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

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

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

+ 39 - 2
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 { Link, useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -56,6 +56,7 @@ import {
   Cog,
   Cog,
   Archive as ArchiveIcon,
   Archive as ArchiveIcon,
   History,
   History,
+  CheckCircle2,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
 import { SliceModal } from '../components/SliceModal';
@@ -147,6 +148,7 @@ async function openInSlicerWithToken(
 function ArchiveCard({
 function ArchiveCard({
   archive,
   archive,
   printerName,
   printerName,
+  printerMap,
   isSelected,
   isSelected,
   onSelect,
   onSelect,
   selectionMode,
   selectionMode,
@@ -172,6 +174,10 @@ function ArchiveCard({
   currency: string;
   currency: string;
   t: TFunction;
   t: TFunction;
   onNavigateToArchive?: (archiveId: number) => void;
   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
   // Debug: log when card is highlighted
   if (isHighlighted) {
   if (isHighlighted) {
@@ -183,6 +189,17 @@ function ArchiveCard({
   const { hasPermission, canModify } = useAuth();
   const { hasPermission, canModify } = useAuth();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
   const navigate = useNavigate();
   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 [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showRunPipeline, setShowRunPipeline] = useState(false);
@@ -1111,6 +1128,20 @@ function ArchiveCard({
           )}
           )}
         </div>
         </div>
 
 
+        {/* 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', { printer: savedSlicerAmsMappingPrinter })}
+          </div>
+        )}
+
         {/* Tags & Notes */}
         {/* Tags & Notes */}
         {(archive.tags || archive.notes) && (
         {(archive.tags || archive.notes) && (
           <div className="flex flex-wrap items-center gap-1.5 mb-3">
           <div className="flex flex-wrap items-center gap-1.5 mb-3">
@@ -2960,7 +2991,12 @@ export function ArchivesPage() {
     localStorage.setItem('logPageSize', logPageSize.toString());
     localStorage.setItem('logPageSize', logPageSize.toString());
   }, [logPageSize]);
   }, [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
   // Extract unique materials and colors from archives
   const uniqueMaterials = [...new Set(
   const uniqueMaterials = [...new Set(
@@ -3714,6 +3750,7 @@ export function ArchivesPage() {
                 key={archive.id}
                 key={archive.id}
                 archive={archive}
                 archive={archive}
                 printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model || 'No Printer')}
                 printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model || 'No Printer')}
+                printerMap={printerMap}
                 isSelected={selectedIds.has(archive.id)}
                 isSelected={selectedIds.has(archive.id)}
                 onSelect={toggleSelect}
                 onSelect={toggleSelect}
                 selectionMode={selectionMode}
                 selectionMode={selectionMode}

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

@@ -731,6 +731,19 @@ function SortableQueueItem({
             </p>
             </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 */}
           {/* Error message */}
           {item.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">
             <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">

Plik diff jest za duży
+ 0 - 0
static/assets/index-Css9_XII.js


+ 1 - 1
static/index.html

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

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików