mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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のジョブに適用します。デフォルトはオフです。',
|
||||
|
||||
@@ -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 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 для соответствующей модели, заданные в настройках. По умолчанию выключено.",
|
||||
|
||||
@@ -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ı.",
|
||||
|
||||
@@ -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-коду, налаштовані для кожної моделі. Типово вимкнено.",
|
||||
|
||||
@@ -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 的作业。默认关闭。',
|
||||
|
||||
@@ -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 的作業。預設關閉。',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user