mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
fix(print-modal): map each plate on its own, and show the panel that does it (#2551)
Selecting several plates hid the filament mapping panel but did not stop the modal sending a mapping. With no single plate selected it fell back to the whole file's filament list -- the union of every plate -- and matched against that. Tray assignment is stateful, so where plate 1 prints red on slot 1 and plate 2 prints red on slot 2, slot 1 claimed the only red spool and slot 2 fell through to a type-only match on black. That one mapping went out with every plate, and the scheduler uses a stored mapping verbatim, so plate 2 printed in the wrong colour -- decided by a panel the user never saw. Fetch each selected plate's requirements and map them separately: one panel per plate, named after it, with its own tray overrides, and each queue item carries its own plate's mapping. A fan-out across several printers would be a panel per plate per printer, so those items carry no mapping and the scheduler maps each plate against the printer it picks. Model mode is unchanged -- no printer means no trays to map onto. The tray matcher existed twice and this needed a third caller, so extract it once and have both existing paths delegate; its 62 tests pass unchanged. The bug only reproduces with a realistic query cache -- the shared test harness sets gcTime: 0, which evicts the union and makes the modal look innocent -- so the new modal tests bring their own client.
This commit is contained in:
@@ -23,6 +23,7 @@ export function FilamentMapping({
|
||||
defaultExpanded = false,
|
||||
forceColorMatch,
|
||||
onForceColorMatchChange,
|
||||
plateLabel,
|
||||
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -192,7 +193,7 @@ export function FilamentMapping({
|
||||
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>{t('printModal.filamentMapping')}</span>
|
||||
<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 ? (
|
||||
|
||||
@@ -9,7 +9,12 @@ import { Card, CardContent } from '../Card';
|
||||
import { Button } from '../Button';
|
||||
import { ConfirmModal } from '../ConfirmModal';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { buildLoadedFilaments, useFilamentMapping } from '../../hooks/useFilamentMapping';
|
||||
import {
|
||||
buildAmsMapping,
|
||||
buildFilamentComparison,
|
||||
buildLoadedFilaments,
|
||||
useFilamentMapping,
|
||||
} from '../../hooks/useFilamentMapping';
|
||||
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
import { getCurrencySymbol } from '../../utils/currency';
|
||||
@@ -24,6 +29,7 @@ import { PrintOptionsPanel } from './PrintOptions';
|
||||
import { ScheduleOptionsPanel } from './ScheduleOptions';
|
||||
import type {
|
||||
AssignmentMode,
|
||||
FilamentReqsData,
|
||||
PrintModalProps,
|
||||
PrintOptions,
|
||||
ScheduleOptions,
|
||||
@@ -415,6 +421,81 @@ export function PrintModal({
|
||||
effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined,
|
||||
);
|
||||
|
||||
// --- Per-plate filament mapping (multi-plate submissions) ---------------
|
||||
// Each plate prints its own subset of the file's slots and needs its own AMS
|
||||
// mapping. `effectiveFilamentReqs` above is keyed on `selectedPlate`, which is
|
||||
// null the moment two plates are picked, so it holds the union of every plate's
|
||||
// filaments — matching against that union lets two plates that share a colour
|
||||
// on different slots compete for the same tray, and sends the loser to a worse
|
||||
// tray or to none. So when several plates are selected we fetch each plate's
|
||||
// requirements and map them separately (#2551 follow-up).
|
||||
const selectedPlateIds = useMemo(() => [...selectedPlates].sort((a, b) => a - b), [selectedPlates]);
|
||||
const isMultiPlateSelection = selectedPlates.size > 1;
|
||||
|
||||
const perPlateReqQueries = useQueries({
|
||||
queries: (isMultiPlateSelection ? selectedPlateIds : []).map((plateId) => ({
|
||||
queryKey: isLibraryFile
|
||||
? ['library-file-filaments', libraryFileId, plateId]
|
||||
: ['archive-filaments', archiveId, plateId],
|
||||
queryFn: () =>
|
||||
isLibraryFile
|
||||
? api.getLibraryFileFilamentRequirements(libraryFileId!, plateId)
|
||||
: api.getArchiveFilamentRequirements(archiveId!, plateId),
|
||||
enabled: isLibraryFile ? !!libraryFileId : !!archiveId,
|
||||
})),
|
||||
});
|
||||
|
||||
const perPlateReqs = useMemo(() => {
|
||||
const byPlate = new Map<number, FilamentReqsData>();
|
||||
selectedPlateIds.forEach((plateId, i) => {
|
||||
const data = perPlateReqQueries[i]?.data;
|
||||
if (data) byPlate.set(plateId, data);
|
||||
});
|
||||
return byPlate;
|
||||
// perPlateReqQueries is a fresh array each render; its data identity is what matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedPlateIds, ...perPlateReqQueries.map((q) => q.data)]);
|
||||
|
||||
// Manual slot overrides are per plate: slot 3 of plate 1 and slot 3 of plate 2
|
||||
// are different prints and may want different trays.
|
||||
const [manualMappingsByPlate, setManualMappingsByPlate] = useState<Record<number, Record<number, number>>>({});
|
||||
|
||||
// Only ever computed for a single target printer: a tray id means nothing on a
|
||||
// different printer, so a fan-out across printers must not reuse these.
|
||||
const perPlateAmsMappings = useMemo(() => {
|
||||
const byPlate = new Map<number, number[] | undefined>();
|
||||
if (!isMultiPlateSelection || !effectivePrinterId || selectedPrinters.length !== 1) return byPlate;
|
||||
|
||||
const loaded = buildLoadedFilaments(printerStatus);
|
||||
const ftsActive = printerStatus?.fila_switch?.installed === true;
|
||||
const inventoryByTrayId = inventoryByTrayIdPerPrinter.get(effectivePrinterId);
|
||||
|
||||
for (const plateId of selectedPlateIds) {
|
||||
const reqs = perPlateReqs.get(plateId);
|
||||
if (!reqs) continue;
|
||||
const comparison = buildFilamentComparison(
|
||||
reqs,
|
||||
loaded,
|
||||
manualMappingsByPlate[plateId] ?? {},
|
||||
singlePrinterPreferLowest,
|
||||
inventoryByTrayId,
|
||||
ftsActive,
|
||||
);
|
||||
byPlate.set(plateId, buildAmsMapping(comparison));
|
||||
}
|
||||
return byPlate;
|
||||
}, [
|
||||
isMultiPlateSelection,
|
||||
effectivePrinterId,
|
||||
printerStatus,
|
||||
inventoryByTrayIdPerPrinter,
|
||||
selectedPlateIds,
|
||||
perPlateReqs,
|
||||
manualMappingsByPlate,
|
||||
singlePrinterPreferLowest,
|
||||
selectedPrinters.length,
|
||||
]);
|
||||
|
||||
// Multi-printer filament mapping (for per-printer configuration)
|
||||
const multiPrinterMapping = useMultiPrinterFilamentMapping(
|
||||
selectedPrinters,
|
||||
@@ -660,8 +741,21 @@ export function PrintModal({
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Get mapping for a specific printer (per-printer override or default)
|
||||
const getMappingForPrinter = (printerId: number): number[] | undefined => {
|
||||
// Get mapping for a specific printer (per-printer override or default).
|
||||
// A multi-plate submission maps each plate on its own — `amsMapping` and the
|
||||
// per-printer mappings are both derived from the union of every selected
|
||||
// plate's filaments, which is not this plate's print (#2551 follow-up).
|
||||
// Without a per-plate mapping we send none at all and let the scheduler
|
||||
// compute one at dispatch, which it already does per plate; a union mapping
|
||||
// would be used verbatim and could feed a slot from the wrong tray.
|
||||
const getMappingForPrinter = (printerId: number, plateId: number | null): number[] | undefined => {
|
||||
if (isMultiPlateSelection) {
|
||||
// Fanning several plates across several printers would be a mapping per
|
||||
// plate *per printer*; those items go out without one and the scheduler
|
||||
// maps each plate against the printer it actually picks.
|
||||
if (plateId === null || selectedPrinters.length !== 1) return undefined;
|
||||
return perPlateAmsMappings.get(plateId);
|
||||
}
|
||||
// For multi-printer selection, check if this printer has an override
|
||||
if (selectedPrinters.length > 1) {
|
||||
const printerConfig = perPrinterConfigs[printerId];
|
||||
@@ -747,7 +841,9 @@ export function PrintModal({
|
||||
};
|
||||
|
||||
// Common queue data for create and edit modes
|
||||
const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({
|
||||
const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
|
||||
const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
|
||||
return {
|
||||
printer_id: assignmentMode === 'printer' ? printerId : null,
|
||||
target_model: assignmentMode === 'model' ? targetModel : null,
|
||||
target_location: assignmentMode === 'model' ? targetLocation : null,
|
||||
@@ -763,8 +859,8 @@ export function PrintModal({
|
||||
// persist that acknowledgement so the scheduler doesn't immediately
|
||||
// re-flag the item on its first dispatch tick (#1698-followup).
|
||||
skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
|
||||
ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
|
||||
plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,
|
||||
ams_mapping: printerId ? getMappingForPrinter(printerId, plateId) : undefined,
|
||||
plate_id: plateId,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
: undefined,
|
||||
@@ -772,7 +868,8 @@ export function PrintModal({
|
||||
project_id: projectId ?? undefined,
|
||||
batch_id: autoBatchId ?? undefined,
|
||||
cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Model-based assignment
|
||||
if (assignmentMode === 'model') {
|
||||
@@ -840,7 +937,7 @@ export function PrintModal({
|
||||
try {
|
||||
if (isEditing && progressCounter === 1) {
|
||||
// Edit mode - update the original queue item for the first entry
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
const printerMapping = getMappingForPrinter(printerId, plateId);
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: printerId,
|
||||
target_model: null,
|
||||
@@ -991,6 +1088,13 @@ export function PrintModal({
|
||||
isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
|
||||
);
|
||||
|
||||
// Several plates on one printer: one mapping panel per plate, each mapping only
|
||||
// the slots its own plate prints. Multi-printer fan-out would be a panel per
|
||||
// plate *per printer*, so those items ship without a mapping and the scheduler
|
||||
// computes one per plate when it picks the printer.
|
||||
const showPerPlateFilamentMapping =
|
||||
!!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
|
||||
|
||||
// Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
|
||||
// Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
|
||||
// the toggle without a specific printer selected. For printer-mode we rely
|
||||
@@ -1165,6 +1269,33 @@ export function PrintModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filament mapping, one panel per selected plate — each plate is its
|
||||
own print with its own slots, so it gets its own AMS mapping. */}
|
||||
{showPerPlateFilamentMapping && !archiveDataMissing && selectedPlateIds.map((plateId) => {
|
||||
const plate = plates.find((p) => p.index === plateId);
|
||||
const plateReqs = perPlateReqs.get(plateId);
|
||||
if (!plateReqs) return null;
|
||||
return (
|
||||
<FilamentMapping
|
||||
key={plateId}
|
||||
printerId={effectivePrinterId!}
|
||||
plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
|
||||
filamentReqs={plateReqs}
|
||||
manualMappings={manualMappingsByPlate[plateId] ?? {}}
|
||||
onManualMappingChange={(mappings) =>
|
||||
setManualMappingsByPlate((prev) => ({ ...prev, [plateId]: mappings }))
|
||||
}
|
||||
defaultExpanded={false}
|
||||
currencySymbol={currencySymbol}
|
||||
defaultCostPerKg={defaultCostPerKg}
|
||||
forceColorMatch={forceColorMatch}
|
||||
onForceColorMatchChange={(slotId, value) =>
|
||||
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Print options */}
|
||||
{(mode === 'create' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
|
||||
<PrintOptionsPanel
|
||||
|
||||
@@ -218,6 +218,10 @@ export interface FilamentMappingProps {
|
||||
forceColorMatch?: Record<number, boolean>;
|
||||
/** Called when a slot's force-color-match checkbox is toggled. */
|
||||
onForceColorMatchChange?: (slotId: number, value: boolean) => void;
|
||||
/** Names the plate this panel maps, when one panel is rendered per selected
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user