diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py
index 0346d8efe..d55f83837 100644
--- a/backend/app/api/routes/print_queue.py
+++ b/backend/app/api/routes/print_queue.py
@@ -250,6 +250,12 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
response.nozzle_diameter = item.archive.nozzle_diameter
response.sliced_for_model = item.archive.sliced_for_model
response.bed_type = item.archive.bed_type
+ # Marks history/reprint rows whose archive carries the slicer's own
+ # live-resolved AMS-slot pick (extra_data.slicer_ams_mapping) — see
+ # `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py.
+ response.archive_has_slicer_ams_mapping = bool(
+ item.archive.extra_data and item.archive.extra_data.get("slicer_ams_mapping")
+ )
if item.plate_id:
archive_path = settings.base_dir / item.archive.file_path
if archive_path.exists():
@@ -643,6 +649,23 @@ async def add_to_queue(
raise HTTPException(status_code=404, detail="Project not found")
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
+ # Reprint fallback: the caller didn't specify an explicit ams_mapping (no
+ # per-slot filament-mapping edit was made), but the archive carries the
+ # slicer's own live-resolved AMS-slot pick from the original print (see
+ # `extra_data.slicer_ams_mapping`, written by the VP-queue path via
+ # `_extract_slicer_ams_mapping_json`). Reuse it so the reprint dispatches
+ # to the exact same physical spool instead of the scheduler re-deriving a
+ # (possibly ambiguous) mapping from just the file's static type/color.
+ #
+ # Note this is unconditional — it applies regardless of whether the
+ # physical spool in that slot has changed since the original print. #1308
+ # covers re-verifying a stored mapping against live AMS state at dispatch
+ # time; that check is a separate PR and, once merged, will also catch a
+ # stale slot inherited through this fallback.
+ if ams_mapping_json is None and archive and archive.extra_data:
+ saved_mapping = archive.extra_data.get("slicer_ams_mapping")
+ if isinstance(saved_mapping, list) and saved_mapping:
+ ams_mapping_json = json.dumps(saved_mapping)
items = []
for i in range(quantity):
item = PrintQueueItem(
diff --git a/backend/app/api/routes/virtual_printers.py b/backend/app/api/routes/virtual_printers.py
index a8f11d141..e314fe272 100644
--- a/backend/app/api/routes/virtual_printers.py
+++ b/backend/app/api/routes/virtual_printers.py
@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool = True
queue_force_color_match: bool = False
+ save_ams_mapping: bool = False
gcode_injection: bool = False
bind_ip: str | None = None
remote_interface_ip: str | None = None
@@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool | None = None
queue_force_color_match: bool | None = None
+ save_ams_mapping: bool | None = None
gcode_injection: bool | None = None
bind_ip: str | None = None
remote_interface_ip: str | None = None
@@ -109,6 +111,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
"target_printer_id": vp.target_printer_id,
"auto_dispatch": vp.auto_dispatch,
"queue_force_color_match": vp.queue_force_color_match,
+ "save_ams_mapping": vp.save_ams_mapping,
"gcode_injection": vp.gcode_injection,
"bind_ip": vp.bind_ip,
"remote_interface_ip": vp.remote_interface_ip,
@@ -245,6 +248,7 @@ async def create_virtual_printer(
target_printer_id=body.target_printer_id,
auto_dispatch=body.auto_dispatch,
queue_force_color_match=body.queue_force_color_match,
+ save_ams_mapping=body.save_ams_mapping,
gcode_injection=body.gcode_injection,
bind_ip=body.bind_ip,
remote_interface_ip=body.remote_interface_ip,
@@ -423,6 +427,8 @@ async def update_virtual_printer(
vp.auto_dispatch = body.auto_dispatch
if body.queue_force_color_match is not None:
vp.queue_force_color_match = body.queue_force_color_match
+ if body.save_ams_mapping is not None:
+ vp.save_ams_mapping = body.save_ams_mapping
if body.gcode_injection is not None:
vp.gcode_injection = body.gcode_injection
if body.bind_ip is not None:
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index b012bda54..b8986c889 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -1363,6 +1363,15 @@ async def run_migrations(conn):
conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
)
+ # Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
+ # when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
+ # onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
+ # Default false to preserve current behaviour for upgraders.
+ if is_sqlite():
+ await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
+ else:
+ await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
+
# Per-VP opt-in for auto-print G-code injection (#1516). Default false so
# existing gcode_snippets users don't silently start injecting on VP/Studio
# Send jobs after upgrading.
diff --git a/backend/app/models/virtual_printer.py b/backend/app/models/virtual_printer.py
index fb598c620..09ed4a579 100644
--- a/backend/app/models/virtual_printer.py
+++ b/backend/app/models/virtual_printer.py
@@ -49,6 +49,14 @@ class VirtualPrinter(Base):
) # queue mode: pin per-slot type+color from the 3MF onto the queue
# item so the scheduler refuses to dispatch onto a printer with the wrong
# filament loaded (#1188).
+ save_ams_mapping: Mapped[bool] = mapped_column(
+ Boolean, server_default="false"
+ ) # queue mode: persist the slicer's own live-resolved AMS-slot pick (the
+ # `ams_mapping` field on the MQTT `project_file` command) onto the
+ # archive's `extra_data.slicer_ams_mapping`, so a later reprint can reuse
+ # the exact physical spool instead of re-deriving one from the file's
+ # static type/color. Off by default — archives don't grow this field
+ # unless the user opts in per virtual printer.
gcode_injection: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py
index 8a8bee250..84ad7449d 100644
--- a/backend/app/schemas/print_queue.py
+++ b/backend/app/schemas/print_queue.py
@@ -194,6 +194,11 @@ class PrintQueueItemResponse(BaseModel):
# 3MFs: when `plate_id` is set, the value is the matching plate's
# `curr_bed_type` rather than the archive-level first-plate default.
bed_type: str | None = None
+ # True when the source archive carries the slicer's own live-resolved
+ # AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint of this
+ # archive reuses that exact physical spool instead of the scheduler
+ # re-deriving one from just the file's static type/color.
+ archive_has_slicer_ams_mapping: bool = False
# User tracking (Issue #206)
created_by_id: int | None = None
diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py
index 1b36c0078..1f79759aa 100644
--- a/backend/app/services/archive.py
+++ b/backend/app/services/archive.py
@@ -1253,6 +1253,18 @@ class ArchiveService:
# Merge with print data from MQTT
if print_data:
metadata["_print_data"] = print_data
+ # Promote the slicer's own live-resolved AMS-slot pick (captured
+ # from the project_file MQTT command by the VP-queue path — see
+ # `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py)
+ # to a stable top-level extra_data key. Lets a later reprint reuse
+ # the exact tray the user picked/BambuStudio auto-matched at slice
+ # time instead of the scheduler re-deriving one from just the
+ # file's static type/color, which can land on the wrong physical
+ # spool when that match isn't unique. Top-level (not nested under
+ # the `_print_data` diagnostic bag) so API consumers have a single
+ # stable path: `archive.extra_data.slicer_ams_mapping`.
+ if print_data.get("ams_mapping"):
+ metadata["slicer_ams_mapping"] = print_data["ams_mapping"]
# Determine status and timestamps
status = print_data.get("status", "completed") if print_data else "archived"
diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py
index 81805a3eb..09c666025 100644
--- a/backend/app/services/virtual_printer/manager.py
+++ b/backend/app/services/virtual_printer/manager.py
@@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
"""
import asyncio
+import json
import logging
import time
from collections.abc import Callable
@@ -154,6 +155,54 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
return None
+def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
+ """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
+
+ BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
+ live, right before sending — either automatically or via the slicer's
+ manual per-filament AMS-slot assignment dialog — and embeds the result as
+ ``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
+ ID) directly in the MQTT ``project_file`` command. Confirmed by wire
+ capture: the field is present and already in the exact shape
+ ``PrintQueueItem.ams_mapping`` expects.
+
+ The VP-queue path previously never read this — every queued print had the
+ scheduler re-derive a mapping from just the 3MF's static type/color at
+ dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
+ the slicer's already-correct, live-resolved pick. That re-derivation can
+ land on the wrong physical spool whenever the file's type+color match
+ isn't unique (e.g. two spools of the same color) or the file's own
+ filament-slot color wasn't what the user actually intended for that
+ particular print. Capturing it here — mirroring the existing
+ ``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
+ scheduler's "already resolved, don't touch it" branch in
+ ``_ensure_ams_mapping`` use the slicer's own choice unchanged.
+
+ Returns ``None`` when the field is absent, unparsable, or the classic
+ "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
+ fresh live computation.
+ """
+ raw = data.get("ams_mapping")
+ if raw is None:
+ return None
+ if isinstance(raw, str):
+ try:
+ raw = json.loads(raw)
+ except json.JSONDecodeError:
+ logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
+ return None
+ # bool is a subclass of int in Python — isinstance(True, int) is True —
+ # so it must be excluded explicitly, or [True, False] would pass as a
+ # valid mapping.
+ if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
+ return None
+ if all(v < 0 for v in raw):
+ # #2589 sentinel — every slot unresolved. Let the scheduler compute a
+ # fresh mapping from live AMS state instead of trusting this.
+ return None
+ return json.dumps(raw)
+
+
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
"""Get serial number for the given model and suffix."""
prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
@@ -181,6 +230,7 @@ class VirtualPrinterInstance:
target_printer_id: int | None = None,
auto_dispatch: bool = True,
queue_force_color_match: bool = False,
+ save_ams_mapping: bool = False,
gcode_injection: bool = False,
bind_ip: str = "",
remote_interface_ip: str = "",
@@ -204,6 +254,7 @@ class VirtualPrinterInstance:
self.target_printer_id = target_printer_id
self.auto_dispatch = auto_dispatch
self.queue_force_color_match = queue_force_color_match
+ self.save_ams_mapping = save_ams_mapping
self.gcode_injection = gcode_injection
self.bind_ip = bind_ip
self.remote_interface_ip = remote_interface_ip
@@ -416,8 +467,9 @@ class VirtualPrinterInstance:
row was already written with settings defaults. This method runs
on the late MQTT path: it looks up the most recent queue items
committed for this filename and patches in the slicer's
- ``nozzle_mapping`` + workflow flags, but only while the items are
- still ``pending`` (scheduler hasn't dispatched them yet).
+ ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
+ while the items are still ``pending`` (scheduler hasn't dispatched
+ them yet).
"""
if not self._session_factory:
return
@@ -469,12 +521,17 @@ class VirtualPrinterInstance:
if raw is not None:
patch["nozzle_mapping"] = json.dumps(raw)
+ ams_mapping_json = _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
+ if ams_mapping_json is not None:
+ patch["ams_mapping"] = ams_mapping_json
+
if not patch:
self._recent_queue_items.pop(stash_key, None)
return
from sqlalchemy import select, update
+ from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
try:
@@ -482,16 +539,36 @@ class VirtualPrinterInstance:
# Only stamp items still pending; once the scheduler has
# picked the row up we can't safely race the dispatcher.
result = await db.execute(
- select(PrintQueueItem.id).where(
+ select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
PrintQueueItem.id.in_(queue_item_ids),
PrintQueueItem.status == "pending",
)
)
- eligible_ids = [row[0] for row in result.all()]
+ rows = result.all()
+ eligible_ids = [row[0] for row in rows]
if not eligible_ids:
self._recent_queue_items.pop(stash_key, None)
return
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+
+ # The archive was already created (with no slicer_ams_mapping)
+ # before this late MQTT arrived — see
+ # `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
+ # too so a reprint later still picks up the slicer's pick, and
+ # the "AMS mapping from slicer" badge reflects reality instead
+ # of staying stuck on the archive's initial (empty) snapshot.
+ # Opt-in per VP, same as the immediate path in
+ # `_add_to_print_queue` — only the archive persistence is
+ # gated, not the queue item's own `ams_mapping` patched above.
+ if ams_mapping_json is not None and self.save_ams_mapping:
+ archive_ids = {row[1] for row in rows if row[1] is not None}
+ if archive_ids:
+ archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
+ for archive in archive_result.scalars().all():
+ extra = dict(archive.extra_data or {})
+ extra["slicer_ams_mapping"] = json.loads(ams_mapping_json)
+ archive.extra_data = extra
+
await db.commit()
logger.info(
"[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
@@ -834,6 +911,16 @@ class VirtualPrinterInstance:
if raw is not None:
nozzle_mapping_json = json.dumps(raw)
+ # Slicer's own live-resolved AMS-slot pick (see docstring on
+ # `_extract_slicer_ams_mapping_json`). Stamped onto every plate
+ # below, same treatment as nozzle_mapping_json above — when
+ # present it makes `_ensure_ams_mapping` skip its own
+ # type/color re-derivation entirely and dispatch use exactly
+ # the tray the slicer/user picked.
+ ams_mapping_json: str | None = None
+ if slicer_opts is not None:
+ ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
+
service = ArchiveService(db)
archive = await service.archive_print(
printer_id=None,
@@ -842,6 +929,17 @@ class VirtualPrinterInstance:
"status": "archived",
"source": "virtual_printer",
"source_ip": source_ip,
+ # Slicer's own live AMS-slot pick -- promoted to
+ # `extra_data.slicer_ams_mapping` by archive_print() so
+ # a later reprint can reuse it. Opt-in per VP
+ # (`save_ams_mapping`) — only the archive persistence is
+ # gated; the queue item's own `ams_mapping` (used for
+ # *this* dispatch, below) is set unconditionally
+ # whenever the slicer provides it, since that's a
+ # correctness fix, not a feature toggle.
+ "ams_mapping": (
+ json.loads(ams_mapping_json) if ams_mapping_json and self.save_ams_mapping else None
+ ),
},
prefer_filename_for_name=prefer_filename,
)
@@ -950,6 +1048,9 @@ class VirtualPrinterInstance:
# the same nozzle pick across plates rather than only the
# first one (mirrors the #1697 / #1188 per-plate loop fix).
nozzle_mapping=nozzle_mapping_json,
+ # Slicer's own live AMS-slot pick, when present —
+ # see `_extract_slicer_ams_mapping_json`.
+ ams_mapping=ams_mapping_json,
)
db.add(queue_item)
await db.flush() # populate queue_item.id before logging
@@ -1547,6 +1648,7 @@ class VirtualPrinterManager:
# instance silently keeps the old value until process
# restart (#1552 follow-up family).
or instance.queue_force_color_match != vp.queue_force_color_match
+ or instance.save_ams_mapping != vp.save_ams_mapping
or instance.gcode_injection != vp.gcode_injection
or proxy_target_changed
)
@@ -1601,6 +1703,7 @@ class VirtualPrinterManager:
target_printer_id=vp.target_printer_id,
auto_dispatch=vp.auto_dispatch,
queue_force_color_match=vp.queue_force_color_match,
+ save_ams_mapping=vp.save_ams_mapping,
gcode_injection=vp.gcode_injection,
bind_ip=vp.bind_ip or "",
remote_interface_ip=vp.remote_interface_ip or "",
diff --git a/backend/tests/integration/test_print_queue_api.py b/backend/tests/integration/test_print_queue_api.py
index 959f0405a..44af572f3 100644
--- a/backend/tests/integration/test_print_queue_api.py
+++ b/backend/tests/integration/test_print_queue_api.py
@@ -258,6 +258,71 @@ class TestPrintQueueAPI:
assert result["archive_id"] == archive.id
assert result["ams_mapping"] == [5, -1, 2, -1]
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_add_to_queue_falls_back_to_archive_slicer_ams_mapping_when_unset(
+ self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+ ):
+ """When the caller sends no explicit ams_mapping, but the archive
+ carries the slicer's own saved pick (extra_data.slicer_ams_mapping,
+ written by a VP with "Save AMS mapping" on), the queue item should
+ inherit it — the same exact-physical-spool reuse the "Mapping"
+ button gives you, but automatic when nothing was hand-edited.
+ """
+ printer = await printer_factory()
+ archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
+
+ data = {
+ "printer_id": printer.id,
+ "archive_id": archive.id,
+ }
+ response = await async_client.post("/api/v1/queue/", json=data)
+ assert response.status_code == 200
+ result = response.json()
+ assert result["ams_mapping"] == [5, -1, 2, -1]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
+ self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+ ):
+ """An explicit ams_mapping in the request (e.g. from the filament
+ mapping panel) must take priority over the archive's saved slicer
+ pick — the fallback only fires when the caller sent nothing at all.
+ """
+ printer = await printer_factory()
+ archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
+
+ data = {
+ "printer_id": printer.id,
+ "archive_id": archive.id,
+ "ams_mapping": [9, -1, 1, -1],
+ }
+ response = await async_client.post("/api/v1/queue/", json=data)
+ assert response.status_code == 200
+ result = response.json()
+ assert result["ams_mapping"] == [9, -1, 1, -1]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_add_to_queue_archive_extra_data_without_slicer_mapping_key_not_used(
+ self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+ ):
+ """extra_data present but without a slicer_ams_mapping key (the
+ common case — most archives have other metadata but no saved slicer
+ mapping) must not accidentally trip the fallback."""
+ printer = await printer_factory()
+ archive = await archive_factory(extra_data={"filament_slots": []})
+
+ data = {
+ "printer_id": printer.id,
+ "archive_id": archive.id,
+ }
+ response = await async_client.post("/api/v1/queue/", json=data)
+ assert response.status_code == 200
+ result = response.json()
+ assert result["ams_mapping"] is None
+
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_with_plate_id(
diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py
index 7b8e83b6d..cf02e621c 100644
--- a/backend/tests/unit/services/test_virtual_printer.py
+++ b/backend/tests/unit/services/test_virtual_printer.py
@@ -1847,6 +1847,202 @@ class TestVirtualPrinterInstance:
item = added_items[0]
assert item.nozzle_mapping is None
+ @pytest.mark.asyncio
+ async def test_add_to_print_queue_captures_ams_mapping_on_queue_item_regardless_of_toggle(self, tmp_path):
+ """The slicer's live-resolved AMS-slot pick (`ams_mapping` in the
+ project_file MQTT command) must land on the queue item's own
+ `ams_mapping` column unconditionally — this is what THIS dispatch
+ uses, and is a correctness fix, not a feature. Only persisting it
+ onto the *archive* (for future reprints) is gated behind the
+ per-VP `save_ams_mapping` toggle — see the sibling test below.
+ """
+ import json as _json
+
+ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+ added_items = []
+ mock_db = AsyncMock()
+ mock_db.add = MagicMock(side_effect=added_items.append)
+ mock_db.commit = AsyncMock()
+ mock_session_factory = MagicMock()
+ mock_session_ctx = AsyncMock()
+ mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+ mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+ mock_session_factory.return_value = mock_session_ctx
+
+ inst = VirtualPrinterInstance(
+ vp_id=44,
+ name="AMSMappingOff",
+ mode="queue",
+ model="X1C",
+ access_code="12345678",
+ serial_suffix="391800044",
+ base_dir=tmp_path,
+ session_factory=mock_session_factory,
+ save_ams_mapping=False,
+ )
+
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(b"fake3mf")
+
+ await inst.on_print_command(
+ file_path.name,
+ {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
+ )
+
+ mock_archive = MagicMock()
+ mock_archive.id = 1
+ mock_archive.print_name = "test"
+
+ with (
+ patch(
+ "backend.app.api.routes.settings.get_setting",
+ new_callable=AsyncMock,
+ return_value=None,
+ ),
+ patch(
+ "backend.app.services.archive.ArchiveService.archive_print",
+ new_callable=AsyncMock,
+ return_value=mock_archive,
+ ) as mock_archive_print,
+ ):
+ await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+ assert len(added_items) == 1
+ item = added_items[0]
+ assert item.ams_mapping is not None
+ assert _json.loads(item.ams_mapping) == [4, -1, 12, -1]
+
+ # Toggle is off: the archive's print_data must NOT carry the mapping.
+ print_data = mock_archive_print.await_args.kwargs["print_data"]
+ assert print_data["ams_mapping"] is None
+
+ @pytest.mark.asyncio
+ async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path):
+ """With the per-VP `save_ams_mapping` toggle on, the slicer's AMS
+ pick must also be forwarded to `ArchiveService.archive_print` so it
+ gets promoted to `archive.extra_data.slicer_ams_mapping` and a later
+ reprint can reuse the exact physical spool.
+ """
+ import json as _json
+
+ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+ added_items = []
+ mock_db = AsyncMock()
+ mock_db.add = MagicMock(side_effect=added_items.append)
+ mock_db.commit = AsyncMock()
+ mock_session_factory = MagicMock()
+ mock_session_ctx = AsyncMock()
+ mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+ mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+ mock_session_factory.return_value = mock_session_ctx
+
+ inst = VirtualPrinterInstance(
+ vp_id=45,
+ name="AMSMappingOn",
+ mode="queue",
+ model="X1C",
+ access_code="12345678",
+ serial_suffix="391800045",
+ base_dir=tmp_path,
+ session_factory=mock_session_factory,
+ save_ams_mapping=True,
+ )
+
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(b"fake3mf")
+
+ await inst.on_print_command(
+ file_path.name,
+ {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
+ )
+
+ mock_archive = MagicMock()
+ mock_archive.id = 1
+ mock_archive.print_name = "test"
+
+ with (
+ patch(
+ "backend.app.api.routes.settings.get_setting",
+ new_callable=AsyncMock,
+ return_value=None,
+ ),
+ patch(
+ "backend.app.services.archive.ArchiveService.archive_print",
+ new_callable=AsyncMock,
+ return_value=mock_archive,
+ ) as mock_archive_print,
+ ):
+ await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+ assert len(added_items) == 1
+ assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1]
+
+ print_data = mock_archive_print.await_args.kwargs["print_data"]
+ assert print_data["ams_mapping"] == [4, -1, 12, -1]
+
+ @pytest.mark.asyncio
+ async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path):
+ """#2589: an all -1 `ams_mapping` means the slicer's own race lost —
+ every slot unresolved. Must be dropped (queue item AND archive),
+ never trusted over a fresh scheduler-computed mapping.
+ """
+ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+ added_items = []
+ mock_db = AsyncMock()
+ mock_db.add = MagicMock(side_effect=added_items.append)
+ mock_db.commit = AsyncMock()
+ mock_session_factory = MagicMock()
+ mock_session_ctx = AsyncMock()
+ mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+ mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+ mock_session_factory.return_value = mock_session_ctx
+
+ inst = VirtualPrinterInstance(
+ vp_id=46,
+ name="AMSMappingSentinel",
+ mode="queue",
+ model="X1C",
+ access_code="12345678",
+ serial_suffix="391800046",
+ base_dir=tmp_path,
+ session_factory=mock_session_factory,
+ save_ams_mapping=True,
+ )
+
+ file_path = tmp_path / "test.3mf"
+ file_path.write_bytes(b"fake3mf")
+
+ await inst.on_print_command(
+ file_path.name,
+ {"command": "project_file", "ams_mapping": [-1, -1, -1, -1]},
+ )
+
+ mock_archive = MagicMock()
+ mock_archive.id = 1
+ mock_archive.print_name = "test"
+
+ with (
+ patch(
+ "backend.app.api.routes.settings.get_setting",
+ new_callable=AsyncMock,
+ return_value=None,
+ ),
+ patch(
+ "backend.app.services.archive.ArchiveService.archive_print",
+ new_callable=AsyncMock,
+ return_value=mock_archive,
+ ) as mock_archive_print,
+ ):
+ await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+ assert len(added_items) == 1
+ assert added_items[0].ams_mapping is None
+ print_data = mock_archive_print.await_args.kwargs["print_data"]
+ assert print_data["ams_mapping"] is None
+
@pytest.mark.asyncio
async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
"""#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
@@ -2185,6 +2381,59 @@ class TestVirtualPrinterInstance:
assert "test.3mf" not in inst._recent_queue_items
+class TestExtractSlicerAmsMappingJson:
+ """Unit tests for `_extract_slicer_ams_mapping_json`, the pure-function
+ parser that pulls the slicer's own live-resolved AMS-slot pick out of a
+ captured project_file MQTT payload (see docstring in manager.py)."""
+
+ def _extract(self, data):
+ from backend.app.services.virtual_printer.manager import _extract_slicer_ams_mapping_json
+
+ return _extract_slicer_ams_mapping_json(data, "[test]")
+
+ def test_missing_field_returns_none(self):
+ assert self._extract({}) is None
+
+ def test_valid_int_list_returns_json(self):
+ import json as _json
+
+ result = self._extract({"ams_mapping": [4, -1, 12, -1]})
+ assert result is not None
+ assert _json.loads(result) == [4, -1, 12, -1]
+
+ def test_stringified_json_is_parsed(self):
+ import json as _json
+
+ result = self._extract({"ams_mapping": "[0, 1, 2]"})
+ assert result is not None
+ assert _json.loads(result) == [0, 1, 2]
+
+ def test_unparseable_string_returns_none(self):
+ assert self._extract({"ams_mapping": "not json"}) is None
+
+ def test_non_list_value_returns_none(self):
+ assert self._extract({"ams_mapping": 42}) is None
+
+ def test_empty_list_returns_none(self):
+ assert self._extract({"ams_mapping": []}) is None
+
+ def test_non_int_entries_return_none(self):
+ assert self._extract({"ams_mapping": [1, "two", 3]}) is None
+
+ def test_2589_all_unresolved_sentinel_returns_none(self):
+ """#2589: every slot -1 means the slicer's own resolution race
+ lost — never trust this over a fresh live computation."""
+ assert self._extract({"ams_mapping": [-1, -1, -1, -1]}) is None
+
+ def test_partially_resolved_mapping_is_kept(self):
+ import json as _json
+
+ # Only some slots resolved is still meaningful — keep it.
+ result = self._extract({"ams_mapping": [-1, 4, -1, -1]})
+ assert result is not None
+ assert _json.loads(result) == [-1, 4, -1, -1]
+
+
class TestVirtualPrinterManager:
"""Tests for VirtualPrinterManager orchestrator."""
@@ -2358,6 +2607,7 @@ class TestVirtualPrinterManager:
"auto_dispatch": True,
"tailscale_disabled": True, # Opt-in default (#1070 UX fix)
"queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector
+ "save_ams_mapping": False, # same reason as above
"gcode_injection": False, # same reason as above
"position": 0,
}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 37c773b23..1a179ad78 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -2210,6 +2210,10 @@ export interface PrintQueueItem {
// start route when skip_filament_check=true, or at queue creation if
// PrintModal's deficit warning was acknowledged.
skip_filament_check: boolean;
+ // True when the source archive carries the slicer's own live-resolved
+ // AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint reuses that
+ // exact physical spool instead of re-deriving one from type/color.
+ archive_has_slicer_ams_mapping: boolean;
ams_mapping: number[] | null; // AMS slot mapping for multi-color prints
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment
plate_id: number | null; // Plate ID for multi-plate 3MF files
@@ -7272,6 +7276,7 @@ export interface VirtualPrinterConfig {
target_printer_id: number | null;
auto_dispatch: boolean;
queue_force_color_match: boolean;
+ save_ams_mapping: boolean;
gcode_injection: boolean;
tailscale_disabled: boolean;
bind_ip: string | null;
@@ -7299,6 +7304,7 @@ export const multiVirtualPrinterApi = {
target_printer_id?: number;
auto_dispatch?: boolean;
queue_force_color_match?: boolean;
+ save_ams_mapping?: boolean;
gcode_injection?: boolean;
bind_ip?: string;
remote_interface_ip?: string;
@@ -7317,6 +7323,7 @@ export const multiVirtualPrinterApi = {
target_printer_id?: number;
auto_dispatch?: boolean;
queue_force_color_match?: boolean;
+ save_ams_mapping?: boolean;
gcode_injection?: boolean;
tailscale_disabled?: boolean;
bind_ip?: string;
diff --git a/frontend/src/components/PrintModal/FilamentMapping.tsx b/frontend/src/components/PrintModal/FilamentMapping.tsx
index 659aac46e..acd8854c1 100644
--- a/frontend/src/components/PrintModal/FilamentMapping.tsx
+++ b/frontend/src/components/PrintModal/FilamentMapping.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
@@ -24,11 +24,50 @@ export function FilamentMapping({
forceColorMatch,
onForceColorMatchChange,
plateLabel,
+ archiveAmsMapping,
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+ // "Mapping" toggle (only shown when the archive has a saved slicer pick):
+ // ON selects every slot straight from `archiveAmsMapping`, bypassing the
+ // type/color auto-match entirely — same mechanism as a manual per-slot
+ // pick (`manualMappings`), just applied to every required slot at once.
+ // OFF removes exactly those overrides so the panel falls back to its
+ // normal auto-match, without touching any *other* manual picks the user
+ // made by hand.
+ const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
+
+ // Reset the toggle whenever the saved mapping it would apply changes — a
+ // different plate selection or a different archive entirely. Without this
+ // the button can read ON (green) from a previous archive/plate even though
+ // it was never pressed against the mapping currently in scope.
+ useEffect(() => {
+ setUsingArchiveMapping(false);
+ }, [archiveAmsMapping, plateLabel]);
+
+ const toggleArchiveMapping = () => {
+ if (!archiveAmsMapping || !filamentReqs?.filaments) return;
+ if (usingArchiveMapping) {
+ const next = { ...manualMappings };
+ for (const req of filamentReqs.filaments) {
+ if (req.slot_id > 0) delete next[req.slot_id];
+ }
+ onManualMappingChange(next);
+ setUsingArchiveMapping(false);
+ return;
+ }
+ const next = { ...manualMappings };
+ for (const req of filamentReqs.filaments) {
+ const idx = req.slot_id - 1;
+ if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
+ next[req.slot_id] = archiveAmsMapping[idx];
+ }
+ }
+ onManualMappingChange(next);
+ setUsingArchiveMapping(true);
+ };
// Fetch printer status
const { data: printerStatus } = useQuery({
@@ -211,16 +250,33 @@ export function FilamentMapping({
{isExpanded && (
- Click to change slot assignment
-
+ {t('printModal.clickToChangeSlot')}
+
+ {archiveAmsMapping && (
+
+ )}
+
+
{filamentComparison.map((item, idx) => {
// #1717: surface the same per-slot force-color-match checkbox here
diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx
index 7e742b091..1067e1954 100644
--- a/frontend/src/components/PrintModal/index.tsx
+++ b/frontend/src/components/PrintModal/index.tsx
@@ -325,6 +325,13 @@ export function PrintModal({
// Get sliced_for_model from archive or library file
const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
+ // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
+ // mapping" virtual-printer setting) — undefined for library files or
+ // archives that predate the feature / had it off at print time.
+ const archiveSlicerAmsMapping = !isLibraryFile
+ ? (archiveDetails?.extra_data?.slicer_ams_mapping as number[] | undefined)
+ : undefined;
+
// Fetch plates for archives
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
queryKey: ['archive-plates', archiveId],
@@ -1407,6 +1414,7 @@ export function PrintModal({
onForceColorMatchChange={(slotId, value) =>
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
}
+ archiveAmsMapping={archiveSlicerAmsMapping}
/>
)}
diff --git a/frontend/src/components/PrintModal/types.ts b/frontend/src/components/PrintModal/types.ts
index 2155c75a3..da1b40096 100644
--- a/frontend/src/components/PrintModal/types.ts
+++ b/frontend/src/components/PrintModal/types.ts
@@ -224,6 +224,14 @@ export interface FilamentMappingProps {
* plate. Each plate prints its own subset of the file's slots and gets its
* own AMS mapping, so the panels have to be told apart. */
plateLabel?: string;
+ /** The archive's own saved AMS-slot pick from the slicer
+ * (`extra_data.slicer_ams_mapping`, written when the source virtual
+ * printer has "Save AMS mapping" enabled) — position = slot_id-1, value =
+ * global tray ID. When present, a "Mapping" toggle next to "Re-read" lets
+ * the user select every slot from this array instead of the type/color
+ * auto-match. Undefined/omitted when the archive has no saved mapping —
+ * the toggle is hidden and behaviour is unchanged. */
+ archiveAmsMapping?: number[];
}
/**
diff --git a/frontend/src/components/VirtualPrinterCard.tsx b/frontend/src/components/VirtualPrinterCard.tsx
index 0519696f8..4e08b8a03 100644
--- a/frontend/src/components/VirtualPrinterCard.tsx
+++ b/frontend/src/components/VirtualPrinterCard.tsx
@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
const [localModel, setLocalModel] = useState(printer.model || '');
const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
+ const [localSaveAmsMapping, setLocalSaveAmsMapping] = useState(printer.save_ams_mapping ?? false);
const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
const [showAccessCode, setShowAccessCode] = useState(false);
@@ -101,6 +102,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
setLocalModel(printer.model || '');
setLocalAutoDispatch(printer.auto_dispatch ?? true);
setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
+ setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
setLocalGcodeInjection(printer.gcode_injection ?? false);
setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
}
@@ -439,6 +441,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
)}
+ {/* Save-AMS-mapping toggle - only for queue mode */}
+ {localMode === 'queue' && (
+
+
+
+
{t('virtualPrinter.saveAmsMapping.title')}
+
{t('virtualPrinter.saveAmsMapping.description')}
+
+
+
+
+ )}
+
{/* G-code injection toggle - only for queue mode (#1516) */}
{localMode === 'queue' && (
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index 7a899607a..0f95c914f 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Hochgeladen von',
noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',
+ slicerAmsMapping: 'AMS-Zuordnung gespeichert',
+ slicerAmsMappingTooltip: 'Für dieses Archiv ist eine gespeicherte AMS-Zuordnung vom Slicer vorhanden',
noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
openInBambuStudio: 'Im Slicer öffnen',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'unbekannt',
printAnyway: 'Trotzdem drucken',
},
+ slicerAmsMapping: {
+ rowBadge: 'AMS-Steckplatz vom Slicer gespeichert',
+ rowTooltip: 'Dieses Archiv enthält den genauen AMS-Steckplatz, den der Slicer beim Slicen/Senden ausgewählt hat. Ein erneuter Druck verwendet diese physische Spule, statt sie erneut anhand von Typ/Farbe zu erraten.',
+ },
title: 'Druckwarteschlange',
subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
// Print modal
@@ -4619,6 +4625,10 @@ export default {
selectPrinter: 'Drucker auswählen',
selectPlate: 'Platte auswählen',
filamentMapping: 'Filamentzuordnung',
+ useArchiveMapping: 'Zuordnung',
+ useArchiveMappingTooltip: 'Jeden Steckplatz aus der mit diesem Archiv gespeicherten AMS-Zuordnung (vom Slicer) auswählen, anstatt nach Typ/Farbe abzugleichen.',
+ clickToChangeSlot: 'Klicken, um die Steckplatzzuweisung zu ändern',
+ reRead: 'Neu einlesen',
plateN: 'Platte {{n}}',
plateFilamentsUnreadable: 'Die Filamente einer ausgewählten Platte konnten nicht gelesen werden, sie lässt sich daher nicht zuordnen. Wähle sie ab, um die anderen einzureihen.',
totalCost: 'Gesamtkosten:',
@@ -5140,6 +5150,10 @@ export default {
title: 'Farbabgleich erzwingen',
description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
},
+ saveAmsMapping: {
+ title: 'AMS-Zuordnung speichern',
+ description: 'Speichert die vom Slicer selbst gewählte AMS-Steckplatz-Zuordnung (aus dem MQTT-Befehl project_file) im Archiv, sodass ein späterer erneuter Druck dieselbe physische Spule verwendet, anstatt sie erneut aus Typ/Farbe der Datei abzuleiten. Standardmäßig deaktiviert.',
+ },
gcodeInjection: {
title: 'G-code-Injektion',
description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 99a57e41f..53443b977 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -939,6 +939,8 @@ export default {
uploadedBy: 'Uploaded By',
noPermissionReprint: 'You do not have permission to reprint',
noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',
+ slicerAmsMapping: 'AMS mapping saved',
+ slicerAmsMappingTooltip: 'This archive has a saved AMS slot mapping from the slicer',
noPermissionEdit: 'You do not have permission to edit archives',
noPermissionDelete: 'You do not have permission to delete archives',
openInBambuStudio: 'Open in Slicer',
@@ -1155,6 +1157,10 @@ export default {
unknown: 'unknown',
printAnyway: 'Print Anyway',
},
+ slicerAmsMapping: {
+ rowBadge: 'AMS slot saved from slicer',
+ rowTooltip: 'This archive carries the exact AMS slot the slicer picked when it was sliced/sent. A reprint reuses that physical spool instead of re-guessing from type/color.',
+ },
// Print modal
editQueueItem: 'Edit Queue Item',
selectAllPlates: 'Select All {{count}} Plates',
@@ -4662,6 +4668,10 @@ export default {
selectPrinter: 'Select Printer',
selectPlate: 'Select Plate',
filamentMapping: 'Filament Mapping',
+ useArchiveMapping: 'Mapping',
+ useArchiveMappingTooltip: 'Select every slot from the AMS mapping saved with this archive (from the slicer), instead of matching by type/color.',
+ clickToChangeSlot: 'Click to change slot assignment',
+ reRead: 'Re-read',
plateN: 'Plate {{n}}',
plateFilamentsUnreadable: 'The filaments of a selected plate could not be read, so it can\'t be mapped. Deselect it to queue the others.',
totalCost: 'Total cost:',
@@ -5184,6 +5194,10 @@ export default {
title: 'Force color match',
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
},
+ saveAmsMapping: {
+ title: 'Save AMS mapping',
+ description: 'Persist the slicer\'s own AMS-slot pick (from the project_file MQTT command) onto the archive, so a later reprint reuses the exact physical spool instead of re-deriving one from the file\'s type/color. Off by default.',
+ },
gcodeInjection: {
title: 'G-code injection',
description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.',
diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts
index 237a8a68a..309d8a449 100644
--- a/frontend/src/i18n/locales/es.ts
+++ b/frontend/src/i18n/locales/es.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Subido por',
noPermissionReprint: 'No tiene permiso para reimprimir',
noFileForReprint: 'No hay archivo 3MF disponible — no se pudo descargar el archivo de la impresora cuando se registró la impresión',
+ slicerAmsMapping: 'Mapeo de AMS guardado',
+ slicerAmsMappingTooltip: 'Este archivo tiene un mapeo de ranuras AMS guardado desde el slicer',
noPermissionEdit: 'No tiene permiso para editar archivos',
noPermissionDelete: 'No tiene permiso para eliminar archivos',
openInBambuStudio: 'Abrir en el laminador',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'desconocido',
printAnyway: 'Imprimir de todos modos',
},
+ slicerAmsMapping: {
+ rowBadge: 'Ranura AMS guardada desde el slicer',
+ rowTooltip: 'Este archivo conserva la ranura AMS exacta que eligió el slicer al laminar/enviar. Una reimpresión reutiliza ese carrete físico en lugar de volver a adivinar por tipo/color.',
+ },
title: 'Cola de impresión',
subtitle: 'Programe y gestione sus trabajos de impresión',
// Print modal
@@ -4627,6 +4633,10 @@ export default {
selectPrinter: 'Seleccionar impresora',
selectPlate: 'Seleccionar cama',
filamentMapping: 'Mapeo de filamentos',
+ useArchiveMapping: 'Mapeo',
+ useArchiveMappingTooltip: 'Selecciona todas las ranuras a partir del mapeo de AMS guardado con este archivo (del slicer), en lugar de emparejar por tipo/color.',
+ clickToChangeSlot: 'Haga clic para cambiar la asignación de ranura',
+ reRead: 'Releer',
plateN: 'Cama {{n}}',
plateFilamentsUnreadable: 'No se han podido leer los filamentos de una cama seleccionada, por lo que no se puede asignar. Deselecciónala para encolar las demás.',
totalCost: 'Coste total:',
@@ -5149,6 +5159,10 @@ export default {
title: 'Forzar la coincidencia de color',
description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.',
},
+ saveAmsMapping: {
+ title: 'Guardar mapeo de AMS',
+ description: 'Guarda en el archivo la selección de ranura AMS propia del slicer (del comando MQTT project_file), de modo que una reimpresión posterior reutilice el mismo carrete físico en lugar de volver a derivarlo del tipo/color del archivo. Desactivado de forma predeterminada.',
+ },
gcodeInjection: {
title: 'Inyección de G-code',
description: 'Aplica los fragmentos de G-code configurados por modelo en Ajustes a los trabajos de esta IV. Desactivado de forma predeterminada.',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index c087267a2..cd2fe84a3 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Téléversé par',
noPermissionReprint: 'Pas d\'autorisation de réimpression',
noFileForReprint: 'Aucun fichier 3MF disponible — le fichier n\'a pas pu être téléchargé depuis l\'imprimante lors de l\'enregistrement',
+ slicerAmsMapping: 'Mappage AMS enregistré',
+ slicerAmsMappingTooltip: 'Cette archive dispose d\'un mappage des emplacements AMS enregistré depuis le slicer',
noPermissionEdit: 'Pas d\'autorisation de modification',
noPermissionDelete: 'Pas d\'autorisation de suppression',
openInBambuStudio: 'Ouvrir dans le Slicer',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'inconnu',
printAnyway: 'Imprimer quand meme',
},
+ slicerAmsMapping: {
+ rowBadge: 'Emplacement AMS enregistré depuis le slicer',
+ rowTooltip: 'Cette archive conserve l\'emplacement AMS exact choisi par le slicer lors du tranchage/envoi. Une réimpression réutilise cette bobine physique au lieu de deviner à nouveau par type/couleur.',
+ },
title: 'File d\'attente',
subtitle: 'Gérez vos travaux d\'impression',
// Print modal
@@ -4608,6 +4614,10 @@ export default {
selectPrinter: 'Choisir l\'imprimante',
selectPlate: 'Choisir le plateau',
filamentMapping: 'Mapping Filament',
+ useArchiveMapping: 'Mappage',
+ useArchiveMappingTooltip: 'Sélectionner tous les emplacements à partir du mappage AMS enregistré avec cette archive (depuis le slicer), au lieu de faire correspondre par type/couleur.',
+ clickToChangeSlot: 'Cliquez pour modifier l\'attribution de l\'emplacement',
+ reRead: 'Relire',
plateN: 'Plateau {{n}}',
plateFilamentsUnreadable: 'Les filaments d\'un plateau sélectionné n\'ont pas pu être lus, il est donc impossible de l\'affecter. Désélectionnez-le pour mettre les autres en file.',
totalCost: 'Coût total :',
@@ -5130,6 +5140,10 @@ export default {
title: 'Forcer la correspondance des couleurs',
description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.',
},
+ saveAmsMapping: {
+ title: 'Enregistrer le mappage AMS',
+ description: 'Conserve dans l\'archive le choix d\'emplacement AMS propre au slicer (depuis la commande MQTT project_file), afin qu\'une réimpression ultérieure réutilise la même bobine physique au lieu de la redéduire à partir du type/de la couleur du fichier. Désactivé par défaut.',
+ },
gcodeInjection: {
title: 'Injection G-code',
description: 'Applique les extraits de G-code configurés par modèle dans les Paramètres aux travaux de ce VP. Désactivé par défaut.',
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index 2469c9271..7cebdfede 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Caricato da',
noPermissionReprint: 'Non hai il permesso di ristampare',
noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
+ slicerAmsMapping: 'Mappatura AMS salvata',
+ slicerAmsMappingTooltip: 'Questo archivio ha una mappatura degli slot AMS salvata dallo slicer',
noPermissionEdit: 'Non hai il permesso di modificare archivi',
noPermissionDelete: 'Non hai il permesso di eliminare archivi',
openInBambuStudio: 'Apri nello slicer',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'sconosciuto',
printAnyway: 'Stampa comunque',
},
+ slicerAmsMapping: {
+ rowBadge: 'Slot AMS salvato dallo slicer',
+ rowTooltip: 'Questo archivio conserva lo slot AMS esatto scelto dallo slicer al momento dello slicing/invio. Una ristampa riutilizza quella bobina fisica invece di indovinare di nuovo in base a tipo/colore.',
+ },
title: 'Coda di stampa',
subtitle: 'Programma e gestisci i tuoi lavori di stampa',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: 'Seleziona stampante',
selectPlate: 'Seleziona piatto',
filamentMapping: 'Mappatura filamento',
+ useArchiveMapping: 'Mappatura',
+ useArchiveMappingTooltip: 'Seleziona ogni slot dalla mappatura AMS salvata con questo archivio (dallo slicer), invece di abbinare per tipo/colore.',
+ clickToChangeSlot: 'Fai clic per modificare l\'assegnazione dello slot',
+ reRead: 'Rileggi',
plateN: 'Piatto {{n}}',
plateFilamentsUnreadable: 'Non è stato possibile leggere i filamenti di un piatto selezionato, quindi non può essere assegnato. Deselezionalo per accodare gli altri.',
totalCost: 'Costo totale:',
@@ -5129,6 +5139,10 @@ export default {
title: 'Forza corrispondenza colori',
description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
},
+ saveAmsMapping: {
+ title: 'Salva mappatura AMS',
+ description: 'Salva nell\'archivio la scelta dello slot AMS effettuata dallo slicer stesso (dal comando MQTT project_file), in modo che una ristampa successiva riutilizzi la stessa bobina fisica invece di ricavarla di nuovo dal tipo/colore del file. Disattivato per impostazione predefinita.',
+ },
gcodeInjection: {
title: 'Iniezione G-code',
description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.',
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index e8507f3bb..366647bcd 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -934,6 +934,8 @@ export default {
uploadedBy: 'アップロード者',
noPermissionReprint: '再印刷する権限がありません',
noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
+ slicerAmsMapping: 'AMSマッピングを保存しました',
+ slicerAmsMappingTooltip: 'このアーカイブにはスライサーから保存されたAMSスロットマッピングがあります',
noPermissionEdit: 'プロファイルを編集する権限がありません',
noPermissionDelete: 'アーカイブを削除する権限がありません',
openInBambuStudio: 'スライサーで開く',
@@ -1143,6 +1145,10 @@ export default {
unknown: '不明',
printAnyway: 'それでも印刷',
},
+ slicerAmsMapping: {
+ rowBadge: 'スライサーから保存されたAMSスロット',
+ rowTooltip: 'このアーカイブには、スライス/送信時にスライサーが選択した正確なAMSスロットが保持されています。再印刷では、タイプ/色から再推測するのではなく、その物理スプールが再利用されます。',
+ },
title: '印刷キュー',
subtitle: '印刷ジョブのスケジュールと管理',
// Print modal
@@ -4619,6 +4625,10 @@ export default {
selectPrinter: 'プリンターを選択',
selectPlate: 'プレートを選択',
filamentMapping: 'フィラメントマッピング',
+ useArchiveMapping: 'マッピング',
+ useArchiveMappingTooltip: 'タイプ/色で照合する代わりに、このアーカイブに保存されたAMSマッピング(スライサーから)からすべてのスロットを選択します。',
+ clickToChangeSlot: 'クリックしてスロットの割り当てを変更',
+ reRead: '再読み込み',
plateN: 'プレート {{n}}',
plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
totalCost: '合計コスト:',
@@ -5141,6 +5151,10 @@ export default {
title: '色の一致を強制',
description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
},
+ saveAmsMapping: {
+ title: 'AMSマッピングを保存',
+ description: 'スライサー自身が選択したAMSスロット(project_file MQTTコマンドから)をアーカイブに保存し、後で再印刷する際にファイルのタイプ/色から再導出するのではなく、同じ物理スプールを再利用できるようにします。デフォルトはオフです。',
+ },
gcodeInjection: {
title: 'G-codeインジェクション',
description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',
diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts
index 02f8f4071..355c02d03 100644
--- a/frontend/src/i18n/locales/ko.ts
+++ b/frontend/src/i18n/locales/ko.ts
@@ -890,6 +890,8 @@ export default {
uploadedBy: '업로드한 사용자',
noPermissionReprint: '재인쇄 권한이 없습니다',
noFileForReprint: '3MF 파일 없음 — 인쇄 기록 시 프린터에서 파일을 다운로드할 수 없었습니다',
+ slicerAmsMapping: 'AMS 매핑 저장됨',
+ slicerAmsMappingTooltip: '이 아카이브에는 슬라이서에서 저장된 AMS 슬롯 매핑이 있습니다',
noPermissionEdit: '아카이브를 편집할 권한이 없습니다',
noPermissionDelete: '아카이브를 삭제할 권한이 없습니다',
openInBambuStudio: '슬라이서에서 열기',
@@ -1349,7 +1351,11 @@ export default {
lineItem: '슬롯 {{slot}}: {{required}}g 필요, {{remaining}}g 남음',
unknown: '알 수 없음',
printAnyway: '그냥 인쇄'
- }
+ },
+ slicerAmsMapping: {
+ rowBadge: '슬라이서에서 저장된 AMS 슬롯',
+ rowTooltip: '이 아카이브에는 슬라이싱/전송 시 슬라이서가 선택한 정확한 AMS 슬롯이 보존되어 있습니다. 재인쇄 시 유형/색상으로 다시 추측하는 대신 해당 실물 스풀을 재사용합니다.',
+ },
},
stats: {
title: '대시보드',
@@ -4391,6 +4397,10 @@ export default {
selectPrinter: '프린터 선택',
selectPlate: '플레이트 선택',
filamentMapping: '필라멘트 매핑',
+ useArchiveMapping: '매핑',
+ useArchiveMappingTooltip: '유형/색상으로 매칭하는 대신, 이 아카이브에 저장된 AMS 매핑(슬라이서 제공)에서 모든 슬롯을 선택합니다.',
+ clickToChangeSlot: '클릭하여 슬롯 할당 변경',
+ reRead: '다시 읽기',
plateN: '플레이트 {{n}}',
plateFilamentsUnreadable: '선택한 플레이트의 필라멘트를 읽을 수 없어 매핑할 수 없습니다. 해당 플레이트를 선택 해제하면 나머지를 대기열에 추가할 수 있습니다.',
totalCost: '총 비용:',
@@ -4878,6 +4888,10 @@ export default {
title: '색상 일치 강제',
description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
},
+ saveAmsMapping: {
+ title: 'AMS 매핑 저장',
+ description: '슬라이서가 직접 선택한 AMS 슬롯(project_file MQTT 명령에서)을 아카이브에 저장하여, 이후 재인쇄 시 파일의 유형/색상에서 다시 유추하지 않고 동일한 실물 스풀을 재사용하도록 합니다. 기본값은 꺼짐입니다.',
+ },
gcodeInjection: {
title: 'G-code 주입',
description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index b3b7975c9..5dbefc460 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Enviado por',
noPermissionReprint: 'Você não tem permissão para reimprimir',
noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',
+ slicerAmsMapping: 'Mapeamento de AMS salvo',
+ slicerAmsMappingTooltip: 'Este arquivo tem um mapeamento de slots AMS salvo do fatiador',
noPermissionEdit: 'Você não tem permissão para editar arquivos',
noPermissionDelete: 'Você não tem permissão para excluir arquivos',
openInBambuStudio: 'Abrir no Bambu Studio',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'desconhecido',
printAnyway: 'Imprimir mesmo assim',
},
+ slicerAmsMapping: {
+ rowBadge: 'Slot AMS salvo do fatiador',
+ rowTooltip: 'Este arquivo mantém o slot AMS exato escolhido pelo fatiador ao fatiar/enviar. Uma reimpressão reutiliza esse carretel físico em vez de adivinhar novamente pelo tipo/cor.',
+ },
title: 'Fila de Impressão',
subtitle: 'Agende e gerencie seus trabalhos de impressão',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: 'Selecionar Impressora',
selectPlate: 'Selecionar Placa',
filamentMapping: 'Mapeamento de Filamento',
+ useArchiveMapping: 'Mapeamento',
+ useArchiveMappingTooltip: 'Selecionar todos os slots a partir do mapeamento de AMS salvo com este arquivo (do fatiador), em vez de corresponder por tipo/cor.',
+ clickToChangeSlot: 'Clique para alterar a atribuição do slot',
+ reRead: 'Reler',
plateN: 'Placa {{n}}',
plateFilamentsUnreadable: 'Não foi possível ler os filamentos de uma placa selecionada, portanto ela não pode ser mapeada. Desmarque-a para enfileirar as demais.',
totalCost: 'Custo total:',
@@ -5129,6 +5139,10 @@ export default {
title: 'Forçar correspondência de cor',
description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.',
},
+ saveAmsMapping: {
+ title: 'Salvar mapeamento de AMS',
+ description: 'Persiste a escolha de slot AMS feita pelo próprio fatiador (do comando MQTT project_file) no arquivo, para que uma reimpressão posterior reutilize o mesmo carretel físico em vez de derivá-lo novamente do tipo/cor do arquivo. Desativado por padrão.',
+ },
gcodeInjection: {
title: 'Injeção de G-code',
description: 'Aplica os trechos de G-code configurados por modelo nas Configurações aos trabalhos deste VP. Desativado por padrão.',
diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts
index 630cbf5ea..103276190 100644
--- a/frontend/src/i18n/locales/ru.ts
+++ b/frontend/src/i18n/locales/ru.ts
@@ -891,6 +891,8 @@ export default {
uploadedBy: "Загрузил",
noPermissionReprint: "У вас нет разрешения на повторную печать",
noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
+ slicerAmsMapping: "Маппинг AMS сохранён",
+ slicerAmsMappingTooltip: "У этого архива сохранён маппинг AMS-ячеек от слайсера",
noPermissionEdit: "У вас нет разрешения на изменение архива",
noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
openInBambuStudio: "Открыть в слайсере",
@@ -1096,6 +1098,10 @@ export default {
unknown: "неизвестно",
printAnyway: "Всё равно печатать",
},
+ slicerAmsMapping: {
+ rowBadge: "Ячейка AMS сохранена от слайсера",
+ rowTooltip: "У этого архива сохранена точная ячейка AMS, которую выбрал слайсер при нарезке/отправке. Повторная печать использует именно эту физическую катушку, а не подбор заново по типу/цвету.",
+ },
editQueueItem: "Изменить задание в очереди",
selectAllPlates: "Выбрать все пластины ({{count}})",
deselectAll: "Снять выделение",
@@ -4380,6 +4386,10 @@ export default {
selectPrinter: "Выберите принтер",
selectPlate: "Выберите пластину",
filamentMapping: "Сопоставление филаментов",
+ useArchiveMapping: "Маппинг",
+ useArchiveMappingTooltip: "Выбрать все ячейки из маппинга AMS, сохранённого с этим архивом (от слайсера), вместо подбора по типу/цвету.",
+ clickToChangeSlot: "Нажмите, чтобы изменить назначение ячейки",
+ reRead: "Перечитать",
plateN: "Пластина {{n}}",
plateFilamentsUnreadable: "Не удалось определить филаменты выбранной пластины, поэтому их невозможно сопоставить. Снимите выбор с этой пластины, чтобы добавить остальные в очередь.",
totalCost: "Общая стоимость:",
@@ -4866,6 +4876,10 @@ export default {
title: "Требовать совпадения цвета",
description: "Не назначать задание принтеру, если загружены филамент другого типа или другого цвета. По умолчанию выключено: без этой проверки очередь сопоставляет только модель принтера и может выбрать принтер с неподходящим цветом.",
},
+ saveAmsMapping: {
+ title: "Сохранять маппинг AMS",
+ description: "Сохранять выбор AMS-ячейки, который сделал слайсер (из MQTT-команды project_file), в архив — тогда повторная печать использует ту же физическую катушку вместо повторного подбора по типу/цвету файла. По умолчанию выключено.",
+ },
gcodeInjection: {
title: "Вставка G-code",
description: "Применять к заданиям этого виртуального принтера фрагменты G-code для соответствующей модели, заданные в настройках. По умолчанию выключено.",
diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts
index 372327870..aa95bd409 100644
--- a/frontend/src/i18n/locales/tr.ts
+++ b/frontend/src/i18n/locales/tr.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Yükleyen',
noPermissionReprint: 'Yeniden yazdırma izniniz yok',
noFileForReprint: 'Kullanılabilir 3MF dosyası yok — baskı kaydedildiğinde dosya yazıcıdan indirilemedi',
+ slicerAmsMapping: 'AMS eşlemesi kaydedildi',
+ slicerAmsMappingTooltip: 'Bu arşivde dilimleyiciden kaydedilmiş bir AMS yuva eşlemesi var',
noPermissionEdit: 'Arşivleri düzenleme izniniz yok',
noPermissionDelete: 'Arşivleri silme izniniz yok',
openInBambuStudio: 'Dilimleyicide Aç',
@@ -1146,6 +1148,10 @@ export default {
unknown: 'bilinmiyor',
printAnyway: 'Yine de Yazdır',
},
+ slicerAmsMapping: {
+ rowBadge: 'Dilimleyiciden kaydedilen AMS yuvası',
+ rowTooltip: 'Bu arşiv, dilimleme/gönderme sırasında dilimleyicinin seçtiği tam AMS yuvasını taşır. Yeniden yazdırma, tür/renkten yeniden tahmin etmek yerine o fiziksel makarayı yeniden kullanır.',
+ },
// Baskı modali
editQueueItem: 'Kuyruk Öğesini Düzenle',
selectAllPlates: 'Tüm {{count}} Plakayı Seç',
@@ -4597,6 +4603,10 @@ export default {
selectPrinter: 'Yazıcı Seç',
selectPlate: 'Plaka Seç',
filamentMapping: 'Filament Eşlemesi',
+ useArchiveMapping: 'Eşleme',
+ useArchiveMappingTooltip: 'Tür/renge göre eşleştirmek yerine, bu arşivle kaydedilen AMS eşlemesindeki (dilimleyiciden) her yuvayı seç.',
+ clickToChangeSlot: 'Yuva atamasını değiştirmek için tıklayın',
+ reRead: 'Yeniden oku',
plateN: 'Plaka {{n}}',
plateFilamentsUnreadable: 'Seçili bir plakanın filamentleri okunamadı, bu yüzden eşleştirilemiyor. Diğerlerini kuyruğa almak için o plakanın seçimini kaldırın.',
totalCost: 'Toplam maliyet:',
@@ -5105,6 +5115,10 @@ export default {
title: 'Renk eşleşmesini zorla',
description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.',
},
+ saveAmsMapping: {
+ title: 'AMS eşlemesini kaydet',
+ description: 'Dilimleyicinin kendi seçtiği AMS yuvasını (project_file MQTT komutundan) arşive kalıcı olarak kaydeder, böylece daha sonraki bir yeniden yazdırma dosyanın türünden/renginden yeniden türetmek yerine tam olarak aynı fiziksel makarayı yeniden kullanır. Varsayılan olarak kapalı.',
+ },
gcodeInjection: {
title: 'G-code enjeksiyonu',
description: "Ayarlar'da model bazında yapılandırılan G-code parçacıklarını bu VP'nin işlerine uygular. Varsayılan olarak kapalı.",
diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts
index d9bb4719c..2d513a6e5 100644
--- a/frontend/src/i18n/locales/uk.ts
+++ b/frontend/src/i18n/locales/uk.ts
@@ -939,6 +939,8 @@ export default {
uploadedBy: "Вивантажив",
noPermissionReprint: "Ви не маєте дозволу на передрук",
noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
+ slicerAmsMapping: "Зіставлення AMS збережено",
+ slicerAmsMappingTooltip: "Для цього запису збережено зіставлення слотів AMS зі слайсера",
noPermissionEdit: "Ви не маєте прав на редагування архівів",
noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
openInBambuStudio: "Відкрити у слайсері",
@@ -1155,6 +1157,10 @@ export default {
unknown: "невідомо",
printAnyway: "Усе одно друкувати",
},
+ slicerAmsMapping: {
+ rowBadge: "Слот AMS збережено зі слайсера",
+ rowTooltip: "Цей запис містить точний слот AMS, який вибрав слайсер під час нарізання/надсилання. Повторний друк використає саме цю фізичну котушку замість повторного добору за типом/кольором.",
+ },
// Print modal
editQueueItem: "Редагувати елемент черги",
selectAllPlates: "Вибрати всі пластини ({{count}})",
@@ -4662,6 +4668,10 @@ export default {
selectPrinter: "Вибрати принтер",
selectPlate: "Вибрати пластину",
filamentMapping: "Зіставлення філаментів",
+ useArchiveMapping: "Зіставлення",
+ useArchiveMappingTooltip: "Вибрати всі слоти зі зіставлення AMS, збереженого з цим записом (зі слайсера), замість добору за типом/кольором.",
+ clickToChangeSlot: "Натисніть, щоб змінити призначення слота",
+ reRead: "Перечитати",
plateN: "Пластина {{n}}",
plateFilamentsUnreadable: "Не вдалося прочитати філаменти вибраної пластини, тому їх неможливо зіставити. Зніміть вибір із цієї пластини, щоб додати решту до черги.",
totalCost: "Загальна вартість:",
@@ -5184,6 +5194,10 @@ export default {
title: "Примусовий збіг кольорів",
description: "Не надсилати завдання на принтер без філаменту точного типу й кольору. Типово вимкнено: без цього черга зіставляє лише модель і може вибрати принтер із філаментом іншого кольору.",
},
+ saveAmsMapping: {
+ title: "Зберігати зіставлення AMS",
+ description: "Зберігати в записі вибір слота AMS, зроблений самим слайсером (з MQTT-команди project_file), щоб пізніший повторний друк використав ту саму фізичну котушку замість повторного визначення за типом/кольором файлу. Типово вимкнено.",
+ },
gcodeInjection: {
title: "Вставлення G-коду",
description: "Застосовувати до завдань цього віртуального принтера фрагменти G-коду, налаштовані для кожної моделі. Типово вимкнено.",
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 48b65ed6a..913deeb62 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: '上传者',
noPermissionReprint: '您没有重新打印的权限',
noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',
+ slicerAmsMapping: '已保存 AMS 映射',
+ slicerAmsMappingTooltip: '此存档已保存来自切片软件的 AMS 槽位映射',
noPermissionEdit: '您没有编辑归档的权限',
noPermissionDelete: '您没有删除归档的权限',
openInBambuStudio: '在切片软件中打开',
@@ -1144,6 +1146,10 @@ export default {
unknown: '未知',
printAnyway: '仍要打印',
},
+ slicerAmsMapping: {
+ rowBadge: '已保存来自切片软件的 AMS 槽位',
+ rowTooltip: '此存档保留了切片/发送时切片软件选择的确切 AMS 槽位。重新打印会复用该实体线材,而不是根据类型/颜色重新猜测。',
+ },
title: '打印队列',
subtitle: '排程和管理您的打印任务',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: '选择打印机',
selectPlate: '选择板',
filamentMapping: '耗材映射',
+ useArchiveMapping: '映射',
+ useArchiveMappingTooltip: '从此存档保存的 AMS 映射(来自切片软件)中选择所有槽位,而不是按类型/颜色匹配。',
+ clickToChangeSlot: '点击更改槽位分配',
+ reRead: '重新读取',
plateN: '板 {{n}}',
plateFilamentsUnreadable: '无法读取所选盘的耗材信息,因此无法进行映射。取消选择该盘即可将其余盘加入队列。',
totalCost: '总成本:',
@@ -5129,6 +5139,10 @@ export default {
title: '强制颜色匹配',
description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
},
+ saveAmsMapping: {
+ title: '保存 AMS 映射',
+ description: '将切片软件自身选择的 AMS 槽位(来自 project_file MQTT 命令)保存到存档中,以便之后重新打印时复用同一卷实体线材,而不是根据文件的类型/颜色重新推导。默认关闭。',
+ },
gcodeInjection: {
title: 'G-code 注入',
description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。',
diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts
index 1f48b97d0..4ae172535 100644
--- a/frontend/src/i18n/locales/zh-TW.ts
+++ b/frontend/src/i18n/locales/zh-TW.ts
@@ -935,6 +935,8 @@ export default {
uploadedBy: '上傳者',
noPermissionReprint: '您沒有重新列印的權限',
noFileForReprint: '無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案',
+ slicerAmsMapping: '已儲存 AMS 對應',
+ slicerAmsMappingTooltip: '此封存已儲存來自切片軟體的 AMS 槽位對應',
noPermissionEdit: '您沒有編輯歸檔的權限',
noPermissionDelete: '您沒有刪除歸檔的權限',
openInBambuStudio: '在切片軟體中開啟',
@@ -1144,6 +1146,10 @@ export default {
unknown: '不明',
printAnyway: '仍要列印',
},
+ slicerAmsMapping: {
+ rowBadge: '已儲存來自切片軟體的 AMS 槽位',
+ rowTooltip: '此封存保留了切片/傳送時切片軟體選擇的確切 AMS 槽位。重新列印會重複使用該實體線材,而不是依類型/顏色重新猜測。',
+ },
title: '列印佇列',
subtitle: '排程和管理您的列印任務',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: '選擇印表機',
selectPlate: '選擇板',
filamentMapping: '耗材對應',
+ useArchiveMapping: '對應',
+ useArchiveMappingTooltip: '從此封存儲存的 AMS 對應(來自切片軟體)中選取所有槽位,而不是依類型/顏色比對。',
+ clickToChangeSlot: '點擊更改槽位分配',
+ reRead: '重新讀取',
plateN: '板 {{n}}',
plateFilamentsUnreadable: '無法讀取所選盤的耗材資訊,因此無法進行對應。取消選取該盤即可將其餘盤加入佇列。',
totalCost: '總成本:',
@@ -5129,6 +5139,10 @@ export default {
title: '強制顏色匹配',
description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
},
+ saveAmsMapping: {
+ title: '儲存 AMS 對應',
+ description: '將切片軟體自行選擇的 AMS 槽位(來自 project_file MQTT 指令)儲存到封存中,讓之後的重新列印能重複使用同一捲實體線材,而不是依檔案的類型/顏色重新推導。預設關閉。',
+ },
gcodeInjection: {
title: 'G-code 注入',
description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。',
diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx
index 7b2cb8618..4aa2a4e6b 100644
--- a/frontend/src/pages/ArchivesPage.tsx
+++ b/frontend/src/pages/ArchivesPage.tsx
@@ -56,6 +56,7 @@ import {
Cog,
Archive as ArchiveIcon,
History,
+ CheckCircle2,
} from 'lucide-react';
import { api } from '../api/client';
import { SliceModal } from '../components/SliceModal';
@@ -1109,6 +1110,16 @@ function ArchiveCard({
)}
+ {/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting) —
+ a reprint reuses this exact physical spool instead of re-deriving
+ one from the file's type/color. */}
+ {Array.isArray((archive.extra_data as Record | null)?.slicer_ams_mapping) && (
+