Sfoglia il codice sorgente

fix(print-modal): give each plate its own Filament Override, and stop
queueing plates we cannot map (#2552)

The override panel disappeared for a multi-plate selection in Any [model]
mode, but only once the dialog had been opened before -- which the reporter
saw as "after the file was queued or printed". The filament requirements are
keyed on the selected plate, which is null as soon as two plates are ticked.
On a cold cache the modal cannot yet tell the file is multi-plate and fetches
the whole file's requirements for one render; the panel rendered from that
union. On a warm cache it knows from the first render, the whole-file fetch
never runs, and the panel had nothing to render. Visibility was decided by a
cache race, and the "working" case listed filaments from plates the user had
not selected.

Model mode now renders one panel per selected plate from that plate's own
requirements, and each queued plate carries only the overrides for the slots
it prints, so a colour forced on one plate no longer blocks another.

Reviewing the per-plate machinery turned up four more holes, all closed here:
a manual tray pick survived a change of printer, and a global tray id names a
different spool on a different machine; a plate whose filaments could not be
read was indistinguishable from one needing none and was queued with neither
mapping nor forced colours, so Print now waits for every selected plate to
answer and names the one it cannot read; the insufficient-filament check still
weighed the whole file against a mapping the plates no longer use, and now
follows what each plate dispatches, summing demand per tray; and the
per-printer tray editor no longer appears for a multi-plate fan-out, where its
choices were collected and then discarded.

maziggy 1 mese fa
parent
commit
095d63b24a

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 256 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1731,4 +1731,260 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
     await waitFor(() => expect(queued.length).toBe(2));
     queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull());
   });
+
+  it('drops a plate\'s manual tray overrides when the printer changes', async () => {
+    // A manual override holds a global tray id, which names a different spool on a
+    // different printer. Carrying plate 1's "slot 1 -> tray 1" from the X1C over to
+    // the P1S would dispatch a tray the user never picked.
+    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 1/)).toBeInTheDocument());
+
+    // Force plate 1's slot 1 onto the black tray (1) instead of the auto-matched red (0).
+    await user.click(screen.getByText(/Filament Mapping — Plate 1/));
+    const traySelects = await waitFor(() => {
+      const found = screen.getAllByRole('combobox').filter((el) =>
+        Array.from((el as HTMLSelectElement).options).some((o) => o.value === '1'),
+      );
+      if (found.length === 0) throw new Error('no tray select rendered');
+      return found;
+    });
+    await user.selectOptions(traySelects[0], '1');
+
+    // Now move the job to the other printer.
+    await user.click(screen.getByText('X1 Carbon')); // deselect
+    await user.click(screen.getByText('P1S'));
+
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+    await waitFor(() => expect(queued.length).toBe(2));
+
+    // Plate 1 auto-matches red (tray 0) on the new printer. Tray 1 is the stale pick.
+    const plate1 = queued.find((q) => q.plate_id === 1);
+    expect(plate1?.ams_mapping).toEqual([0]);
+  });
+
+  it('sums what the plates draw from one spool before warning about it', async () => {
+    // Both plates map to the same red tray. 60 g left covers either plate on its
+    // own (40 g), but not both — the check has to add them up, not test them
+    // one at a time.
+    server.use(
+      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, used_grams: 40 }] });
+        if (plateId === '2') return HttpResponse.json({ filaments: [{ ...SLOT_2_RED, used_grams: 40 }] });
+        return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
+      }),
+      http.get('/api/v1/inventory/assignments', () =>
+        HttpResponse.json([
+          {
+            id: 1, spool_id: 1, printer_id: 1, printer_name: 'X1 Carbon', ams_id: 0, tray_id: 0,
+            fingerprint_color: null, fingerprint_type: null, configured: true,
+            spool: { id: 1, label_weight: 1000, weight_used: 940 },
+          },
+        ]),
+      ),
+    );
+
+    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);
+
+    // 80 g needed from a spool with 60 g left → the acknowledgement dialog, not a
+    // silent queue.
+    await waitFor(() => expect(screen.getByText(/Not enough filament/i)).toBeInTheDocument());
+    expect(screen.getByText(/needs 80g, remaining 60g/i)).toBeInTheDocument();
+  });
+
+  it('holds the Print button until every selected plate has answered', async () => {
+    // A plate whose filaments cannot be read has no mapping and no forced colours;
+    // queueing it anyway prints it in whatever happens to be loaded.
+    server.use(
+      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 new HttpResponse(null, { status: 500 });
+        return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
+
+    await selectBothPlates(user);
+
+    await waitFor(() =>
+      expect(document.querySelector('button[type="submit"]')).toBeDisabled(),
+    );
+    expect(screen.getByText(/could not be read/i)).toBeInTheDocument();
+
+    // Deselecting the unreadable plate frees the rest of the job.
+    await user.click(screen.getByText('Plate 2'));
+    await waitFor(() =>
+      expect(document.querySelector('button[type="submit"]')).not.toBeDisabled(),
+    );
+  });
+});
+
+describe('PrintModal — per-plate filament override in model mode (#2552)', () => {
+  const mockOnClose = vi.fn();
+
+  // The reporter's trigger — "only after the file was previously queued or printed"
+  // — is really "only once the plates query is warm in the cache". On a cold cache
+  // the plates data is undefined for the first render, so the whole-file filament
+  // requirements are fetched and left behind; the override panel then rendered from
+  // that union. On a warm cache the modal knows it is multi-plate from the first
+  // render, the whole-file query never runs, and the panel disappeared entirely.
+  // Both states are exercised here, and neither may depend on the cache.
+  const renderWithCache = (ui: React.ReactElement, prewarm?: (qc: QueryClient) => void) => {
+    const queryClient = new QueryClient({
+      defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+    });
+    prewarm?.(queryClient);
+    return rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>{ui}</ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>,
+    );
+  };
+
+  // Plate 1 prints slot 1 (red), plate 2 prints slot 2 (blue). Nothing is shared.
+  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: '#0000FF' }], 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_BLUE = { slot_id: 2, type: 'PLA', color: '#0000FF', 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)),
+      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_BLUE] });
+        return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_BLUE] });
+      }),
+      http.get('/api/v1/printers/available-filaments', () =>
+        HttpResponse.json([
+          { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+          { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+        ]),
+      ),
+      http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })),
+    );
+  });
+
+  const selectAnyX1CAndBothPlates = async (user: ReturnType<typeof userEvent.setup>) => {
+    await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
+    await user.click(screen.getByText('Plate 2')); // Plate 1 is auto-selected
+    await user.click(screen.getByRole('button', { name: /any model/i }));
+    // Switching to model mode pre-selects the first model, so the override panels
+    // are already up; the target-model select is the one offering the models.
+    const modelSelect = await waitFor(() => {
+      const select = screen
+        .getAllByRole('combobox')
+        .find((el) => (el as HTMLSelectElement).options[0]?.value === '' && (el as HTMLSelectElement).options[0]?.text === 'Select a model...');
+      if (!select) throw new Error('target model select not rendered');
+      return select as HTMLSelectElement;
+    });
+    await user.selectOptions(modelSelect, 'X1C');
+  };
+
+  it('shows one override panel per selected plate even when the plates query is already cached', async () => {
+    const user = userEvent.setup();
+    renderWithCache(
+      <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
+      // The file has been opened before: the plates are known on the first render,
+      // so the whole-file requirements query never runs. This is the state in which
+      // the override section used to vanish for a multi-plate selection.
+      (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
+    );
+
+    await selectAnyX1CAndBothPlates(user);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument();
+      expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument();
+    });
+  });
+
+  it('shows the same per-plate panels on a cold cache', async () => {
+    const user = userEvent.setup();
+    renderWithCache(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
+
+    await selectAnyX1CAndBothPlates(user);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument();
+      expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument();
+    });
+    // Not the whole-file union in a single unnamed panel.
+    expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
+  });
+
+  it('sends each plate only the overrides for the slots it prints', async () => {
+    type Queued = {
+      plate_id: number;
+      filament_overrides?: Array<{ slot_id: number; force_color_match: boolean }> | null;
+    };
+    const queued: Queued[] = [];
+    server.use(
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    const user = userEvent.setup();
+    renderWithCache(
+      <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
+      (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
+    );
+
+    await selectAnyX1CAndBothPlates(user);
+    await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument());
+
+    // Force the colour on plate 2's only slot. Plate 1 does not print slot 2 and
+    // must not be held back waiting for a blue spool it never uses (#2551).
+    const forceBoxes = screen.getAllByRole('checkbox', { name: /force color match/i });
+    expect(forceBoxes).toHaveLength(2); // one slot per plate
+    await user.click(forceBoxes[1]);
+
+    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);
+    expect(plate1?.filament_overrides ?? null).toBeNull();
+    expect(plate2?.filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 2, force_color_match: true }),
+    ]);
+  });
 });

+ 14 - 2
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -16,6 +16,12 @@ interface FilamentOverrideProps {
   forceColorMatch?: Record<number, boolean>;
   /** Called when a slot's force color match checkbox is toggled. */
   onForceColorMatchChange?: (slotId: number, value: boolean) => void;
+  /** Names the plate these requirements belong to, when one panel is rendered per
+   *  selected plate. Each plate prints its own subset of the file's slots (#2552). */
+  plateLabel?: string;
+  /** Whether to print the explanatory hint. A stack of per-plate panels only needs
+   *  it once, above the first one. Defaults to true. */
+  showHint?: boolean;
 }
 
 /**
@@ -30,6 +36,8 @@ export function FilamentOverride({
   onChange,
   forceColorMatch,
   onForceColorMatchChange,
+  plateLabel,
+  showHint = true,
 }: FilamentOverrideProps) {
   const { t } = useTranslation();
 
@@ -71,9 +79,13 @@ export function FilamentOverride({
   return (
     <div className="mb-4">
       <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
-        <span>{t('printModal.filamentOverride')}</span>
+        <span>
+          {plateLabel
+            ? `${t('printModal.filamentOverride')} — ${plateLabel}`
+            : t('printModal.filamentOverride')}
+        </span>
       </div>
-      <p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>
+      {showHint && <p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>}
       <div className="bg-bambu-dark rounded-lg p-3 space-y-2">
         {filaments.map((req, slotIdx) => {
           const override = overrides[req.slot_id];

+ 169 - 56
frontend/src/components/PrintModal/index.tsx

@@ -442,9 +442,22 @@ export function PrintModal({
           ? api.getLibraryFileFilamentRequirements(libraryFileId!, plateId)
           : api.getArchiveFilamentRequirements(archiveId!, plateId),
       enabled: isLibraryFile ? !!libraryFileId : !!archiveId,
+      // Same policy as the single-plate query above: these keys are shared, and a
+      // retrying observer would leave the plate looking merely slow for seconds.
+      retry: false,
     })),
   });
 
+  // A plate that has not answered yet and a plate whose 3MF cannot be read look
+  // identical from here — both are simply absent from `perPlateReqs`. Neither may
+  // be treated as "this plate needs no filament": that would queue it with no
+  // mapping and no force-colour overrides, and it would print in whatever happens
+  // to be loaded. Both states gate submission instead (see `canSubmit`).
+  // `isPending` is "no data yet", not "a request is in flight" — a background
+  // refetch of a plate we already have must not disable the button under the user.
+  const perPlateReqsPending = perPlateReqQueries.some((q) => q.isPending);
+  const perPlateReqsFailed = perPlateReqQueries.some((q) => q.isError);
+
   const perPlateReqs = useMemo(() => {
     const byPlate = new Map<number, FilamentReqsData>();
     selectedPlateIds.forEach((plateId, i) => {
@@ -452,9 +465,11 @@ export function PrintModal({
       if (data) byPlate.set(plateId, data);
     });
     return byPlate;
-    // perPlateReqQueries is a fresh array each render; its data identity is what matters.
+    // Keyed on each query's last update stamp, not on the query objects (fresh every
+    // render) and not on a spread of their data (a dep array whose *length* changes
+    // with the plate count, which React treats as always-changed and warns about).
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [selectedPlateIds, ...perPlateReqQueries.map((q) => q.data)]);
+  }, [selectedPlateIds, perPlateReqQueries.map((q) => q.dataUpdatedAt).join('|')]);
 
   // 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.
@@ -525,18 +540,22 @@ export function PrintModal({
     }
   }, [mode, printers, selectedPrinters.length]);
 
-  // Clear manual mappings and per-printer configs when printer or plate changes
+  // Clear manual mappings and per-printer configs when printer or plate changes.
+  // The per-plate mappings go with them: a manual override holds a global tray id,
+  // which names a different spool on a different printer.
   useEffect(() => {
     if (mode === 'edit-queue-item') {
       // For edit mode, clear mappings if printer selection or plate changed from initial
       const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
       if (printersChanged || selectedPlate !== initialPlateId) {
         setManualMappings({});
+        setManualMappingsByPlate({});
         setPerPrinterConfigs({});
         setInitialExpandApplied(new Set());
       }
     } else {
       setManualMappings({});
+      setManualMappingsByPlate({});
       setPerPrinterConfigs({});
       setInitialExpandApplied(new Set());
     }
@@ -650,6 +669,31 @@ export function PrintModal({
     },
   });
 
+  // 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];
+      if (printerConfig && !printerConfig.useDefault) {
+        return multiPrinterMapping.getFinalMapping(printerId);
+      }
+    }
+    return amsMapping;
+  };
+
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
 
@@ -660,9 +704,17 @@ export function PrintModal({
       assignmentMode === 'printer'
     ) {
       const warningItems: FilamentWarningItem[] = [];
-      const filamentReqs = effectiveFilamentReqs?.filaments ?? [];
 
-      if (filamentReqs.length > 0 && spoolAssignmentsByPrinter.size > 0) {
+      // The spool check follows what is actually dispatched: one job per selected
+      // plate, each with the mapping that plate's queue item carries. Two plates
+      // can also draw on the same spool, so the demand is summed per tray before
+      // it is weighed against what is left on it — 60 g left does not cover two
+      // plates of 40 g, even though it covers either one of them (#2551).
+      const plateJobs = isMultiPlateSelection
+        ? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
+        : [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
+
+      if (plateJobs.some((job) => job.reqs.length > 0) && spoolAssignmentsByPrinter.size > 0) {
         const getRemainingWeight = (labelWeight: number, weightUsed: number) => {
           if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;
           if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;
@@ -670,11 +722,6 @@ export function PrintModal({
         };
 
         for (const printerId of selectedPrinters) {
-          const printerMapping = selectedPrinters.length > 1
-            ? multiPrinterMapping.getFinalMapping(printerId)
-            : amsMapping;
-          if (!printerMapping) continue;
-
           const printerStatusForWarning = selectedPrinters.length > 1
             ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
             : printerStatus;
@@ -686,26 +733,36 @@ export function PrintModal({
 
           if (!assignments) continue;
 
-          filamentReqs.forEach((req) => {
-            if (!req.slot_id || req.slot_id <= 0) return;
-            const globalTrayId = printerMapping[req.slot_id - 1];
-            if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
+          const gramsByTray = new Map<number, number>();
+          for (const job of plateJobs) {
+            // No mapping means the scheduler picks the trays at dispatch, against
+            // an AMS state we cannot see from here — nothing to weigh.
+            const printerMapping = getMappingForPrinter(printerId, job.plateId);
+            if (!printerMapping) continue;
+
+            job.reqs.forEach((req) => {
+              if (!req.slot_id || req.slot_id <= 0) return;
+              const globalTrayId = printerMapping[req.slot_id - 1];
+              if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
+              gramsByTray.set(globalTrayId, (gramsByTray.get(globalTrayId) ?? 0) + req.used_grams);
+            });
+          }
 
-            const assignment = assignments.get(globalTrayId);
-            const spool = assignment?.spool;
-            if (!spool) return;
+          for (const [globalTrayId, requiredGrams] of gramsByTray) {
+            const spool = assignments.get(globalTrayId)?.spool;
+            if (!spool) continue;
 
             const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
-            if (remainingGrams === null) return;
-            if (remainingGrams >= req.used_grams) return;
+            if (remainingGrams === null) continue;
+            if (remainingGrams >= requiredGrams) continue;
 
             warningItems.push({
               printerName,
-              slotLabel: slotLabelByTray.get(globalTrayId) ?? `Slot ${req.slot_id}`,
-              requiredGrams: req.used_grams,
+              slotLabel: slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`,
+              requiredGrams,
               remainingGrams,
             });
-          });
+          }
         }
       }
 
@@ -741,40 +798,15 @@ export function PrintModal({
       errors: [],
     };
 
-    // 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];
-        if (printerConfig && !printerConfig.useDefault) {
-          return multiPrinterMapping.getFinalMapping(printerId);
-        }
-      }
-      return amsMapping;
-    };
-
     // Convert filament overrides from Record to array format for API.
     // Include all slots that either have a user override or have force_color_match enabled
     // (which is the default for model-based assignment).
-    const buildFilamentOverridesArray = () => {
+    const buildFilamentOverridesArray = (reqs: FilamentReqsData | undefined) => {
       const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = [];
 
       // Process all slots from filament requirements (to capture force_color_match defaults)
-      if (effectiveFilamentReqs?.filaments) {
-        for (const req of effectiveFilamentReqs.filaments) {
+      if (reqs?.filaments) {
+        for (const req of reqs.filaments) {
           const userOverride = filamentOverrides[req.slot_id];
           const isForceColor = forceColorMatch[req.slot_id] ?? false;
           const effectiveType = userOverride?.type ?? req.type;
@@ -797,7 +829,18 @@ export function PrintModal({
       return entries.length > 0 ? entries : undefined;
     };
 
-    const filamentOverridesArray = buildFilamentOverridesArray();
+    const filamentOverridesArray = buildFilamentOverridesArray(effectiveFilamentReqs);
+
+    // A plate only carries the slots it prints (#2552). Slot ids are global to the
+    // file, so an override on slot 3 means the same filament in every plate that
+    // uses slot 3 — the per-plate list is a subset of the shared state, not a
+    // rewrite of it. No fallback to the whole-file list: it holds slots this plate
+    // never prints, and submission is gated on every selected plate having answered,
+    // so a plate is never missing here.
+    const overridesForPlate = (plateId: number | null) =>
+      isMultiPlateSelection && plateId !== null
+        ? buildFilamentOverridesArray(perPlateReqs.get(plateId))
+        : filamentOverridesArray;
 
     // Multi-plate auto-batch: when the user adds 2+ plates from one source in
     // a single create submission, pre-create a PrintBatch and pass its
@@ -847,7 +890,7 @@ export function PrintModal({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
       target_location: assignmentMode === 'model' ? targetLocation : null,
-      filament_overrides: assignmentMode === 'model' ? filamentOverridesArray : undefined,
+      filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : undefined,
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -1031,8 +1074,23 @@ export function PrintModal({
     // For multi-plate files, need at least one plate selected
     if (isMultiPlate && selectedPlates.size === 0) return false;
 
+    // Every selected plate has to have answered before we can queue it: a plate
+    // still in flight would be sent with no mapping and no overrides, and one that
+    // failed to load cannot be mapped at all. Deselect the failing plate to queue
+    // the rest — the banner above says which state we are in.
+    if (perPlateReqsPending || perPlateReqsFailed) return false;
+
     return true;
-  }, [selectedPrinters.length, assignmentMode, targetModel, isMultiPlate, selectedPlates.size, isPending]);
+  }, [
+    selectedPrinters.length,
+    assignmentMode,
+    targetModel,
+    isMultiPlate,
+    selectedPlates.size,
+    isPending,
+    perPlateReqsPending,
+    perPlateReqsFailed,
+  ]);
 
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
@@ -1095,6 +1153,13 @@ export function PrintModal({
   const showPerPlateFilamentMapping =
     !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
 
+  // Model mode has no printer and so no trays to map onto; what it offers instead
+  // is the filament each slot must be printed in, which the scheduler matches
+  // against whatever printer of the model it picks. Needs the model's loaded
+  // filaments to offer as alternatives.
+  const showFilamentOverride =
+    assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
+
   // 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
@@ -1199,7 +1264,12 @@ export function PrintModal({
                 showInactive={mode === 'edit-queue-item'}
                 disableBusy={false}
                 printerMappingResults={multiPrinterMapping.printerResults}
-                filamentReqs={effectiveFilamentReqs}
+                // The per-printer tray editor inside the selector maps one filament
+                // list onto each printer. Several plates have several lists, and a
+                // fan-out across printers ships no mapping at all (the scheduler maps
+                // each plate against the printer it picks), so the editor would be
+                // collecting tray choices it then throws away. Withhold its input.
+                filamentReqs={isMultiPlateSelection ? undefined : effectiveFilamentReqs}
                 onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
                 onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
                 assignmentMode={assignmentMode}
@@ -1213,10 +1283,10 @@ export function PrintModal({
             )}
 
             {/* Filament override - shown in model mode when filament requirements are available */}
-            {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
+            {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
               <FilamentOverride
                 filamentReqs={effectiveFilamentReqs}
-                availableFilaments={availableFilaments}
+                availableFilaments={availableFilaments!}
                 overrides={filamentOverrides}
                 onChange={setFilamentOverrides}
                 forceColorMatch={forceColorMatch}
@@ -1226,6 +1296,34 @@ export function PrintModal({
               />
             )}
 
+            {/* Filament override, one panel per selected plate. `effectiveFilamentReqs`
+                is keyed on `selectedPlate`, which is null as soon as two plates are
+                picked, so a multi-plate selection used to render this panel from
+                whatever the whole-file query had left in the cache — the union of every
+                plate's filaments, or nothing at all once the plates query was warm and
+                the whole-file query therefore never ran, which is why the section
+                vanished on the second open of the dialog (#2552). */}
+            {showFilamentOverride && isMultiPlateSelection && selectedPlateIds.map((plateId, idx) => {
+              const plate = plates.find((p) => p.index === plateId);
+              const plateReqs = perPlateReqs.get(plateId);
+              if (!plateReqs) return null;
+              return (
+                <FilamentOverride
+                  key={plateId}
+                  plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
+                  showHint={idx === 0}
+                  filamentReqs={plateReqs}
+                  availableFilaments={availableFilaments!}
+                  overrides={filamentOverrides}
+                  onChange={setFilamentOverrides}
+                  forceColorMatch={forceColorMatch}
+                  onForceColorMatchChange={(slotId, value) =>
+                    setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
+                  }
+                />
+              );
+            })}
+
             {/* Compatibility warning when sliced model doesn't match selected printer */}
             {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
               const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
@@ -1252,6 +1350,21 @@ export function PrintModal({
               </div>
             )}
 
+            {/* A selected plate whose filaments could not be read cannot be mapped and
+                cannot carry its forced colours, so it is not queued silently — say so
+                and hold the button until the plate is deselected. */}
+            {perPlateReqsFailed && (
+              <div className="flex items-start gap-2 p-3 mb-2 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg text-sm">
+                <AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
+                <p className="text-orange-700 dark:text-orange-400">
+                  {t(
+                    'printModal.plateFilamentsUnreadable',
+                    "The filaments of a selected plate could not be read, so it can't be mapped. Deselect it to queue the others.",
+                  )}
+                </p>
+              </div>
+            )}
+
             {/* Filament mapping - only show when single printer selected */}
             {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
               <FilamentMapping

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -4563,6 +4563,7 @@ export default {
     selectPlate: 'Platte auswählen',
     filamentMapping: 'Filamentzuordnung',
     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:',
     slotRemainingShort: ' - {{grams}}g übrig',
     printSettings: 'Druckeinstellungen',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -4606,6 +4606,7 @@ export default {
     selectPlate: 'Select Plate',
     filamentMapping: 'Filament Mapping',
     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:',
     slotRemainingShort: ' - {{grams}}g left',
     printSettings: 'Print Settings',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -4571,6 +4571,7 @@ export default {
     selectPlate: 'Seleccionar cama',
     filamentMapping: 'Mapeo de filamentos',
     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:',
     slotRemainingShort: ' - quedan {{grams}} g',
     printSettings: 'Ajustes de impresión',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -4552,6 +4552,7 @@ export default {
     selectPlate: 'Choisir le plateau',
     filamentMapping: 'Mapping Filament',
     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 :',
     slotRemainingShort: ' - {{grams}}g rest.',
     printSettings: 'Réglages d\'impression',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -4551,6 +4551,7 @@ export default {
     selectPlate: 'Seleziona piatto',
     filamentMapping: 'Mappatura filamento',
     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:',
     slotRemainingShort: ' - {{grams}}g rim.',
     printSettings: 'Impostazioni stampa',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -4563,6 +4563,7 @@ export default {
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',
     plateN: 'プレート {{n}}',
+    plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
     totalCost: '合計コスト:',
     slotRemainingShort: ' - 残{{grams}}g',
     printSettings: '印刷設定',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4335,6 +4335,7 @@ export default {
     selectPlate: '플레이트 선택',
     filamentMapping: '필라멘트 매핑',
     plateN: '플레이트 {{n}}',
+    plateFilamentsUnreadable: '선택한 플레이트의 필라멘트를 읽을 수 없어 매핑할 수 없습니다. 해당 플레이트를 선택 해제하면 나머지를 대기열에 추가할 수 있습니다.',
     totalCost: '총 비용:',
     slotRemainingShort: ' - {{grams}}g 남음',
     printSettings: '인쇄 설정',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4551,6 +4551,7 @@ export default {
     selectPlate: 'Selecionar Placa',
     filamentMapping: 'Mapeamento de Filamento',
     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:',
     slotRemainingShort: ' - {{grams}}g rest.',
     printSettings: 'Configurações de Impressão',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4541,6 +4541,7 @@ export default {
     selectPlate: 'Plaka Seç',
     filamentMapping: 'Filament Eşlemesi',
     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:',
     slotRemainingShort: ' - {{grams}}g kaldı',
     printSettings: 'Baskı Ayarları',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4551,6 +4551,7 @@ export default {
     selectPlate: '选择板',
     filamentMapping: '耗材映射',
     plateN: '板 {{n}}',
+    plateFilamentsUnreadable: '无法读取所选盘的耗材信息,因此无法进行映射。取消选择该盘即可将其余盘加入队列。',
     totalCost: '总成本:',
     slotRemainingShort: ' - 剩余 {{grams}}g',
     printSettings: '打印设置',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4551,6 +4551,7 @@ export default {
     selectPlate: '選擇板',
     filamentMapping: '耗材對應',
     plateN: '板 {{n}}',
+    plateFilamentsUnreadable: '無法讀取所選盤的耗材資訊,因此無法進行對應。取消選取該盤即可將其餘盤加入佇列。',
     totalCost: '總成本:',
     slotRemainingShort: ' - 剩餘 {{grams}}g',
     printSettings: '列印設定',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D3e4re0S.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CCULKpLm.js"></script>
+    <script type="module" crossorigin src="/assets/index-D3e4re0S.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DASc8Ke0.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff