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

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

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

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

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

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

Closes #2684
This commit is contained in:
Sergey Dontsov
2026-07-31 11:08:58 +03:00
parent 1c2d219b89
commit bab1cfb906
29 changed files with 814 additions and 16 deletions
+23
View File
@@ -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(
@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool = True
queue_force_color_match: bool = False
save_ams_mapping: bool = False
gcode_injection: bool = False
bind_ip: str | None = None
remote_interface_ip: str | None = None
@@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool | None = None
queue_force_color_match: bool | None = None
save_ams_mapping: bool | None = None
gcode_injection: bool | None = None
bind_ip: str | None = None
remote_interface_ip: str | None = None
@@ -109,6 +111,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
"target_printer_id": vp.target_printer_id,
"auto_dispatch": vp.auto_dispatch,
"queue_force_color_match": vp.queue_force_color_match,
"save_ams_mapping": vp.save_ams_mapping,
"gcode_injection": vp.gcode_injection,
"bind_ip": vp.bind_ip,
"remote_interface_ip": vp.remote_interface_ip,
@@ -245,6 +248,7 @@ async def create_virtual_printer(
target_printer_id=body.target_printer_id,
auto_dispatch=body.auto_dispatch,
queue_force_color_match=body.queue_force_color_match,
save_ams_mapping=body.save_ams_mapping,
gcode_injection=body.gcode_injection,
bind_ip=body.bind_ip,
remote_interface_ip=body.remote_interface_ip,
@@ -423,6 +427,8 @@ async def update_virtual_printer(
vp.auto_dispatch = body.auto_dispatch
if body.queue_force_color_match is not None:
vp.queue_force_color_match = body.queue_force_color_match
if body.save_ams_mapping is not None:
vp.save_ams_mapping = body.save_ams_mapping
if body.gcode_injection is not None:
vp.gcode_injection = body.gcode_injection
if body.bind_ip is not None:
+9
View File
@@ -1363,6 +1363,15 @@ async def run_migrations(conn):
conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
)
# Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
# when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
# onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
# Default false to preserve current behaviour for upgraders.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
else:
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
# Per-VP opt-in for auto-print G-code injection (#1516). Default false so
# existing gcode_snippets users don't silently start injecting on VP/Studio
# Send jobs after upgrading.
+8
View File
@@ -49,6 +49,14 @@ class VirtualPrinter(Base):
) # queue mode: pin per-slot type+color from the 3MF onto the queue
# item so the scheduler refuses to dispatch onto a printer with the wrong
# filament loaded (#1188).
save_ams_mapping: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: persist the slicer's own live-resolved AMS-slot pick (the
# `ams_mapping` field on the MQTT `project_file` command) onto the
# archive's `extra_data.slicer_ams_mapping`, so a later reprint can reuse
# the exact physical spool instead of re-deriving one from the file's
# static type/color. Off by default — archives don't grow this field
# unless the user opts in per virtual printer.
gcode_injection: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
+5
View File
@@ -194,6 +194,11 @@ class PrintQueueItemResponse(BaseModel):
# 3MFs: when `plate_id` is set, the value is the matching plate's
# `curr_bed_type` rather than the archive-level first-plate default.
bed_type: str | None = None
# True when the source archive carries the slicer's own live-resolved
# AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint of this
# archive reuses that exact physical spool instead of the scheduler
# re-deriving one from just the file's static type/color.
archive_has_slicer_ams_mapping: bool = False
# User tracking (Issue #206)
created_by_id: int | None = None
+12
View File
@@ -1253,6 +1253,18 @@ class ArchiveService:
# Merge with print data from MQTT
if print_data:
metadata["_print_data"] = print_data
# Promote the slicer's own live-resolved AMS-slot pick (captured
# from the project_file MQTT command by the VP-queue path — see
# `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py)
# to a stable top-level extra_data key. Lets a later reprint reuse
# the exact tray the user picked/BambuStudio auto-matched at slice
# time instead of the scheduler re-deriving one from just the
# file's static type/color, which can land on the wrong physical
# spool when that match isn't unique. Top-level (not nested under
# the `_print_data` diagnostic bag) so API consumers have a single
# stable path: `archive.extra_data.slicer_ams_mapping`.
if print_data.get("ams_mapping"):
metadata["slicer_ams_mapping"] = print_data["ams_mapping"]
# Determine status and timestamps
status = print_data.get("status", "completed") if print_data else "archived"
+107 -4
View File
@@ -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 "",
@@ -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(
@@ -1847,6 +1847,202 @@ class TestVirtualPrinterInstance:
item = added_items[0]
assert item.nozzle_mapping is None
@pytest.mark.asyncio
async def test_add_to_print_queue_captures_ams_mapping_on_queue_item_regardless_of_toggle(self, tmp_path):
"""The slicer's live-resolved AMS-slot pick (`ams_mapping` in the
project_file MQTT command) must land on the queue item's own
`ams_mapping` column unconditionally — this is what THIS dispatch
uses, and is a correctness fix, not a feature. Only persisting it
onto the *archive* (for future reprints) is gated behind the
per-VP `save_ams_mapping` toggle — see the sibling test below.
"""
import json as _json
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
added_items = []
mock_db = AsyncMock()
mock_db.add = MagicMock(side_effect=added_items.append)
mock_db.commit = AsyncMock()
mock_session_factory = MagicMock()
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_factory.return_value = mock_session_ctx
inst = VirtualPrinterInstance(
vp_id=44,
name="AMSMappingOff",
mode="queue",
model="X1C",
access_code="12345678",
serial_suffix="391800044",
base_dir=tmp_path,
session_factory=mock_session_factory,
save_ams_mapping=False,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
await inst.on_print_command(
file_path.name,
{"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value=None,
),
patch(
"backend.app.services.archive.ArchiveService.archive_print",
new_callable=AsyncMock,
return_value=mock_archive,
) as mock_archive_print,
):
await inst._add_to_print_queue(file_path, "192.168.1.100")
assert len(added_items) == 1
item = added_items[0]
assert item.ams_mapping is not None
assert _json.loads(item.ams_mapping) == [4, -1, 12, -1]
# Toggle is off: the archive's print_data must NOT carry the mapping.
print_data = mock_archive_print.await_args.kwargs["print_data"]
assert print_data["ams_mapping"] is None
@pytest.mark.asyncio
async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path):
"""With the per-VP `save_ams_mapping` toggle on, the slicer's AMS
pick must also be forwarded to `ArchiveService.archive_print` so it
gets promoted to `archive.extra_data.slicer_ams_mapping` and a later
reprint can reuse the exact physical spool.
"""
import json as _json
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
added_items = []
mock_db = AsyncMock()
mock_db.add = MagicMock(side_effect=added_items.append)
mock_db.commit = AsyncMock()
mock_session_factory = MagicMock()
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_factory.return_value = mock_session_ctx
inst = VirtualPrinterInstance(
vp_id=45,
name="AMSMappingOn",
mode="queue",
model="X1C",
access_code="12345678",
serial_suffix="391800045",
base_dir=tmp_path,
session_factory=mock_session_factory,
save_ams_mapping=True,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
await inst.on_print_command(
file_path.name,
{"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value=None,
),
patch(
"backend.app.services.archive.ArchiveService.archive_print",
new_callable=AsyncMock,
return_value=mock_archive,
) as mock_archive_print,
):
await inst._add_to_print_queue(file_path, "192.168.1.100")
assert len(added_items) == 1
assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1]
print_data = mock_archive_print.await_args.kwargs["print_data"]
assert print_data["ams_mapping"] == [4, -1, 12, -1]
@pytest.mark.asyncio
async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path):
"""#2589: an all -1 `ams_mapping` means the slicer's own race lost —
every slot unresolved. Must be dropped (queue item AND archive),
never trusted over a fresh scheduler-computed mapping.
"""
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
added_items = []
mock_db = AsyncMock()
mock_db.add = MagicMock(side_effect=added_items.append)
mock_db.commit = AsyncMock()
mock_session_factory = MagicMock()
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_factory.return_value = mock_session_ctx
inst = VirtualPrinterInstance(
vp_id=46,
name="AMSMappingSentinel",
mode="queue",
model="X1C",
access_code="12345678",
serial_suffix="391800046",
base_dir=tmp_path,
session_factory=mock_session_factory,
save_ams_mapping=True,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
await inst.on_print_command(
file_path.name,
{"command": "project_file", "ams_mapping": [-1, -1, -1, -1]},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value=None,
),
patch(
"backend.app.services.archive.ArchiveService.archive_print",
new_callable=AsyncMock,
return_value=mock_archive,
) as mock_archive_print,
):
await inst._add_to_print_queue(file_path, "192.168.1.100")
assert len(added_items) == 1
assert added_items[0].ams_mapping is None
print_data = mock_archive_print.await_args.kwargs["print_data"]
assert print_data["ams_mapping"] is None
@pytest.mark.asyncio
async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
"""#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
@@ -2185,6 +2381,59 @@ class TestVirtualPrinterInstance:
assert "test.3mf" not in inst._recent_queue_items
class TestExtractSlicerAmsMappingJson:
"""Unit tests for `_extract_slicer_ams_mapping_json`, the pure-function
parser that pulls the slicer's own live-resolved AMS-slot pick out of a
captured project_file MQTT payload (see docstring in manager.py)."""
def _extract(self, data):
from backend.app.services.virtual_printer.manager import _extract_slicer_ams_mapping_json
return _extract_slicer_ams_mapping_json(data, "[test]")
def test_missing_field_returns_none(self):
assert self._extract({}) is None
def test_valid_int_list_returns_json(self):
import json as _json
result = self._extract({"ams_mapping": [4, -1, 12, -1]})
assert result is not None
assert _json.loads(result) == [4, -1, 12, -1]
def test_stringified_json_is_parsed(self):
import json as _json
result = self._extract({"ams_mapping": "[0, 1, 2]"})
assert result is not None
assert _json.loads(result) == [0, 1, 2]
def test_unparseable_string_returns_none(self):
assert self._extract({"ams_mapping": "not json"}) is None
def test_non_list_value_returns_none(self):
assert self._extract({"ams_mapping": 42}) is None
def test_empty_list_returns_none(self):
assert self._extract({"ams_mapping": []}) is None
def test_non_int_entries_return_none(self):
assert self._extract({"ams_mapping": [1, "two", 3]}) is None
def test_2589_all_unresolved_sentinel_returns_none(self):
"""#2589: every slot -1 means the slicer's own resolution race
lost — never trust this over a fresh live computation."""
assert self._extract({"ams_mapping": [-1, -1, -1, -1]}) is None
def test_partially_resolved_mapping_is_kept(self):
import json as _json
# Only some slots resolved is still meaningful — keep it.
result = self._extract({"ams_mapping": [-1, 4, -1, -1]})
assert result is not None
assert _json.loads(result) == [-1, 4, -1, -1]
class TestVirtualPrinterManager:
"""Tests for VirtualPrinterManager orchestrator."""
@@ -2358,6 +2607,7 @@ class TestVirtualPrinterManager:
"auto_dispatch": True,
"tailscale_disabled": True, # Opt-in default (#1070 UX fix)
"queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector
"save_ams_mapping": False, # same reason as above
"gcode_injection": False, # same reason as above
"position": 0,
}
+7
View File
@@ -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;
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
@@ -24,11 +24,50 @@ export function FilamentMapping({
forceColorMatch,
onForceColorMatchChange,
plateLabel,
archiveAmsMapping,
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
// "Mapping" toggle (only shown when the archive has a saved slicer pick):
// ON selects every slot straight from `archiveAmsMapping`, bypassing the
// type/color auto-match entirely — same mechanism as a manual per-slot
// pick (`manualMappings`), just applied to every required slot at once.
// OFF removes exactly those overrides so the panel falls back to its
// normal auto-match, without touching any *other* manual picks the user
// made by hand.
const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
// Reset the toggle whenever the saved mapping it would apply changes — a
// different plate selection or a different archive entirely. Without this
// the button can read ON (green) from a previous archive/plate even though
// it was never pressed against the mapping currently in scope.
useEffect(() => {
setUsingArchiveMapping(false);
}, [archiveAmsMapping, plateLabel]);
const toggleArchiveMapping = () => {
if (!archiveAmsMapping || !filamentReqs?.filaments) return;
if (usingArchiveMapping) {
const next = { ...manualMappings };
for (const req of filamentReqs.filaments) {
if (req.slot_id > 0) delete next[req.slot_id];
}
onManualMappingChange(next);
setUsingArchiveMapping(false);
return;
}
const next = { ...manualMappings };
for (const req of filamentReqs.filaments) {
const idx = req.slot_id - 1;
if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
next[req.slot_id] = archiveAmsMapping[idx];
}
}
onManualMappingChange(next);
setUsingArchiveMapping(true);
};
// Fetch printer status
const { data: printerStatus } = useQuery({
@@ -211,16 +250,33 @@ export function FilamentMapping({
{isExpanded && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-bambu-gray">Click to change slot assignment</span>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Re-read</span>
</button>
<span className="text-xs text-bambu-gray">{t('printModal.clickToChangeSlot')}</span>
<div className="flex items-center gap-1.5">
{archiveAmsMapping && (
<button
type="button"
onClick={toggleArchiveMapping}
title={t('printModal.useArchiveMappingTooltip')}
className={`flex items-center gap-1 px-2 py-0.5 text-xs rounded border transition-colors ${
usingArchiveMapping
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
: 'border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
<Check className="w-3 h-3" />
<span>{t('printModal.useArchiveMapping')}</span>
</button>
)}
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>{t('printModal.reRead')}</span>
</button>
</div>
</div>
{filamentComparison.map((item, idx) => {
// #1717: surface the same per-slot force-color-match checkbox here
@@ -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}
/>
)}
@@ -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[];
}
/**
@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
const [localModel, setLocalModel] = useState(printer.model || '');
const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
const [localSaveAmsMapping, setLocalSaveAmsMapping] = useState(printer.save_ams_mapping ?? false);
const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
const [showAccessCode, setShowAccessCode] = useState(false);
@@ -101,6 +102,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
setLocalModel(printer.model || '');
setLocalAutoDispatch(printer.auto_dispatch ?? true);
setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
setLocalGcodeInjection(printer.gcode_injection ?? false);
setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
}
@@ -439,6 +441,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
</div>
)}
{/* Save-AMS-mapping toggle - only for queue mode */}
{localMode === 'queue' && (
<div className="pt-2 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-white text-sm font-medium">{t('virtualPrinter.saveAmsMapping.title')}</div>
<div className="text-[10px] text-bambu-gray">{t('virtualPrinter.saveAmsMapping.description')}</div>
</div>
<button
onClick={() => {
const newVal = !localSaveAmsMapping;
setLocalSaveAmsMapping(newVal);
setPendingAction('saveAmsMapping');
updateMutation.mutate({ save_ams_mapping: newVal });
}}
disabled={pendingAction === 'saveAmsMapping'}
className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
localSaveAmsMapping ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
} ${pendingAction === 'saveAmsMapping' ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
localSaveAmsMapping ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
</div>
)}
{/* G-code injection toggle - only for queue mode (#1516) */}
{localMode === 'queue' && (
<div className="pt-2 border-t border-bambu-dark-tertiary">
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Hochgeladen von',
noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',
slicerAmsMapping: 'AMS-Zuordnung gespeichert',
slicerAmsMappingTooltip: 'Für dieses Archiv ist eine gespeicherte AMS-Zuordnung vom Slicer vorhanden',
noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
openInBambuStudio: 'Im Slicer öffnen',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'unbekannt',
printAnyway: 'Trotzdem drucken',
},
slicerAmsMapping: {
rowBadge: 'AMS-Steckplatz vom Slicer gespeichert',
rowTooltip: 'Dieses Archiv enthält den genauen AMS-Steckplatz, den der Slicer beim Slicen/Senden ausgewählt hat. Ein erneuter Druck verwendet diese physische Spule, statt sie erneut anhand von Typ/Farbe zu erraten.',
},
title: 'Druckwarteschlange',
subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
// Print modal
@@ -4619,6 +4625,10 @@ export default {
selectPrinter: 'Drucker auswählen',
selectPlate: 'Platte auswählen',
filamentMapping: 'Filamentzuordnung',
useArchiveMapping: 'Zuordnung',
useArchiveMappingTooltip: 'Jeden Steckplatz aus der mit diesem Archiv gespeicherten AMS-Zuordnung (vom Slicer) auswählen, anstatt nach Typ/Farbe abzugleichen.',
clickToChangeSlot: 'Klicken, um die Steckplatzzuweisung zu ändern',
reRead: 'Neu einlesen',
plateN: 'Platte {{n}}',
plateFilamentsUnreadable: 'Die Filamente einer ausgewählten Platte konnten nicht gelesen werden, sie lässt sich daher nicht zuordnen. Wähle sie ab, um die anderen einzureihen.',
totalCost: 'Gesamtkosten:',
@@ -5140,6 +5150,10 @@ export default {
title: 'Farbabgleich erzwingen',
description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
},
saveAmsMapping: {
title: 'AMS-Zuordnung speichern',
description: 'Speichert die vom Slicer selbst gewählte AMS-Steckplatz-Zuordnung (aus dem MQTT-Befehl project_file) im Archiv, sodass ein späterer erneuter Druck dieselbe physische Spule verwendet, anstatt sie erneut aus Typ/Farbe der Datei abzuleiten. Standardmäßig deaktiviert.',
},
gcodeInjection: {
title: 'G-code-Injektion',
description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.',
+14
View File
@@ -939,6 +939,8 @@ export default {
uploadedBy: 'Uploaded By',
noPermissionReprint: 'You do not have permission to reprint',
noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',
slicerAmsMapping: 'AMS mapping saved',
slicerAmsMappingTooltip: 'This archive has a saved AMS slot mapping from the slicer',
noPermissionEdit: 'You do not have permission to edit archives',
noPermissionDelete: 'You do not have permission to delete archives',
openInBambuStudio: 'Open in Slicer',
@@ -1155,6 +1157,10 @@ export default {
unknown: 'unknown',
printAnyway: 'Print Anyway',
},
slicerAmsMapping: {
rowBadge: 'AMS slot saved from slicer',
rowTooltip: 'This archive carries the exact AMS slot the slicer picked when it was sliced/sent. A reprint reuses that physical spool instead of re-guessing from type/color.',
},
// Print modal
editQueueItem: 'Edit Queue Item',
selectAllPlates: 'Select All {{count}} Plates',
@@ -4662,6 +4668,10 @@ export default {
selectPrinter: 'Select Printer',
selectPlate: 'Select Plate',
filamentMapping: 'Filament Mapping',
useArchiveMapping: 'Mapping',
useArchiveMappingTooltip: 'Select every slot from the AMS mapping saved with this archive (from the slicer), instead of matching by type/color.',
clickToChangeSlot: 'Click to change slot assignment',
reRead: 'Re-read',
plateN: 'Plate {{n}}',
plateFilamentsUnreadable: 'The filaments of a selected plate could not be read, so it can\'t be mapped. Deselect it to queue the others.',
totalCost: 'Total cost:',
@@ -5184,6 +5194,10 @@ export default {
title: 'Force color match',
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
},
saveAmsMapping: {
title: 'Save AMS mapping',
description: 'Persist the slicer\'s own AMS-slot pick (from the project_file MQTT command) onto the archive, so a later reprint reuses the exact physical spool instead of re-deriving one from the file\'s type/color. Off by default.',
},
gcodeInjection: {
title: 'G-code injection',
description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.',
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Subido por',
noPermissionReprint: 'No tiene permiso para reimprimir',
noFileForReprint: 'No hay archivo 3MF disponible — no se pudo descargar el archivo de la impresora cuando se registró la impresión',
slicerAmsMapping: 'Mapeo de AMS guardado',
slicerAmsMappingTooltip: 'Este archivo tiene un mapeo de ranuras AMS guardado desde el slicer',
noPermissionEdit: 'No tiene permiso para editar archivos',
noPermissionDelete: 'No tiene permiso para eliminar archivos',
openInBambuStudio: 'Abrir en el laminador',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'desconocido',
printAnyway: 'Imprimir de todos modos',
},
slicerAmsMapping: {
rowBadge: 'Ranura AMS guardada desde el slicer',
rowTooltip: 'Este archivo conserva la ranura AMS exacta que eligió el slicer al laminar/enviar. Una reimpresión reutiliza ese carrete físico en lugar de volver a adivinar por tipo/color.',
},
title: 'Cola de impresión',
subtitle: 'Programe y gestione sus trabajos de impresión',
// Print modal
@@ -4627,6 +4633,10 @@ export default {
selectPrinter: 'Seleccionar impresora',
selectPlate: 'Seleccionar cama',
filamentMapping: 'Mapeo de filamentos',
useArchiveMapping: 'Mapeo',
useArchiveMappingTooltip: 'Selecciona todas las ranuras a partir del mapeo de AMS guardado con este archivo (del slicer), en lugar de emparejar por tipo/color.',
clickToChangeSlot: 'Haga clic para cambiar la asignación de ranura',
reRead: 'Releer',
plateN: 'Cama {{n}}',
plateFilamentsUnreadable: 'No se han podido leer los filamentos de una cama seleccionada, por lo que no se puede asignar. Deselecciónala para encolar las demás.',
totalCost: 'Coste total:',
@@ -5149,6 +5159,10 @@ export default {
title: 'Forzar la coincidencia de color',
description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.',
},
saveAmsMapping: {
title: 'Guardar mapeo de AMS',
description: 'Guarda en el archivo la selección de ranura AMS propia del slicer (del comando MQTT project_file), de modo que una reimpresión posterior reutilice el mismo carrete físico en lugar de volver a derivarlo del tipo/color del archivo. Desactivado de forma predeterminada.',
},
gcodeInjection: {
title: 'Inyección de G-code',
description: 'Aplica los fragmentos de G-code configurados por modelo en Ajustes a los trabajos de esta IV. Desactivado de forma predeterminada.',
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Téléversé par',
noPermissionReprint: 'Pas d\'autorisation de réimpression',
noFileForReprint: 'Aucun fichier 3MF disponible — le fichier n\'a pas pu être téléchargé depuis l\'imprimante lors de l\'enregistrement',
slicerAmsMapping: 'Mappage AMS enregistré',
slicerAmsMappingTooltip: 'Cette archive dispose d\'un mappage des emplacements AMS enregistré depuis le slicer',
noPermissionEdit: 'Pas d\'autorisation de modification',
noPermissionDelete: 'Pas d\'autorisation de suppression',
openInBambuStudio: 'Ouvrir dans le Slicer',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'inconnu',
printAnyway: 'Imprimer quand meme',
},
slicerAmsMapping: {
rowBadge: 'Emplacement AMS enregistré depuis le slicer',
rowTooltip: 'Cette archive conserve l\'emplacement AMS exact choisi par le slicer lors du tranchage/envoi. Une réimpression réutilise cette bobine physique au lieu de deviner à nouveau par type/couleur.',
},
title: 'File d\'attente',
subtitle: 'Gérez vos travaux d\'impression',
// Print modal
@@ -4608,6 +4614,10 @@ export default {
selectPrinter: 'Choisir l\'imprimante',
selectPlate: 'Choisir le plateau',
filamentMapping: 'Mapping Filament',
useArchiveMapping: 'Mappage',
useArchiveMappingTooltip: 'Sélectionner tous les emplacements à partir du mappage AMS enregistré avec cette archive (depuis le slicer), au lieu de faire correspondre par type/couleur.',
clickToChangeSlot: 'Cliquez pour modifier l\'attribution de l\'emplacement',
reRead: 'Relire',
plateN: 'Plateau {{n}}',
plateFilamentsUnreadable: 'Les filaments d\'un plateau sélectionné n\'ont pas pu être lus, il est donc impossible de l\'affecter. Désélectionnez-le pour mettre les autres en file.',
totalCost: 'Coût total :',
@@ -5130,6 +5140,10 @@ export default {
title: 'Forcer la correspondance des couleurs',
description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.',
},
saveAmsMapping: {
title: 'Enregistrer le mappage AMS',
description: 'Conserve dans l\'archive le choix d\'emplacement AMS propre au slicer (depuis la commande MQTT project_file), afin qu\'une réimpression ultérieure réutilise la même bobine physique au lieu de la redéduire à partir du type/de la couleur du fichier. Désactivé par défaut.',
},
gcodeInjection: {
title: 'Injection G-code',
description: 'Applique les extraits de G-code configurés par modèle dans les Paramètres aux travaux de ce VP. Désactivé par défaut.',
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Caricato da',
noPermissionReprint: 'Non hai il permesso di ristampare',
noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
slicerAmsMapping: 'Mappatura AMS salvata',
slicerAmsMappingTooltip: 'Questo archivio ha una mappatura degli slot AMS salvata dallo slicer',
noPermissionEdit: 'Non hai il permesso di modificare archivi',
noPermissionDelete: 'Non hai il permesso di eliminare archivi',
openInBambuStudio: 'Apri nello slicer',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'sconosciuto',
printAnyway: 'Stampa comunque',
},
slicerAmsMapping: {
rowBadge: 'Slot AMS salvato dallo slicer',
rowTooltip: 'Questo archivio conserva lo slot AMS esatto scelto dallo slicer al momento dello slicing/invio. Una ristampa riutilizza quella bobina fisica invece di indovinare di nuovo in base a tipo/colore.',
},
title: 'Coda di stampa',
subtitle: 'Programma e gestisci i tuoi lavori di stampa',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: 'Seleziona stampante',
selectPlate: 'Seleziona piatto',
filamentMapping: 'Mappatura filamento',
useArchiveMapping: 'Mappatura',
useArchiveMappingTooltip: 'Seleziona ogni slot dalla mappatura AMS salvata con questo archivio (dallo slicer), invece di abbinare per tipo/colore.',
clickToChangeSlot: 'Fai clic per modificare l\'assegnazione dello slot',
reRead: 'Rileggi',
plateN: 'Piatto {{n}}',
plateFilamentsUnreadable: 'Non è stato possibile leggere i filamenti di un piatto selezionato, quindi non può essere assegnato. Deselezionalo per accodare gli altri.',
totalCost: 'Costo totale:',
@@ -5129,6 +5139,10 @@ export default {
title: 'Forza corrispondenza colori',
description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
},
saveAmsMapping: {
title: 'Salva mappatura AMS',
description: 'Salva nell\'archivio la scelta dello slot AMS effettuata dallo slicer stesso (dal comando MQTT project_file), in modo che una ristampa successiva riutilizzi la stessa bobina fisica invece di ricavarla di nuovo dal tipo/colore del file. Disattivato per impostazione predefinita.',
},
gcodeInjection: {
title: 'Iniezione G-code',
description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.',
+14
View File
@@ -934,6 +934,8 @@ export default {
uploadedBy: 'アップロード者',
noPermissionReprint: '再印刷する権限がありません',
noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
slicerAmsMapping: 'AMSマッピングを保存しました',
slicerAmsMappingTooltip: 'このアーカイブにはスライサーから保存されたAMSスロットマッピングがあります',
noPermissionEdit: 'プロファイルを編集する権限がありません',
noPermissionDelete: 'アーカイブを削除する権限がありません',
openInBambuStudio: 'スライサーで開く',
@@ -1143,6 +1145,10 @@ export default {
unknown: '不明',
printAnyway: 'それでも印刷',
},
slicerAmsMapping: {
rowBadge: 'スライサーから保存されたAMSスロット',
rowTooltip: 'このアーカイブには、スライス/送信時にスライサーが選択した正確なAMSスロットが保持されています。再印刷では、タイプ/色から再推測するのではなく、その物理スプールが再利用されます。',
},
title: '印刷キュー',
subtitle: '印刷ジョブのスケジュールと管理',
// Print modal
@@ -4619,6 +4625,10 @@ export default {
selectPrinter: 'プリンターを選択',
selectPlate: 'プレートを選択',
filamentMapping: 'フィラメントマッピング',
useArchiveMapping: 'マッピング',
useArchiveMappingTooltip: 'タイプ/色で照合する代わりに、このアーカイブに保存されたAMSマッピング(スライサーから)からすべてのスロットを選択します。',
clickToChangeSlot: 'クリックしてスロットの割り当てを変更',
reRead: '再読み込み',
plateN: 'プレート {{n}}',
plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
totalCost: '合計コスト:',
@@ -5141,6 +5151,10 @@ export default {
title: '色の一致を強制',
description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
},
saveAmsMapping: {
title: 'AMSマッピングを保存',
description: 'スライサー自身が選択したAMSスロット(project_file MQTTコマンドから)をアーカイブに保存し、後で再印刷する際にファイルのタイプ/色から再導出するのではなく、同じ物理スプールを再利用できるようにします。デフォルトはオフです。',
},
gcodeInjection: {
title: 'G-codeインジェクション',
description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',
+15 -1
View File
@@ -890,6 +890,8 @@ export default {
uploadedBy: '업로드한 사용자',
noPermissionReprint: '재인쇄 권한이 없습니다',
noFileForReprint: '3MF 파일 없음 — 인쇄 기록 시 프린터에서 파일을 다운로드할 수 없었습니다',
slicerAmsMapping: 'AMS 매핑 저장됨',
slicerAmsMappingTooltip: '이 아카이브에는 슬라이서에서 저장된 AMS 슬롯 매핑이 있습니다',
noPermissionEdit: '아카이브를 편집할 권한이 없습니다',
noPermissionDelete: '아카이브를 삭제할 권한이 없습니다',
openInBambuStudio: '슬라이서에서 열기',
@@ -1349,7 +1351,11 @@ export default {
lineItem: '슬롯 {{slot}}: {{required}}g 필요, {{remaining}}g 남음',
unknown: '알 수 없음',
printAnyway: '그냥 인쇄'
}
},
slicerAmsMapping: {
rowBadge: '슬라이서에서 저장된 AMS 슬롯',
rowTooltip: '이 아카이브에는 슬라이싱/전송 시 슬라이서가 선택한 정확한 AMS 슬롯이 보존되어 있습니다. 재인쇄 시 유형/색상으로 다시 추측하는 대신 해당 실물 스풀을 재사용합니다.',
},
},
stats: {
title: '대시보드',
@@ -4391,6 +4397,10 @@ export default {
selectPrinter: '프린터 선택',
selectPlate: '플레이트 선택',
filamentMapping: '필라멘트 매핑',
useArchiveMapping: '매핑',
useArchiveMappingTooltip: '유형/색상으로 매칭하는 대신, 이 아카이브에 저장된 AMS 매핑(슬라이서 제공)에서 모든 슬롯을 선택합니다.',
clickToChangeSlot: '클릭하여 슬롯 할당 변경',
reRead: '다시 읽기',
plateN: '플레이트 {{n}}',
plateFilamentsUnreadable: '선택한 플레이트의 필라멘트를 읽을 수 없어 매핑할 수 없습니다. 해당 플레이트를 선택 해제하면 나머지를 대기열에 추가할 수 있습니다.',
totalCost: '총 비용:',
@@ -4878,6 +4888,10 @@ export default {
title: '색상 일치 강제',
description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
},
saveAmsMapping: {
title: 'AMS 매핑 저장',
description: '슬라이서가 직접 선택한 AMS 슬롯(project_file MQTT 명령에서)을 아카이브에 저장하여, 이후 재인쇄 시 파일의 유형/색상에서 다시 유추하지 않고 동일한 실물 스풀을 재사용하도록 합니다. 기본값은 꺼짐입니다.',
},
gcodeInjection: {
title: 'G-code 주입',
description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Enviado por',
noPermissionReprint: 'Você não tem permissão para reimprimir',
noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',
slicerAmsMapping: 'Mapeamento de AMS salvo',
slicerAmsMappingTooltip: 'Este arquivo tem um mapeamento de slots AMS salvo do fatiador',
noPermissionEdit: 'Você não tem permissão para editar arquivos',
noPermissionDelete: 'Você não tem permissão para excluir arquivos',
openInBambuStudio: 'Abrir no Bambu Studio',
@@ -1144,6 +1146,10 @@ export default {
unknown: 'desconhecido',
printAnyway: 'Imprimir mesmo assim',
},
slicerAmsMapping: {
rowBadge: 'Slot AMS salvo do fatiador',
rowTooltip: 'Este arquivo mantém o slot AMS exato escolhido pelo fatiador ao fatiar/enviar. Uma reimpressão reutiliza esse carretel físico em vez de adivinhar novamente pelo tipo/cor.',
},
title: 'Fila de Impressão',
subtitle: 'Agende e gerencie seus trabalhos de impressão',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: 'Selecionar Impressora',
selectPlate: 'Selecionar Placa',
filamentMapping: 'Mapeamento de Filamento',
useArchiveMapping: 'Mapeamento',
useArchiveMappingTooltip: 'Selecionar todos os slots a partir do mapeamento de AMS salvo com este arquivo (do fatiador), em vez de corresponder por tipo/cor.',
clickToChangeSlot: 'Clique para alterar a atribuição do slot',
reRead: 'Reler',
plateN: 'Placa {{n}}',
plateFilamentsUnreadable: 'Não foi possível ler os filamentos de uma placa selecionada, portanto ela não pode ser mapeada. Desmarque-a para enfileirar as demais.',
totalCost: 'Custo total:',
@@ -5129,6 +5139,10 @@ export default {
title: 'Forçar correspondência de cor',
description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.',
},
saveAmsMapping: {
title: 'Salvar mapeamento de AMS',
description: 'Persiste a escolha de slot AMS feita pelo próprio fatiador (do comando MQTT project_file) no arquivo, para que uma reimpressão posterior reutilize o mesmo carretel físico em vez de derivá-lo novamente do tipo/cor do arquivo. Desativado por padrão.',
},
gcodeInjection: {
title: 'Injeção de G-code',
description: 'Aplica os trechos de G-code configurados por modelo nas Configurações aos trabalhos deste VP. Desativado por padrão.',
+14
View File
@@ -891,6 +891,8 @@ export default {
uploadedBy: "Загрузил",
noPermissionReprint: "У вас нет разрешения на повторную печать",
noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
slicerAmsMapping: "Маппинг AMS сохранён",
slicerAmsMappingTooltip: "У этого архива сохранён маппинг AMS-ячеек от слайсера",
noPermissionEdit: "У вас нет разрешения на изменение архива",
noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
openInBambuStudio: "Открыть в слайсере",
@@ -1096,6 +1098,10 @@ export default {
unknown: "неизвестно",
printAnyway: "Всё равно печатать",
},
slicerAmsMapping: {
rowBadge: "Ячейка AMS сохранена от слайсера",
rowTooltip: "У этого архива сохранена точная ячейка AMS, которую выбрал слайсер при нарезке/отправке. Повторная печать использует именно эту физическую катушку, а не подбор заново по типу/цвету.",
},
editQueueItem: "Изменить задание в очереди",
selectAllPlates: "Выбрать все пластины ({{count}})",
deselectAll: "Снять выделение",
@@ -4380,6 +4386,10 @@ export default {
selectPrinter: "Выберите принтер",
selectPlate: "Выберите пластину",
filamentMapping: "Сопоставление филаментов",
useArchiveMapping: "Маппинг",
useArchiveMappingTooltip: "Выбрать все ячейки из маппинга AMS, сохранённого с этим архивом (от слайсера), вместо подбора по типу/цвету.",
clickToChangeSlot: "Нажмите, чтобы изменить назначение ячейки",
reRead: "Перечитать",
plateN: "Пластина {{n}}",
plateFilamentsUnreadable: "Не удалось определить филаменты выбранной пластины, поэтому их невозможно сопоставить. Снимите выбор с этой пластины, чтобы добавить остальные в очередь.",
totalCost: "Общая стоимость:",
@@ -4866,6 +4876,10 @@ export default {
title: "Требовать совпадения цвета",
description: "Не назначать задание принтеру, если загружены филамент другого типа или другого цвета. По умолчанию выключено: без этой проверки очередь сопоставляет только модель принтера и может выбрать принтер с неподходящим цветом.",
},
saveAmsMapping: {
title: "Сохранять маппинг AMS",
description: "Сохранять выбор AMS-ячейки, который сделал слайсер (из MQTT-команды project_file), в архив — тогда повторная печать использует ту же физическую катушку вместо повторного подбора по типу/цвету файла. По умолчанию выключено.",
},
gcodeInjection: {
title: "Вставка G-code",
description: "Применять к заданиям этого виртуального принтера фрагменты G-code для соответствующей модели, заданные в настройках. По умолчанию выключено.",
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: 'Yükleyen',
noPermissionReprint: 'Yeniden yazdırma izniniz yok',
noFileForReprint: 'Kullanılabilir 3MF dosyası yok — baskı kaydedildiğinde dosya yazıcıdan indirilemedi',
slicerAmsMapping: 'AMS eşlemesi kaydedildi',
slicerAmsMappingTooltip: 'Bu arşivde dilimleyiciden kaydedilmiş bir AMS yuva eşlemesi var',
noPermissionEdit: 'Arşivleri düzenleme izniniz yok',
noPermissionDelete: 'Arşivleri silme izniniz yok',
openInBambuStudio: 'Dilimleyicide Aç',
@@ -1146,6 +1148,10 @@ export default {
unknown: 'bilinmiyor',
printAnyway: 'Yine de Yazdır',
},
slicerAmsMapping: {
rowBadge: 'Dilimleyiciden kaydedilen AMS yuvası',
rowTooltip: 'Bu arşiv, dilimleme/gönderme sırasında dilimleyicinin seçtiği tam AMS yuvasını taşır. Yeniden yazdırma, tür/renkten yeniden tahmin etmek yerine o fiziksel makarayı yeniden kullanır.',
},
// Baskı modali
editQueueItem: 'Kuyruk Öğesini Düzenle',
selectAllPlates: 'Tüm {{count}} Plakayı Seç',
@@ -4597,6 +4603,10 @@ export default {
selectPrinter: 'Yazıcı Seç',
selectPlate: 'Plaka Seç',
filamentMapping: 'Filament Eşlemesi',
useArchiveMapping: 'Eşleme',
useArchiveMappingTooltip: 'Tür/renge göre eşleştirmek yerine, bu arşivle kaydedilen AMS eşlemesindeki (dilimleyiciden) her yuvayı seç.',
clickToChangeSlot: 'Yuva atamasını değiştirmek için tıklayın',
reRead: 'Yeniden oku',
plateN: 'Plaka {{n}}',
plateFilamentsUnreadable: 'Seçili bir plakanın filamentleri okunamadı, bu yüzden eşleştirilemiyor. Diğerlerini kuyruğa almak için o plakanın seçimini kaldırın.',
totalCost: 'Toplam maliyet:',
@@ -5105,6 +5115,10 @@ export default {
title: 'Renk eşleşmesini zorla',
description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.',
},
saveAmsMapping: {
title: 'AMS eşlemesini kaydet',
description: 'Dilimleyicinin kendi seçtiği AMS yuvasını (project_file MQTT komutundan) arşive kalıcı olarak kaydeder, böylece daha sonraki bir yeniden yazdırma dosyanın türünden/renginden yeniden türetmek yerine tam olarak aynı fiziksel makarayı yeniden kullanır. Varsayılan olarak kapalı.',
},
gcodeInjection: {
title: 'G-code enjeksiyonu',
description: "Ayarlar'da model bazında yapılandırılan G-code parçacıklarını bu VP'nin işlerine uygular. Varsayılan olarak kapalı.",
+14
View File
@@ -939,6 +939,8 @@ export default {
uploadedBy: "Вивантажив",
noPermissionReprint: "Ви не маєте дозволу на передрук",
noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
slicerAmsMapping: "Зіставлення AMS збережено",
slicerAmsMappingTooltip: "Для цього запису збережено зіставлення слотів AMS зі слайсера",
noPermissionEdit: "Ви не маєте прав на редагування архівів",
noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
openInBambuStudio: "Відкрити у слайсері",
@@ -1155,6 +1157,10 @@ export default {
unknown: "невідомо",
printAnyway: "Усе одно друкувати",
},
slicerAmsMapping: {
rowBadge: "Слот AMS збережено зі слайсера",
rowTooltip: "Цей запис містить точний слот AMS, який вибрав слайсер під час нарізання/надсилання. Повторний друк використає саме цю фізичну котушку замість повторного добору за типом/кольором.",
},
// Print modal
editQueueItem: "Редагувати елемент черги",
selectAllPlates: "Вибрати всі пластини ({{count}})",
@@ -4662,6 +4668,10 @@ export default {
selectPrinter: "Вибрати принтер",
selectPlate: "Вибрати пластину",
filamentMapping: "Зіставлення філаментів",
useArchiveMapping: "Зіставлення",
useArchiveMappingTooltip: "Вибрати всі слоти зі зіставлення AMS, збереженого з цим записом (зі слайсера), замість добору за типом/кольором.",
clickToChangeSlot: "Натисніть, щоб змінити призначення слота",
reRead: "Перечитати",
plateN: "Пластина {{n}}",
plateFilamentsUnreadable: "Не вдалося прочитати філаменти вибраної пластини, тому їх неможливо зіставити. Зніміть вибір із цієї пластини, щоб додати решту до черги.",
totalCost: "Загальна вартість:",
@@ -5184,6 +5194,10 @@ export default {
title: "Примусовий збіг кольорів",
description: "Не надсилати завдання на принтер без філаменту точного типу й кольору. Типово вимкнено: без цього черга зіставляє лише модель і може вибрати принтер із філаментом іншого кольору.",
},
saveAmsMapping: {
title: "Зберігати зіставлення AMS",
description: "Зберігати в записі вибір слота AMS, зроблений самим слайсером (з MQTT-команди project_file), щоб пізніший повторний друк використав ту саму фізичну котушку замість повторного визначення за типом/кольором файлу. Типово вимкнено.",
},
gcodeInjection: {
title: "Вставлення G-коду",
description: "Застосовувати до завдань цього віртуального принтера фрагменти G-коду, налаштовані для кожної моделі. Типово вимкнено.",
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: '上传者',
noPermissionReprint: '您没有重新打印的权限',
noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',
slicerAmsMapping: '已保存 AMS 映射',
slicerAmsMappingTooltip: '此存档已保存来自切片软件的 AMS 槽位映射',
noPermissionEdit: '您没有编辑归档的权限',
noPermissionDelete: '您没有删除归档的权限',
openInBambuStudio: '在切片软件中打开',
@@ -1144,6 +1146,10 @@ export default {
unknown: '未知',
printAnyway: '仍要打印',
},
slicerAmsMapping: {
rowBadge: '已保存来自切片软件的 AMS 槽位',
rowTooltip: '此存档保留了切片/发送时切片软件选择的确切 AMS 槽位。重新打印会复用该实体线材,而不是根据类型/颜色重新猜测。',
},
title: '打印队列',
subtitle: '排程和管理您的打印任务',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: '选择打印机',
selectPlate: '选择板',
filamentMapping: '耗材映射',
useArchiveMapping: '映射',
useArchiveMappingTooltip: '从此存档保存的 AMS 映射(来自切片软件)中选择所有槽位,而不是按类型/颜色匹配。',
clickToChangeSlot: '点击更改槽位分配',
reRead: '重新读取',
plateN: '板 {{n}}',
plateFilamentsUnreadable: '无法读取所选盘的耗材信息,因此无法进行映射。取消选择该盘即可将其余盘加入队列。',
totalCost: '总成本:',
@@ -5129,6 +5139,10 @@ export default {
title: '强制颜色匹配',
description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
},
saveAmsMapping: {
title: '保存 AMS 映射',
description: '将切片软件自身选择的 AMS 槽位(来自 project_file MQTT 命令)保存到存档中,以便之后重新打印时复用同一卷实体线材,而不是根据文件的类型/颜色重新推导。默认关闭。',
},
gcodeInjection: {
title: 'G-code 注入',
description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。',
+14
View File
@@ -935,6 +935,8 @@ export default {
uploadedBy: '上傳者',
noPermissionReprint: '您沒有重新列印的權限',
noFileForReprint: '無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案',
slicerAmsMapping: '已儲存 AMS 對應',
slicerAmsMappingTooltip: '此封存已儲存來自切片軟體的 AMS 槽位對應',
noPermissionEdit: '您沒有編輯歸檔的權限',
noPermissionDelete: '您沒有刪除歸檔的權限',
openInBambuStudio: '在切片軟體中開啟',
@@ -1144,6 +1146,10 @@ export default {
unknown: '不明',
printAnyway: '仍要列印',
},
slicerAmsMapping: {
rowBadge: '已儲存來自切片軟體的 AMS 槽位',
rowTooltip: '此封存保留了切片/傳送時切片軟體選擇的確切 AMS 槽位。重新列印會重複使用該實體線材,而不是依類型/顏色重新猜測。',
},
title: '列印佇列',
subtitle: '排程和管理您的列印任務',
// Print modal
@@ -4607,6 +4613,10 @@ export default {
selectPrinter: '選擇印表機',
selectPlate: '選擇板',
filamentMapping: '耗材對應',
useArchiveMapping: '對應',
useArchiveMappingTooltip: '從此封存儲存的 AMS 對應(來自切片軟體)中選取所有槽位,而不是依類型/顏色比對。',
clickToChangeSlot: '點擊更改槽位分配',
reRead: '重新讀取',
plateN: '板 {{n}}',
plateFilamentsUnreadable: '無法讀取所選盤的耗材資訊,因此無法進行對應。取消選取該盤即可將其餘盤加入佇列。',
totalCost: '總成本:',
@@ -5129,6 +5139,10 @@ export default {
title: '強制顏色匹配',
description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
},
saveAmsMapping: {
title: '儲存 AMS 對應',
description: '將切片軟體自行選擇的 AMS 槽位(來自 project_file MQTT 指令)儲存到封存中,讓之後的重新列印能重複使用同一捲實體線材,而不是依檔案的類型/顏色重新推導。預設關閉。',
},
gcodeInjection: {
title: 'G-code 注入',
description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。',
+11
View File
@@ -56,6 +56,7 @@ import {
Cog,
Archive as ArchiveIcon,
History,
CheckCircle2,
} from 'lucide-react';
import { api } from '../api/client';
import { SliceModal } from '../components/SliceModal';
@@ -1109,6 +1110,16 @@ function ArchiveCard({
)}
</div>
{/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting) —
a reprint reuses this exact physical spool instead of re-deriving
one from the file's type/color. */}
{Array.isArray((archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping) && (
<div className="flex items-center gap-1.5 text-bambu-green text-xs mb-3" title={t('archives.card.slicerAmsMappingTooltip')}>
<CheckCircle2 className="w-3.5 h-3.5" />
{t('archives.card.slicerAmsMapping')}
</div>
)}
{/* Tags & Notes */}
{(archive.tags || archive.notes) && (
<div className="flex flex-wrap items-center gap-1.5 mb-3">
+13
View File
@@ -731,6 +731,19 @@ function SortableQueueItem({
</p>
)}
{/* Archive carries the slicer's own live-resolved AMS-slot pick
(extra_data.slicer_ams_mapping) — reprints of this archive reuse
the exact physical spool instead of re-deriving one. */}
{item.archive_has_slicer_ams_mapping && (
<p
className="text-[10px] sm:text-xs text-green-700 dark:text-green-400 mt-1.5 sm:mt-2 flex items-start gap-1"
title={t('queue.slicerAmsMapping.rowTooltip')}
>
<Check className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{t('queue.slicerAmsMapping.rowBadge')}</span>
</p>
)}
{/* Error message */}
{item.error_message && (
<p className="text-[10px] sm:text-xs text-red-700 dark:text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">