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
@@ -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[];
}
/**