mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +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:
@@ -7,10 +7,16 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { render } from '../utils';
|
||||
import { PrintModal } from '../../components/PrintModal';
|
||||
import { AuthProvider } from '../../contexts/AuthContext';
|
||||
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||
import { ToastProvider } from '../../contexts/ToastContext';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
import type { PrintQueueItem } from '../../api/client';
|
||||
@@ -1553,3 +1559,176 @@ describe('PrintModal', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
// These tests deliberately do NOT use the shared `render` from '../utils': its
|
||||
// QueryClient sets `gcTime: 0`, which evicts a query the instant it loses its
|
||||
// observer. The whole-file filament requirements are fetched on open and then
|
||||
// orphaned when a plate is auto-selected, so under gcTime:0 they vanish and the
|
||||
// modal simply sends no mapping — the bug cannot reproduce. A real browser keeps
|
||||
// that entry for 5 minutes and hands the union straight back when the user picks
|
||||
// a second plate, which is what got mapped onto every plate. Same providers,
|
||||
// production cache behaviour.
|
||||
const render = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return rtlRender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>{ui}</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
// Two plates, each printing one red object, but on different slots of the same
|
||||
// file. The printer has exactly one red spool (tray 0) and one black (tray 1).
|
||||
// Matching the union of both plates makes slot 1 claim the red tray and drops
|
||||
// slot 2 onto the black one — so plate 2 would print in the wrong colour.
|
||||
const PLATES = {
|
||||
is_multi_plate: true,
|
||||
plates: [
|
||||
{ index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
|
||||
{ index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const SLOT_1_RED = { slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 };
|
||||
const SLOT_2_RED = { slot_id: 2, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
|
||||
http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)),
|
||||
// The endpoint is plate-aware: no plate_id yields the whole-file union.
|
||||
http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
|
||||
const plateId = new URL(request.url).searchParams.get('plate_id');
|
||||
if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] });
|
||||
if (plateId === '2') return HttpResponse.json({ filaments: [SLOT_2_RED] });
|
||||
return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
|
||||
}),
|
||||
http.get('/api/v1/printers/:id/status', () =>
|
||||
HttpResponse.json({
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
ams: [{ id: 0, tray: [
|
||||
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
|
||||
{ id: 1, tray_type: 'PLA', tray_color: '000000' },
|
||||
] }],
|
||||
vt_tray: [],
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/printers/:id/assignments', () => HttpResponse.json([])),
|
||||
http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })),
|
||||
);
|
||||
});
|
||||
|
||||
const selectBothPlates = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('X1 Carbon'));
|
||||
await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('Plate 2')); // Plate 1 is auto-selected
|
||||
};
|
||||
|
||||
it('shows one mapping panel per selected plate, named after the plate', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
|
||||
|
||||
await selectBothPlates(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends each plate the mapping for its own slots, not the whole-file union', async () => {
|
||||
const queued: { plate_id: number; ams_mapping: number[] | null }[] = [];
|
||||
server.use(
|
||||
http.post('/api/v1/queue/', async ({ request }) => {
|
||||
queued.push((await request.json()) as { plate_id: number; ams_mapping: number[] | null });
|
||||
return HttpResponse.json({ id: queued.length, status: 'pending' });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
|
||||
|
||||
await selectBothPlates(user);
|
||||
await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument());
|
||||
|
||||
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(queued.length).toBe(2));
|
||||
|
||||
const plate1 = queued.find((q) => q.plate_id === 1);
|
||||
const plate2 = queued.find((q) => q.plate_id === 2);
|
||||
// Plate 1 prints slot 1 from the red tray.
|
||||
expect(plate1?.ams_mapping).toEqual([0]);
|
||||
// Plate 2 prints slot 2 from the SAME red tray — slot 1 is not its business.
|
||||
// The union mapping would have been [0, 1], sending this plate to the black tray.
|
||||
expect(plate2?.ams_mapping).toEqual([-1, 0]);
|
||||
});
|
||||
|
||||
it('sends no mapping when several plates fan out across several printers', async () => {
|
||||
// A tray id is meaningless on a different printer, so the first printer's
|
||||
// mapping must not be handed to the second. The scheduler maps each plate
|
||||
// against the printer it actually dispatches to.
|
||||
const queued: { printer_id: number; ams_mapping?: number[] | null }[] = [];
|
||||
server.use(
|
||||
http.post('/api/v1/queue/', async ({ request }) => {
|
||||
queued.push((await request.json()) as { printer_id: number; ams_mapping?: number[] | null });
|
||||
return HttpResponse.json({ id: queued.length, status: 'pending' });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('X1 Carbon'));
|
||||
await user.click(screen.getByText('P1S'));
|
||||
await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('Plate 2'));
|
||||
|
||||
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(queued.length).toBe(4)); // 2 plates x 2 printers
|
||||
queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull());
|
||||
});
|
||||
|
||||
it('sends no mapping for a model-assigned multi-plate job, leaving it to the scheduler', async () => {
|
||||
const queued: { ams_mapping?: number[] | null }[] = [];
|
||||
server.use(
|
||||
http.post('/api/v1/queue/', async ({ request }) => {
|
||||
queued.push((await request.json()) as { ams_mapping?: number[] | null });
|
||||
return HttpResponse.json({ id: queued.length, status: 'pending' });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
|
||||
|
||||
// "Any model" mode: no printer is chosen, so there is no AMS to map onto and
|
||||
// the scheduler computes the mapping per plate once it picks one.
|
||||
await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('Plate 2'));
|
||||
await user.click(screen.getByRole('button', { name: /any model/i }));
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'X1C');
|
||||
|
||||
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(queued.length).toBe(2));
|
||||
queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildAmsMapping,
|
||||
buildFilamentComparison,
|
||||
buildLoadedFilaments,
|
||||
computeAmsMapping,
|
||||
} from '../../hooks/useFilamentMapping';
|
||||
@@ -1191,3 +1193,53 @@ describe('effectivePreferLowest gate (#1766)', () => {
|
||||
expect(result).toEqual([0]); // First match wins; the 5%-remain spool is NOT selected.
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-plate mapping vs. the whole-file union (#2551 follow-up)', () => {
|
||||
// The multi-plate case that made this necessary: two plates, each printing one
|
||||
// red object, but on different slots of the same file. The printer has exactly
|
||||
// one red spool.
|
||||
const PLATE_1 = { filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }] };
|
||||
const PLATE_2 = { filaments: [{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }] };
|
||||
const WHOLE_FILE_UNION = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
|
||||
{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const printer = createPrinterStatus([
|
||||
{
|
||||
id: 0,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // the only red — global tray 0
|
||||
{ id: 1, tray_type: 'PLA', tray_color: '000000' }, // black — global tray 1
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const mapPlate = (reqs: { filaments: { slot_id: number; type: string; color: string; used_grams: number }[] }) =>
|
||||
buildAmsMapping(buildFilamentComparison(reqs, buildLoadedFilaments(printer), {}));
|
||||
|
||||
it('maps each plate to the red tray, because each plate is its own print', () => {
|
||||
expect(mapPlate(PLATE_1)).toEqual([0]);
|
||||
// Slot 1 is unused by this plate, so it stays -1; slot 2 takes the red tray.
|
||||
expect(mapPlate(PLATE_2)).toEqual([-1, 0]);
|
||||
});
|
||||
|
||||
it('the union starves the second slot — which is what a shared mapping sent', () => {
|
||||
// Tray assignment is stateful: slot 1 claims the red tray, so slot 2 falls
|
||||
// through to a type-only match on the BLACK tray. Sending this to plate 2
|
||||
// prints it in black. This is the mapping the modal used to post for every
|
||||
// plate of a multi-plate submission.
|
||||
expect(mapPlate(WHOLE_FILE_UNION)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('a manual override on one plate does not leak into another', () => {
|
||||
const loaded = buildLoadedFilaments(printer);
|
||||
// The user pins plate 2's slot 2 to the black tray by hand.
|
||||
const plate2 = buildAmsMapping(buildFilamentComparison(PLATE_2, loaded, { 2: 1 }));
|
||||
expect(plate2).toEqual([-1, 1]);
|
||||
// Plate 1 is mapped from its own (empty) override set and still gets red.
|
||||
expect(buildAmsMapping(buildFilamentComparison(PLATE_1, loaded, {}))).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,8 +106,6 @@ export function computeAmsMapping(
|
||||
preferLowest?: boolean,
|
||||
inventoryByTrayId?: Map<number, number>,
|
||||
): number[] | undefined {
|
||||
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
|
||||
|
||||
const loadedFilaments = buildLoadedFilaments(printerStatus);
|
||||
if (loadedFilaments.length === 0) return undefined;
|
||||
|
||||
@@ -115,18 +113,164 @@ export function computeAmsMapping(
|
||||
// doesn't apply when it's installed (#1162).
|
||||
const ftsActive = printerStatus?.fila_switch?.installed === true;
|
||||
|
||||
// Track which trays have been assigned to avoid duplicates
|
||||
const usedTrayIds = new Set<number>();
|
||||
// No manual overrides on this path — it maps a printer the user is not looking
|
||||
// at (per-printer fan-out), so there is no panel to override a slot in.
|
||||
return buildAmsMapping(
|
||||
buildFilamentComparison(filamentReqs, loadedFilaments, {}, preferLowest, inventoryByTrayId, ftsActive),
|
||||
);
|
||||
}
|
||||
|
||||
const comparisons = filamentReqs.filaments.map((req) => {
|
||||
/**
|
||||
* Represents a loaded filament in the printer's AMS/HT/External spool holder.
|
||||
*/
|
||||
export interface LoadedFilament {
|
||||
type: string;
|
||||
color: string;
|
||||
colorName: string;
|
||||
amsId: number;
|
||||
trayId: number;
|
||||
isHt: boolean;
|
||||
isExternal: boolean;
|
||||
label: string;
|
||||
globalTrayId: number;
|
||||
/** Unique spool identifier (e.g., "GFA00", "P4d64437") */
|
||||
trayInfoIdx?: string;
|
||||
/** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
|
||||
traySubBrands?: string;
|
||||
/** Extruder ID for dual-nozzle printers (0=right, 1=left) */
|
||||
extruderId?: number;
|
||||
/** Remaining filament percentage (0-100), -1 = unknown */
|
||||
remain: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a required filament from the 3MF file.
|
||||
*/
|
||||
export interface FilamentRequirement {
|
||||
slot_id: number;
|
||||
type: string;
|
||||
color: string;
|
||||
used_grams: number;
|
||||
/** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
|
||||
tray_info_idx?: string;
|
||||
/** Target nozzle for dual-nozzle printers (0=right, 1=left) */
|
||||
nozzle_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of filament comparison between required and loaded.
|
||||
*/
|
||||
export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
|
||||
|
||||
/**
|
||||
* Result of comparing a required filament with loaded filaments.
|
||||
*/
|
||||
export interface FilamentComparison extends FilamentRequirement {
|
||||
loaded: LoadedFilament | undefined;
|
||||
hasFilament: boolean;
|
||||
typeMatch: boolean;
|
||||
colorMatch: boolean;
|
||||
status: FilamentStatus;
|
||||
isManual: boolean;
|
||||
}
|
||||
|
||||
export interface FilamentRequirementsResponse {
|
||||
filaments: FilamentRequirement[];
|
||||
}
|
||||
|
||||
interface UseFilamentMappingResult {
|
||||
/** List of all filaments loaded in the printer */
|
||||
loadedFilaments: LoadedFilament[];
|
||||
/** Comparison results for each required filament */
|
||||
filamentComparison: FilamentComparison[];
|
||||
/** AMS mapping array for the print command */
|
||||
amsMapping: number[] | undefined;
|
||||
/** Whether any required filament type is not loaded */
|
||||
hasTypeMismatch: boolean;
|
||||
/** Whether any required filament has a color mismatch */
|
||||
hasColorMismatch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to build loaded filaments list from printer status.
|
||||
* Extracts filaments from all AMS units (regular and HT) and external spool.
|
||||
*/
|
||||
export function useLoadedFilaments(
|
||||
printerStatus: PrinterStatus | undefined
|
||||
): LoadedFilament[] {
|
||||
return useMemo(() => {
|
||||
return buildLoadedFilaments(printerStatus);
|
||||
}, [printerStatus]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare required filaments with loaded filaments (non-hook version).
|
||||
*
|
||||
* Tray assignment is stateful across the list — a tray matched to one slot is
|
||||
* not offered to the next — so this must be run over exactly the slots of one
|
||||
* print, never a union of several plates: two plates that share a colour on
|
||||
* different slots would otherwise compete for the same tray and one of them
|
||||
* would fall through to a worse match, or to none (#2551 follow-up).
|
||||
*/
|
||||
export function buildFilamentComparison(
|
||||
filamentReqs: FilamentRequirementsResponse | undefined,
|
||||
loadedFilaments: LoadedFilament[],
|
||||
manualMappings: Record<number, number>,
|
||||
preferLowest?: boolean,
|
||||
inventoryByTrayId?: Map<number, number>,
|
||||
ftsActive = false,
|
||||
): FilamentComparison[] {
|
||||
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
|
||||
|
||||
// Track which trays have been assigned to avoid duplicates
|
||||
// First, mark all manually assigned trays as used
|
||||
const usedTrayIds = new Set<number>(Object.values(manualMappings));
|
||||
|
||||
return filamentReqs.filaments.map((req) => {
|
||||
const slotId = req.slot_id || 0;
|
||||
|
||||
// Check if there's a manual override for this slot
|
||||
if (slotId > 0 && manualMappings[slotId] !== undefined) {
|
||||
const manualTrayId = manualMappings[slotId];
|
||||
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
|
||||
|
||||
if (manualLoaded) {
|
||||
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const colorMatch =
|
||||
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
|
||||
colorsAreSimilar(manualLoaded.color, req.color);
|
||||
|
||||
let status: FilamentStatus;
|
||||
if (typeMatch && colorMatch) {
|
||||
status = 'match';
|
||||
} else if (typeMatch) {
|
||||
status = 'type_only';
|
||||
} else {
|
||||
status = 'mismatch';
|
||||
}
|
||||
|
||||
return {
|
||||
...req,
|
||||
loaded: manualLoaded,
|
||||
hasFilament: true,
|
||||
typeMatch,
|
||||
colorMatch,
|
||||
status,
|
||||
isManual: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-match: Find a loaded filament
|
||||
// Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
|
||||
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
|
||||
const reqTrayInfoIdx = req.tray_info_idx || '';
|
||||
|
||||
// Get available trays (not already used)
|
||||
let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
|
||||
|
||||
// Nozzle-aware filtering: restrict to trays on the correct nozzle.
|
||||
// This is a hard filter — cross-nozzle assignment causes print failures
|
||||
// ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
|
||||
// This is a hard filter — cross-nozzle assignment causes print failures.
|
||||
// Skip when an FTS is installed: it can route any slot to either extruder.
|
||||
if (req.nozzle_id != null && !ftsActive) {
|
||||
available = available.filter((f) => f.extruderId === req.nozzle_id);
|
||||
@@ -212,112 +356,55 @@ export function computeAmsMapping(
|
||||
usedTrayIds.add(loaded.globalTrayId);
|
||||
}
|
||||
|
||||
const hasFilament = !!loaded;
|
||||
const typeMatch = hasFilament;
|
||||
// idxMatch is always considered a color match (same spool = same color)
|
||||
const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
|
||||
|
||||
// Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
|
||||
let status: FilamentStatus;
|
||||
if (idxMatch || exactMatch || similarMatch) {
|
||||
status = 'match';
|
||||
} else if (typeOnlyMatch) {
|
||||
status = 'type_only';
|
||||
} else {
|
||||
status = 'mismatch';
|
||||
}
|
||||
|
||||
return {
|
||||
slot_id: req.slot_id,
|
||||
globalTrayId: loaded?.globalTrayId ?? -1,
|
||||
...req,
|
||||
loaded,
|
||||
hasFilament,
|
||||
typeMatch,
|
||||
colorMatch,
|
||||
status,
|
||||
isManual: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Find the max slot_id to determine array size
|
||||
const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
|
||||
/**
|
||||
* Build the AMS mapping array the print command carries (non-hook version).
|
||||
* Position = slot_id - 1 (0-indexed), value = global tray ID, or -1 for a slot
|
||||
* with no matching tray. Indexed by the 3MF's own slot ids, which are global to
|
||||
* the file, so a plate that only prints slot 3 still emits `[-1, -1, tray]`.
|
||||
*/
|
||||
export function buildAmsMapping(filamentComparison: FilamentComparison[]): number[] | undefined {
|
||||
if (filamentComparison.length === 0) return undefined;
|
||||
|
||||
const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
|
||||
if (maxSlotId <= 0) return undefined;
|
||||
|
||||
// Create array with -1 for all positions
|
||||
const mapping = new Array(maxSlotId).fill(-1);
|
||||
|
||||
// Fill in tray IDs at correct positions (slot_id - 1)
|
||||
comparisons.forEach((f) => {
|
||||
filamentComparison.forEach((f) => {
|
||||
if (f.slot_id && f.slot_id > 0) {
|
||||
mapping[f.slot_id - 1] = f.globalTrayId;
|
||||
mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a loaded filament in the printer's AMS/HT/External spool holder.
|
||||
*/
|
||||
export interface LoadedFilament {
|
||||
type: string;
|
||||
color: string;
|
||||
colorName: string;
|
||||
amsId: number;
|
||||
trayId: number;
|
||||
isHt: boolean;
|
||||
isExternal: boolean;
|
||||
label: string;
|
||||
globalTrayId: number;
|
||||
/** Unique spool identifier (e.g., "GFA00", "P4d64437") */
|
||||
trayInfoIdx?: string;
|
||||
/** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
|
||||
traySubBrands?: string;
|
||||
/** Extruder ID for dual-nozzle printers (0=right, 1=left) */
|
||||
extruderId?: number;
|
||||
/** Remaining filament percentage (0-100), -1 = unknown */
|
||||
remain: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a required filament from the 3MF file.
|
||||
*/
|
||||
export interface FilamentRequirement {
|
||||
slot_id: number;
|
||||
type: string;
|
||||
color: string;
|
||||
used_grams: number;
|
||||
/** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
|
||||
tray_info_idx?: string;
|
||||
/** Target nozzle for dual-nozzle printers (0=right, 1=left) */
|
||||
nozzle_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of filament comparison between required and loaded.
|
||||
*/
|
||||
export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
|
||||
|
||||
/**
|
||||
* Result of comparing a required filament with loaded filaments.
|
||||
*/
|
||||
export interface FilamentComparison extends FilamentRequirement {
|
||||
loaded: LoadedFilament | undefined;
|
||||
hasFilament: boolean;
|
||||
typeMatch: boolean;
|
||||
colorMatch: boolean;
|
||||
status: FilamentStatus;
|
||||
isManual: boolean;
|
||||
}
|
||||
|
||||
interface FilamentRequirementsResponse {
|
||||
filaments: FilamentRequirement[];
|
||||
}
|
||||
|
||||
interface UseFilamentMappingResult {
|
||||
/** List of all filaments loaded in the printer */
|
||||
loadedFilaments: LoadedFilament[];
|
||||
/** Comparison results for each required filament */
|
||||
filamentComparison: FilamentComparison[];
|
||||
/** AMS mapping array for the print command */
|
||||
amsMapping: number[] | undefined;
|
||||
/** Whether any required filament type is not loaded */
|
||||
hasTypeMismatch: boolean;
|
||||
/** Whether any required filament has a color mismatch */
|
||||
hasColorMismatch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to build loaded filaments list from printer status.
|
||||
* Extracts filaments from all AMS units (regular and HT) and external spool.
|
||||
*/
|
||||
export function useLoadedFilaments(
|
||||
printerStatus: PrinterStatus | undefined
|
||||
): LoadedFilament[] {
|
||||
return useMemo(() => {
|
||||
return buildLoadedFilaments(printerStatus);
|
||||
}, [printerStatus]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to compare required filaments with loaded filaments and build AMS mapping.
|
||||
* Handles both auto-matching and manual overrides.
|
||||
@@ -339,192 +426,20 @@ export function useFilamentMapping(
|
||||
// doesn't apply when it's installed (#1162).
|
||||
const ftsActive = printerStatus?.fila_switch?.installed === true;
|
||||
|
||||
const filamentComparison = useMemo(() => {
|
||||
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
|
||||
const filamentComparison = useMemo(
|
||||
() =>
|
||||
buildFilamentComparison(
|
||||
filamentReqs,
|
||||
loadedFilaments,
|
||||
manualMappings,
|
||||
preferLowest,
|
||||
inventoryByTrayId,
|
||||
ftsActive,
|
||||
),
|
||||
[filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId],
|
||||
);
|
||||
|
||||
// Track which trays have been assigned to avoid duplicates
|
||||
// First, mark all manually assigned trays as used
|
||||
const usedTrayIds = new Set<number>(Object.values(manualMappings));
|
||||
|
||||
return filamentReqs.filaments.map((req) => {
|
||||
const slotId = req.slot_id || 0;
|
||||
|
||||
// Check if there's a manual override for this slot
|
||||
if (slotId > 0 && manualMappings[slotId] !== undefined) {
|
||||
const manualTrayId = manualMappings[slotId];
|
||||
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
|
||||
|
||||
if (manualLoaded) {
|
||||
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const colorMatch =
|
||||
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
|
||||
colorsAreSimilar(manualLoaded.color, req.color);
|
||||
|
||||
let status: FilamentStatus;
|
||||
if (typeMatch && colorMatch) {
|
||||
status = 'match';
|
||||
} else if (typeMatch) {
|
||||
status = 'type_only';
|
||||
} else {
|
||||
status = 'mismatch';
|
||||
}
|
||||
|
||||
return {
|
||||
...req,
|
||||
loaded: manualLoaded,
|
||||
hasFilament: true,
|
||||
typeMatch,
|
||||
colorMatch,
|
||||
status,
|
||||
isManual: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-match: Find a loaded filament
|
||||
// Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
|
||||
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
|
||||
const reqTrayInfoIdx = req.tray_info_idx || '';
|
||||
|
||||
// Get available trays (not already used)
|
||||
let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
|
||||
|
||||
// Nozzle-aware filtering: restrict to trays on the correct nozzle.
|
||||
// This is a hard filter — cross-nozzle assignment causes print failures.
|
||||
// Skip when an FTS is installed: it can route any slot to either extruder.
|
||||
if (req.nozzle_id != null && !ftsActive) {
|
||||
available = available.filter((f) => f.extruderId === req.nozzle_id);
|
||||
}
|
||||
|
||||
// Sort lowest-first when the preference is on. Inventory-tracked spools
|
||||
// sort before MQTT-only ones; see preferLowestSortKey for the rationale.
|
||||
if (preferLowest) {
|
||||
available = [...available].sort((a, b) =>
|
||||
compareSortKeys(
|
||||
preferLowestSortKey(a, inventoryByTrayId),
|
||||
preferLowestSortKey(b, inventoryByTrayId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let idxMatch: LoadedFilament | undefined;
|
||||
let exactMatch: LoadedFilament | undefined;
|
||||
let similarMatch: LoadedFilament | undefined;
|
||||
let typeOnlyMatch: LoadedFilament | undefined;
|
||||
|
||||
// Check if tray_info_idx is unique among available trays
|
||||
if (reqTrayInfoIdx) {
|
||||
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
|
||||
if (idxMatches.length === 1) {
|
||||
// Unique tray_info_idx - use it as definitive match
|
||||
idxMatch = idxMatches[0];
|
||||
} else if (idxMatches.length > 1) {
|
||||
// Multiple trays with same tray_info_idx - use color matching among them
|
||||
if (preferLowest) {
|
||||
idxMatches.sort((a, b) =>
|
||||
compareSortKeys(
|
||||
preferLowestSortKey(a, inventoryByTrayId),
|
||||
preferLowestSortKey(b, inventoryByTrayId),
|
||||
),
|
||||
);
|
||||
}
|
||||
exactMatch = idxMatches.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
if (!exactMatch) {
|
||||
similarMatch = idxMatches.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
colorsAreSimilar(f.color, req.color)
|
||||
);
|
||||
}
|
||||
if (!exactMatch && !similarMatch) {
|
||||
typeOnlyMatch = idxMatches.find(
|
||||
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no idx match, do standard type/color matching on all available trays
|
||||
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
|
||||
exactMatch = available.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
if (!exactMatch) {
|
||||
similarMatch = available.find(
|
||||
(f) =>
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
colorsAreSimilar(f.color, req.color)
|
||||
);
|
||||
}
|
||||
if (!exactMatch && !similarMatch) {
|
||||
typeOnlyMatch = available.find(
|
||||
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
|
||||
|
||||
// Mark this tray as used so it won't be assigned to another slot
|
||||
if (loaded) {
|
||||
usedTrayIds.add(loaded.globalTrayId);
|
||||
}
|
||||
|
||||
const hasFilament = !!loaded;
|
||||
const typeMatch = hasFilament;
|
||||
// idxMatch is always considered a color match (same spool = same color)
|
||||
const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
|
||||
|
||||
// Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
|
||||
let status: FilamentStatus;
|
||||
if (idxMatch || exactMatch || similarMatch) {
|
||||
status = 'match';
|
||||
} else if (typeOnlyMatch) {
|
||||
status = 'type_only';
|
||||
} else {
|
||||
status = 'mismatch';
|
||||
}
|
||||
|
||||
return {
|
||||
...req,
|
||||
loaded,
|
||||
hasFilament,
|
||||
typeMatch,
|
||||
colorMatch,
|
||||
status,
|
||||
isManual: false,
|
||||
};
|
||||
});
|
||||
}, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId]);
|
||||
|
||||
// Build AMS mapping from matched filaments
|
||||
// Format: array matching 3MF filament slot structure
|
||||
// Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
|
||||
const amsMapping = useMemo(() => {
|
||||
if (filamentComparison.length === 0) return undefined;
|
||||
|
||||
// Find the max slot_id to determine array size
|
||||
const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
|
||||
if (maxSlotId <= 0) return undefined;
|
||||
|
||||
// Create array with -1 for all positions
|
||||
const mapping = new Array(maxSlotId).fill(-1);
|
||||
|
||||
// Fill in tray IDs at correct positions (slot_id - 1)
|
||||
filamentComparison.forEach((f) => {
|
||||
if (f.slot_id && f.slot_id > 0) {
|
||||
mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}, [filamentComparison]);
|
||||
const amsMapping = useMemo(() => buildAmsMapping(filamentComparison), [filamentComparison]);
|
||||
|
||||
const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
|
||||
const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
|
||||
|
||||
@@ -4562,6 +4562,7 @@ export default {
|
||||
selectPrinter: 'Drucker auswählen',
|
||||
selectPlate: 'Platte auswählen',
|
||||
filamentMapping: 'Filamentzuordnung',
|
||||
plateN: 'Platte {{n}}',
|
||||
totalCost: 'Gesamtkosten:',
|
||||
slotRemainingShort: ' - {{grams}}g übrig',
|
||||
printSettings: 'Druckeinstellungen',
|
||||
|
||||
@@ -4605,6 +4605,7 @@ export default {
|
||||
selectPrinter: 'Select Printer',
|
||||
selectPlate: 'Select Plate',
|
||||
filamentMapping: 'Filament Mapping',
|
||||
plateN: 'Plate {{n}}',
|
||||
totalCost: 'Total cost:',
|
||||
slotRemainingShort: ' - {{grams}}g left',
|
||||
printSettings: 'Print Settings',
|
||||
|
||||
@@ -4570,6 +4570,7 @@ export default {
|
||||
selectPrinter: 'Seleccionar impresora',
|
||||
selectPlate: 'Seleccionar cama',
|
||||
filamentMapping: 'Mapeo de filamentos',
|
||||
plateN: 'Cama {{n}}',
|
||||
totalCost: 'Coste total:',
|
||||
slotRemainingShort: ' - quedan {{grams}} g',
|
||||
printSettings: 'Ajustes de impresión',
|
||||
|
||||
@@ -4551,6 +4551,7 @@ export default {
|
||||
selectPrinter: 'Choisir l\'imprimante',
|
||||
selectPlate: 'Choisir le plateau',
|
||||
filamentMapping: 'Mapping Filament',
|
||||
plateN: 'Plateau {{n}}',
|
||||
totalCost: 'Coût total :',
|
||||
slotRemainingShort: ' - {{grams}}g rest.',
|
||||
printSettings: 'Réglages d\'impression',
|
||||
|
||||
@@ -4550,6 +4550,7 @@ export default {
|
||||
selectPrinter: 'Seleziona stampante',
|
||||
selectPlate: 'Seleziona piatto',
|
||||
filamentMapping: 'Mappatura filamento',
|
||||
plateN: 'Piatto {{n}}',
|
||||
totalCost: 'Costo totale:',
|
||||
slotRemainingShort: ' - {{grams}}g rim.',
|
||||
printSettings: 'Impostazioni stampa',
|
||||
|
||||
@@ -4562,6 +4562,7 @@ export default {
|
||||
selectPrinter: 'プリンターを選択',
|
||||
selectPlate: 'プレートを選択',
|
||||
filamentMapping: 'フィラメントマッピング',
|
||||
plateN: 'プレート {{n}}',
|
||||
totalCost: '合計コスト:',
|
||||
slotRemainingShort: ' - 残{{grams}}g',
|
||||
printSettings: '印刷設定',
|
||||
|
||||
@@ -4334,6 +4334,7 @@ export default {
|
||||
selectPrinter: '프린터 선택',
|
||||
selectPlate: '플레이트 선택',
|
||||
filamentMapping: '필라멘트 매핑',
|
||||
plateN: '플레이트 {{n}}',
|
||||
totalCost: '총 비용:',
|
||||
slotRemainingShort: ' - {{grams}}g 남음',
|
||||
printSettings: '인쇄 설정',
|
||||
|
||||
@@ -4550,6 +4550,7 @@ export default {
|
||||
selectPrinter: 'Selecionar Impressora',
|
||||
selectPlate: 'Selecionar Placa',
|
||||
filamentMapping: 'Mapeamento de Filamento',
|
||||
plateN: 'Placa {{n}}',
|
||||
totalCost: 'Custo total:',
|
||||
slotRemainingShort: ' - {{grams}}g rest.',
|
||||
printSettings: 'Configurações de Impressão',
|
||||
|
||||
@@ -4540,6 +4540,7 @@ export default {
|
||||
selectPrinter: 'Yazıcı Seç',
|
||||
selectPlate: 'Plaka Seç',
|
||||
filamentMapping: 'Filament Eşlemesi',
|
||||
plateN: 'Plaka {{n}}',
|
||||
totalCost: 'Toplam maliyet:',
|
||||
slotRemainingShort: ' - {{grams}}g kaldı',
|
||||
printSettings: 'Baskı Ayarları',
|
||||
|
||||
@@ -4550,6 +4550,7 @@ export default {
|
||||
selectPrinter: '选择打印机',
|
||||
selectPlate: '选择板',
|
||||
filamentMapping: '耗材映射',
|
||||
plateN: '板 {{n}}',
|
||||
totalCost: '总成本:',
|
||||
slotRemainingShort: ' - 剩余 {{grams}}g',
|
||||
printSettings: '打印设置',
|
||||
|
||||
@@ -4550,6 +4550,7 @@ export default {
|
||||
selectPrinter: '選擇印表機',
|
||||
selectPlate: '選擇板',
|
||||
filamentMapping: '耗材對應',
|
||||
plateN: '板 {{n}}',
|
||||
totalCost: '總成本:',
|
||||
slotRemainingShort: ' - 剩餘 {{grams}}g',
|
||||
printSettings: '列印設定',
|
||||
|
||||
Reference in New Issue
Block a user