Files
bambuddy/frontend/src/components/PrintModal/FilamentMapping.tsx
T
Sergey Dontsov 13c37ffe51 fix(vp): scope saved AMS mapping to the printer it was resolved against
Round-2 review fixes for #2700.

Blocking: the toggle didn't actually gate the archive write. archive.py's
promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's
request-topic interception captures ams_mapping unconditionally for every
print source (slicer-direct LAN prints included). Since main.py's
real-printer auto-archive path forwards the full MQTT payload as
print_data, every archive on any install — VP or not — grew
extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing
with an explicit `slicer_ams_mapping` param on archive_print() that only
the VP-queue path (already gated on save_ams_mapping) ever passes.

Blocking: a saved mapping could get reused on a printer it was never
resolved against — tray IDs only mean something relative to one printer's
AMS layout. extra_data.slicer_ams_mapping is now stored as
{mapping, printer_id} instead of a bare array:
- add_to_queue's fallback only fires when the reprint's target printer_id
  matches the mapping's origin printer.
- The frontend's archiveAmsMapping only surfaces (and the Mapping button
  only appears) when the print modal's selected printer matches too.
- A model-based VP (target_printer_id=None, no MQTT bridge to any real
  printer) never stamps a mapping in the first place — there's no live AMS
  layout for the slicer to have resolved tray IDs against.

Also from review:
- Multi-plate archives now get the Mapping button too (the per-plate
  FilamentMapping loop was missing archiveAmsMapping entirely).
- Added coverage for the previously-untested late-MQTT archive patch path
  (_restamp_recent_queue_item), including the model-based-VP skip case.
- usingArchiveMapping now also resets on printer change, not just
  plate/archive (it already worked via the printer-scoping above, but is
  now an explicit dependency too).
- The Mapping button's revert (OFF) now undoes only the slots it itself
  set, not every manual pick in scope — matches the comment above it.
- Added a comment on why negative-value slots (external spool) are
  skipped rather than cleared when applying a saved mapping.
2026-07-31 11:08:58 +03:00

428 lines
19 KiB
TypeScript

import { useEffect, useMemo, useRef, 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';
import { api } from '../../api/client';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers';
import { getColorName } from '../../utils/colors';
import { useFilamentLabels } from './useFilamentLabels';
import type { FilamentMappingProps } from './types';
/**
* Filament mapping UI for comparing required filaments with loaded AMS slots.
* Shows auto-matched and manually overridden slot assignments.
*/
export function FilamentMapping({
printerId,
filamentReqs,
manualMappings,
onManualMappingChange,
currencySymbol,
defaultCostPerKg,
defaultExpanded = false,
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);
// Which slot IDs the ON branch below actually wrote into manualMappings —
// so OFF can undo exactly those and leave any *other* manual pick the user
// made by hand (before or after pressing the button) untouched.
const appliedSlotIdsRef = useRef<number[]>([]);
// Reset the toggle whenever the saved mapping it would apply changes — a
// different printer, plate selection, or archive entirely. Without this
// the button can read ON (green) from a previous printer/archive/plate
// even though it was never pressed against the mapping currently in scope.
useEffect(() => {
setUsingArchiveMapping(false);
appliedSlotIdsRef.current = [];
}, [archiveAmsMapping, plateLabel, printerId]);
const toggleArchiveMapping = () => {
if (!archiveAmsMapping || !filamentReqs?.filaments) return;
if (usingArchiveMapping) {
const next = { ...manualMappings };
for (const slotId of appliedSlotIdsRef.current) {
delete next[slotId];
}
onManualMappingChange(next);
appliedSlotIdsRef.current = [];
setUsingArchiveMapping(false);
return;
}
const next = { ...manualMappings };
const appliedSlotIds: number[] = [];
for (const req of filamentReqs.filaments) {
const idx = req.slot_id - 1;
// A negative value (e.g. the external spool sentinel) means the
// slicer didn't resolve this filament to an AMS tray — leave that
// slot's existing auto-match/manual pick alone rather than clearing it.
if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
next[req.slot_id] = archiveAmsMapping[idx];
appliedSlotIds.push(req.slot_id);
}
}
onManualMappingChange(next);
appliedSlotIdsRef.current = appliedSlotIds;
setUsingArchiveMapping(true);
};
// Fetch printer status
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId),
enabled: !!printerId,
});
const { data: assignments } = useQuery({
queryKey: ['spool-assignments', printerId],
queryFn: () => api.getAssignments(printerId),
enabled: !!printerId,
});
// Settings + inventory map drive the same prefer-lowest + AMS-backup gate
// the dispatcher uses (#1766). Without this, the per-slot dropdown's
// auto-suggestion could disagree with what actually gets dispatched.
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const { data: inventoryRemain } = useQuery({
queryKey: ['printer-inventory-remain', printerId],
queryFn: () => api.getInventoryRemain(printerId),
enabled: !!printerId,
staleTime: 30 * 1000,
});
const inventoryByTrayId = useMemo(() => {
if (!inventoryRemain?.inventory_remain_g) return undefined;
const map = new Map<number, number>();
Object.entries(inventoryRemain.inventory_remain_g).forEach(([key, grams]) => {
const gtid = Number(key);
if (!Number.isNaN(gtid)) map.set(gtid, grams);
});
return map;
}, [inventoryRemain]);
const gatedPreferLowest = effectivePreferLowest(
settings?.prefer_lowest_filament,
printerStatus?.ams_filament_backup,
);
const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
useFilamentMapping(filamentReqs, printerStatus, manualMappings, gatedPreferLowest, inventoryByTrayId);
// Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
// shared hook the model-mode FilamentOverride uses so both panels render
// the same sliced-3MF identity. Falls back to the raw type / generic
// colour bucket when the SKU is unknown or the by-material lookup hasn't
// resolved — never blanks out the required row.
const filamentLabels = useFilamentLabels(filamentReqs?.filaments);
const trayCostMap = useMemo(() => {
const map = new Map<number, number | null>();
for (const assignment of assignments || []) {
const isExternal = assignment.ams_id === 255;
const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);
map.set(globalTrayId, assignment.spool?.cost_per_kg ?? null);
}
return map;
}, [assignments]);
const trayRemainingWeightMap = useMemo(() => {
const map = new Map<number, number | null>();
for (const assignment of assignments || []) {
const isExternal = assignment.ams_id === 255;
const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);
const spool = assignment.spool;
if (!spool) {
map.set(globalTrayId, null);
continue;
}
map.set(globalTrayId, Math.max(0, Math.round((spool.label_weight ?? 0) - (spool.weight_used ?? 0))));
}
return map;
}, [assignments]);
const totalCost = useMemo(() => {
let total = 0;
for (const item of filamentComparison) {
const trayId = item.loaded?.globalTrayId;
if (trayId == null) continue;
const assignedCost = trayCostMap.get(trayId) ?? null;
const costPerKg = assignedCost ?? defaultCostPerKg;
if (costPerKg > 0) {
total += (item.used_grams / 1000) * costPerKg;
}
}
return total;
}, [filamentComparison, trayCostMap, defaultCostPerKg]);
const hasAnyCost = useMemo(
() => Array.from(trayCostMap.values()).some((v) => v != null && v > 0),
[trayCostMap]
);
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
// Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic
// (any slot can be routed to either extruder), so the per-nozzle dropdown
// filter is suppressed. fila_switch.in_slots[track] = currently fed slot,
// fila_switch.out_extruders[track] = extruder that track terminates at. See #1162.
const ftsInstalled = printerStatus?.fila_switch?.installed === true;
const ftsExtruderForSlot = (globalTrayId: number): number | null => {
const fs = printerStatus?.fila_switch;
if (!fs?.installed) return null;
const track = fs.in_slots.indexOf(globalTrayId);
if (track < 0) return null;
return fs.out_extruders[track] ?? null;
};
// Don't render if no filament requirements
if (!hasFilamentReqs) {
return null;
}
// Don't render until we have printer status to do the comparison
if (!printerStatus) {
return null;
}
// Determine status indicator color
const statusColor = hasTypeMismatch
? '#f97316' // orange
: hasColorMismatch
? '#facc15' // yellow
: '#00ae42'; // green
const handleSlotChange = (slotId: number, value: string) => {
if (slotId > 0) {
if (value === '') {
// Clear manual override
const next = { ...manualMappings };
delete next[slotId];
onManualMappingChange(next);
} else {
onManualMappingChange({
...manualMappings,
[slotId]: parseInt(value, 10),
});
}
}
};
const handleRefresh = async () => {
setIsRefreshing(true);
try {
// Request fresh data from printer via MQTT pushall command
await api.refreshPrinterStatus(printerId);
// Wait a moment for printer to respond, then refetch
await new Promise((r) => setTimeout(r, 500));
await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
} finally {
setIsRefreshing(false);
}
};
return (
<div className="mb-4">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
>
<Circle className="w-4 h-4" fill={statusColor} stroke="none" />
<span>{plateLabel ? `${t('printModal.filamentMapping')} — ${plateLabel}` : t('printModal.filamentMapping')}</span>
{hasTypeMismatch ? (
<span className="text-xs text-orange-700 dark:text-orange-400">(Type not found)</span>
) : hasColorMismatch ? (
<span className="text-xs text-yellow-700 dark:text-yellow-400">(Color mismatch)</span>
) : (
<span className="text-xs text-bambu-green">(Ready)</span>
)}
{isExpanded ? (
<ChevronUp className="w-4 h-4 ml-auto" />
) : (
<ChevronDown className="w-4 h-4 ml-auto" />
)}
</button>
{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">{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
// that FilamentOverride exposes for model-mode dispatch. The
// scheduler honors the flag in both modes; only the UI was missing.
const slotId = item.slot_id ?? 0;
const canForceMatch = slotId > 0 && onForceColorMatchChange != null;
// #1718: same sub-brand + colour resolution as FilamentOverride.
// Indexing is safe because ``useFilamentLabels`` mirrors the input
// array shape; defensive fallback covers the empty-reqs render
// path that shouldn't reach here anyway.
const { resolvedName, colorLabel } = filamentLabels[idx] ?? { resolvedName: item.type, colorLabel: getColorName(item.color) };
return (
<div key={idx} className="space-y-1">
<div
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
{/* Required color */}
<span title={`Required: ${resolvedName} - ${colorLabel}`}>
<Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
</span>
{/* Required type + grams + nozzle badge. Only the name
truncates; the gram usage is pinned (shrink-0) so it never
clips on narrow/mobile widths (#2669). */}
<span className="text-white flex items-center gap-1 min-w-0">
{isDualNozzle && item.nozzle_id != null && (
<span
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
>
{item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
</span>
)}
<span className="truncate min-w-0" title={resolvedName}>{resolvedName}</span>
<span className="text-bambu-gray shrink-0 whitespace-nowrap">({item.used_grams}g)</span>
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
{/* Slot selector dropdown */}
<select
value={item.loaded?.globalTrayId ?? ''}
onChange={(e) => handleSlotChange(slotId, e.target.value)}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
item.status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: item.status === 'type_only'
? 'border-yellow-500 dark:border-yellow-400/50 text-yellow-700 dark:text-yellow-400'
: 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={item.isManual ? 'Manually selected' : 'Auto-matched'}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- Select slot --
</option>
{/*
#1722: every loaded slot is offered for every filament row,
regardless of which extruder the slot is wired to. Before this
change a slot was only listed when its extruder matched the
filament's slicer-assigned nozzle (item.nozzle_id), which
locked users out of cross-extruder picks even when they'd
intentionally loaded the required filament into the "other"
AMS. The L/R badge on the filament row still tells the user
what the slicer planned; the dropdown now trusts the user to
pick based on their physical setup. Printer firmware accepts
or rejects the ams_mapping at start-print — failure is loud,
not silent.
*/}
{loadedFilaments.map((f) => {
const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
const remainingLabel = remainingWeight != null
? t('printModal.slotRemainingShort', {
grams: remainingWeight,
defaultValue: ` - ${remainingWeight}g left`,
})
: '';
// FTS routing badge: if this slot is currently fed into an FTS
// track, show the destination extruder. Idle (not-loaded) slots
// get no badge — they can be routed to either extruder on demand.
const ftsTargetExtruder = ftsInstalled
? ftsExtruderForSlot(f.globalTrayId)
: null;
const ftsBadge =
ftsTargetExtruder == null
? ''
: ` [${ftsTargetExtruder === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}]`;
return (
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
{f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
</option>
);
})}
</select>
{/* Status icon */}
{item.status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : item.status === 'type_only' ? (
<span title="Same type, different color">
<AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-400" />
</span>
) : (
<span title="Filament type not loaded">
<AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400" />
</span>
)}
</div>
{/* Force Color Match checkbox — matches FilamentOverride's layout. */}
{canForceMatch && (
<label className="inline-flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer select-none pl-5">
<input
type="checkbox"
checked={forceColorMatch?.[slotId] ?? false}
onChange={(e) => onForceColorMatchChange(slotId, e.target.checked)}
className="accent-bambu-green w-3 h-3"
/>
<Palette className="w-3 h-3" />
{t('printModal.forceColorMatch')}
</label>
)}
</div>
);
})}
<div className="text-xs text-bambu-gray">
{t('printModal.totalCost')}{' '}
<span className="text-white">
{totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}
</span>
</div>
{hasTypeMismatch && (
<p className="text-xs text-orange-700 dark:text-orange-400 mt-2">Required filament type not found in printer.</p>
)}
</div>
)}
</div>
);
}