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) && ( +
+ + {t('archives.card.slicerAmsMapping')} +
+ )} + {/* Tags & Notes */} {(archive.tags || archive.notes) && (
diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 7d423460e..212dca0bb 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -731,6 +731,19 @@ function SortableQueueItem({

)} + {/* 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 && ( +

+ + {t('queue.slicerAmsMapping.rowBadge')} +

+ )} + {/* Error message */} {item.error_message && (