Explorar o código

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.
maziggy hai 1 mes
pai
achega
b5da9be794

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
CHANGELOG.md


+ 180 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -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());
+  });
+});

+ 52 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -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]);
+  });
+});

+ 2 - 1
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -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 ? (

+ 139 - 8
frontend/src/components/PrintModal/index.tsx

@@ -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

+ 4 - 0
frontend/src/components/PrintModal/types.ts

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

+ 199 - 284
frontend/src/hooks/useFilamentMapping.ts

@@ -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;
 
+  // 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),
+  );
+}
+
+/**
+ * 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
-  const usedTrayIds = new Set<number>();
+  // 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';
+        }
 
-  const comparisons = filamentReqs.filaments.map((req) => {
+        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 [];
-
-    // 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 filamentComparison = useMemo(
+    () =>
+      buildFilamentComparison(
+        filamentReqs,
+        loadedFilaments,
+        manualMappings,
+        preferLowest,
+        inventoryByTrayId,
+        ftsActive,
+      ),
+    [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId],
+  );
+
+  const amsMapping = useMemo(() => buildAmsMapping(filamentComparison), [filamentComparison]);
 
   const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
   const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');

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

@@ -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',

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

@@ -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',

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

@@ -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',

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

@@ -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',

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

@@ -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',

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

@@ -4562,6 +4562,7 @@ export default {
     selectPrinter: 'プリンターを選択',
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',
+    plateN: 'プレート {{n}}',
     totalCost: '合計コスト:',
     slotRemainingShort: ' - 残{{grams}}g',
     printSettings: '印刷設定',

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

@@ -4334,6 +4334,7 @@ export default {
     selectPrinter: '프린터 선택',
     selectPlate: '플레이트 선택',
     filamentMapping: '필라멘트 매핑',
+    plateN: '플레이트 {{n}}',
     totalCost: '총 비용:',
     slotRemainingShort: ' - {{grams}}g 남음',
     printSettings: '인쇄 설정',

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

@@ -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',

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

@@ -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ı',

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

@@ -4550,6 +4550,7 @@ export default {
     selectPrinter: '选择打印机',
     selectPlate: '选择板',
     filamentMapping: '耗材映射',
+    plateN: '板 {{n}}',
     totalCost: '总成本:',
     slotRemainingShort: ' - 剩余 {{grams}}g',
     printSettings: '打印设置',

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

@@ -4550,6 +4550,7 @@ export default {
     selectPrinter: '選擇印表機',
     selectPlate: '選擇板',
     filamentMapping: '耗材對應',
+    plateN: '板 {{n}}',
     totalCost: '總成本:',
     slotRemainingShort: ' - 剩餘 {{grams}}g',
     printSettings: '列印設定',

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-CCULKpLm.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-xvC2pmGi.js"></script>
+    <script type="module" crossorigin src="/assets/index-CCULKpLm.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DASc8Ke0.css">
   </head>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio