Просмотр исходного кода

fix(queue): keep the filament override when a model job moves to one printer (issue #3133)

Switching an "Any P2S" job to a specific P2S cleared its filament
override: "Specific Printer" empties the target model and the reset
effect counted that as a model change. Printer mode also matched trays
against the 3MF's colours, never sent the override, and left the old one
on the row.

The reset now compares against the last model actually targeted, so the
switch keeps the override while a real model or plate change still clears
it. Printer-mode tray matching (single, per-plate, multi-printer and the
selector's per-printer editor) runs against the requirements with the
overrides applied, mirroring the scheduler's _apply_filament_overrides; an
entry naming the slot's own filament is not a swap and keeps its
tray_info_idx. Printer-mode submits carry the user's overrides, and the
create endpoint stores them for a printer-targeted job, so a dispatch-time
recompute of an unresolved mapping looks for the same filament.

Saving re-attaches the tray_info_idx an unchanged entry already had, so a
virtual printer's force-colour PLA-variant pin (#2650) survives an edit in
either assignment mode. The printer card's compatibility filter skips
printer-targeted jobs: it mirrors the model scheduler, and hiding a job on
filament would hide it from the printer it is going to run on.
maziggy 3 дней назад
Родитель
Сommit
89d94796ee

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 15 - 10
backend/app/api/routes/print_queue.py

@@ -906,29 +906,34 @@ async def add_to_queue(
     # Extract filament types for model-based assignment (used by scheduler for validation)
     required_filament_types = None
     file_path = None
+    # Get file path from archive or library file
+    if archive:
+        file_path = settings.base_dir / archive.file_path
+    elif library_file:
+        lib_path = Path(library_file.file_path)
+        file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
     if target_model_norm:
-        # Get file path from archive or library file
-        if archive:
-            file_path = settings.base_dir / archive.file_path
-        elif library_file:
-            lib_path = Path(library_file.file_path)
-            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
-
         if file_path and file_path.exists():
             filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
             if filament_types:
                 required_filament_types = json.dumps(filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
 
-    # If filament overrides are provided, update required_filament_types to match override types
+    # If filament overrides are provided, update required_filament_types to match override types.
+    # A specific-printer job keeps its overrides too (#3133): an override chosen for
+    # "Any P2S" survives the switch to one P2S in the print dialog, and when the
+    # dialog could not resolve every tray the scheduler recomputes the mapping at
+    # dispatch — against the 3MF's filament, unless the row still says otherwise.
+    # The type list below stays model-only; it gates which printer of a model is
+    # eligible, which a printer-targeted job has already settled.
     filament_overrides_json = None
-    if data.filament_overrides and target_model_norm:
+    if data.filament_overrides and (target_model_norm or data.printer_id is not None):
         plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
         if plate_overrides:
             filament_overrides_json = json.dumps(plate_overrides)
             # Update required_filament_types from overrides so scheduler validates against overridden types
             override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
-            if override_types:
+            if override_types and target_model_norm:
                 # Merge with existing types (overrides may only cover some slots)
                 existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
                 # Replace types for overridden slots, keep others

+ 30 - 0
backend/tests/integration/test_print_queue_api.py

@@ -636,6 +636,36 @@ class TestPrintQueueAPI:
         result = response.json()
         assert result["ams_mapping"] == [5, -1, 2, -1]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_keeps_overrides_on_a_specific_printer_job(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """An override picked for "Any P2S" survives the dialog's switch to one
+        P2S (#3133). The row must keep it: when the dialog could not resolve
+        every tray, the scheduler recomputes the mapping at dispatch, and without
+        the override it would match the 3MF's colour again. It used to be
+        dropped whenever the item had no target model.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filament_overrides"] == [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}]
+        # The type list gates which printer of a model may take the job; a job
+        # for one printer has no such choice left to make.
+        from backend.app.models.print_queue import PrintQueueItem
+
+        row = await db_session.get(PrintQueueItem, result["id"])
+        assert row.required_filament_types is None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(

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

@@ -2272,3 +2272,238 @@ describe('PrintModal — per-plate quantity (#342)', () => {
     expect(queued.every((q) => q.quantity === undefined)).toBe(true);
   });
 });
+
+describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3133)', () => {
+  const mockOnClose = vi.fn();
+
+  const PRINTERS = [
+    { id: 1, name: 'Printer 01', model: 'P2S', ip_address: '192.168.1.101', enabled: true, is_active: true },
+    { id: 2, name: 'Printer 02', model: 'X1C', ip_address: '192.168.1.102', enabled: true, is_active: true },
+  ];
+  const BROWN = '#8B4513';
+  const BONE_WHITE = '#F5F5DC';
+  // The 3MF was sliced in brown; the user asked for Bone White.
+  const SLOT_1_BROWN = { slot_id: 1, type: 'PLA', color: BROWN, tray_info_idx: 'GFA00', used_grams: 50 };
+
+  type Patched = {
+    printer_id?: number | null;
+    target_model?: string | null;
+    ams_mapping?: number[] | null;
+    filament_overrides?: Array<{ slot_id: number; type: string; color: string }> | null;
+  };
+  let patched: Patched[];
+  let posted: Patched[];
+
+  const statusWith = (trays: Array<{ id: number; tray_type: string; tray_color: string }>) =>
+    HttpResponse.json({ connected: true, state: 'IDLE', ams: [{ id: 0, tray: trays }], vt_tray: [] });
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    patched = [];
+    posted = [];
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
+      http.get('/api/v1/archives/:id/plates', () => HttpResponse.json({ is_multi_plate: false, plates: [] })),
+      http.get('/api/v1/archives/:id/filament-requirements', () => HttpResponse.json({ filaments: [SLOT_1_BROWN] })),
+      http.get('/api/v1/printers/available-filaments', () =>
+        HttpResponse.json([
+          { type: 'PLA', color: BROWN, tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+          { type: 'PLA', color: BONE_WHITE, tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+        ]),
+      ),
+      // Printer 01 has both colours loaded; brown is first, so a match against
+      // the 3MF picks tray 0 and a match against the override picks tray 1.
+      http.get('/api/v1/printers/:id/status', () =>
+        statusWith([
+          { id: 0, tray_type: 'PLA', tray_color: '8B4513FF' },
+          { id: 1, tray_type: 'PLA', tray_color: 'F5F5DCFF' },
+        ]),
+      ),
+      http.get('/api/v1/printers/:id/assignments', () => HttpResponse.json([])),
+      http.patch('/api/v1/queue/:id', async ({ request }) => {
+        patched.push((await request.json()) as Patched);
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        posted.push((await request.json()) as Patched);
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+    );
+  });
+
+  const anyP2SItem = () =>
+    createMockQueueItem({
+      printer_id: null,
+      target_model: 'P2S',
+      filament_overrides: [{ slot_id: 1, type: 'PLA', color: BONE_WHITE, force_color_match: false }],
+    } as Partial<PrintQueueItem>);
+
+  const moveToPrinter01 = async (user: ReturnType<typeof userEvent.setup>) => {
+    await user.click(await screen.findByRole('button', { name: /specific printer/i }));
+    await user.click(await screen.findByText('Printer 01'));
+  };
+
+  const submit = async (user: ReturnType<typeof userEvent.setup>) => {
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+  };
+
+  it('matches the chosen printer against the override and keeps it on the item', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
+
+    await moveToPrinter01(user);
+    // Wait for the mapping to settle on the printer's trays before saving.
+    await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].printer_id).toBe(1);
+    expect(patched[0].target_model).toBeNull();
+    // Tray 1 is the Bone White spool. Matching the 3MF would have taken tray 0.
+    expect(patched[0].ams_mapping).toEqual([1]);
+    expect(patched[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, type: 'PLA', color: BONE_WHITE }),
+    ]);
+  });
+
+  it('keeps the requested colour when the printer has no such spool', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () =>
+        statusWith([
+          { id: 0, tray_type: 'PLA', tray_color: '8B4513FF' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000FF' },
+        ]),
+      ),
+    );
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
+
+    await moveToPrinter01(user);
+    await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    // Still asked for, so the scheduler's recompute at dispatch looks for it too
+    // instead of settling on the brown the 3MF was sliced with.
+    expect(patched[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, color: BONE_WHITE }),
+    ]);
+  });
+
+  it('still drops the override when the job moves to a different model', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
+
+    const modelSelect = await waitFor(() => {
+      const select = screen
+        .getAllByRole('combobox')
+        .find((el) => (el as HTMLSelectElement).options[0]?.text === 'Select a model...');
+      if (!select) throw new Error('target model select not rendered');
+      return select as HTMLSelectElement;
+    });
+    // The override was picked from P2S's loaded filaments; X1C's are another list.
+    await user.selectOptions(modelSelect, 'X1C');
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].target_model).toBe('X1C');
+    expect(patched[0].filament_overrides ?? null).toBeNull();
+  });
+
+  it("keeps a virtual printer's variant pin when its specific-printer job is saved", async () => {
+    // A VP with force colour on writes the 3MF's own filament back as an
+    // override, tray_info_idx included, to tell PLA variants apart (#2650). Two
+    // brown trays differ only by variant; the job was sliced for Matte (GFA01).
+    server.use(
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ ...SLOT_1_BROWN, tray_info_idx: 'GFA01' }] }),
+      ),
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({
+          connected: true,
+          state: 'IDLE',
+          ams: [{ id: 0, tray: [
+            { id: 0, tray_type: 'PLA', tray_color: '8B4513FF', tray_info_idx: 'GFA00' },
+            { id: 1, tray_type: 'PLA', tray_color: '8B4513FF', tray_info_idx: 'GFA01' },
+          ] }],
+          vt_tray: [],
+        }),
+      ),
+    );
+    const vpItem = createMockQueueItem({
+      printer_id: 1,
+      target_model: null,
+      filament_overrides: [
+        { slot_id: 1, type: 'PLA', color: BROWN, tray_info_idx: 'GFA01', force_color_match: true },
+      ],
+    } as Partial<PrintQueueItem>);
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={vpItem} onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    // Not treated as a swap: the matcher still pins the Matte tray...
+    expect(patched[0].ams_mapping).toEqual([1]);
+    // ...and the row keeps the pin rather than losing it on save.
+    expect(patched[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, color: BROWN, tray_info_idx: 'GFA01', force_color_match: true }),
+    ]);
+  });
+
+  it("keeps a virtual printer's variant pin when its any-model job is saved", async () => {
+    const vpItem = createMockQueueItem({
+      printer_id: null,
+      target_model: 'P2S',
+      filament_overrides: [
+        { slot_id: 1, type: 'PLA', color: BROWN, tray_info_idx: 'GFA01', force_color_match: true },
+      ],
+    } as Partial<PrintQueueItem>);
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={vpItem} onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText('Filament Override')).toBeInTheDocument());
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].target_model).toBe('P2S');
+    expect(patched[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, tray_info_idx: 'GFA01', force_color_match: true }),
+    ]);
+  });
+
+  it('carries an override picked in model mode into a specific-printer job when creating', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="create" archiveId={1} archiveName="Job" onClose={mockOnClose} />);
+
+    await user.click(await screen.findByRole('button', { name: /any model/i }));
+    const modelSelect = await waitFor(() => {
+      const select = screen
+        .getAllByRole('combobox')
+        .find((el) => (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, 'P2S');
+    const overrideSelect = await waitFor(() => {
+      const select = screen
+        .getAllByRole('combobox')
+        .find((el) => [...(el as HTMLSelectElement).options].some((o) => o.value === `PLA|${BONE_WHITE}`));
+      if (!select) throw new Error('override select not rendered');
+      return select as HTMLSelectElement;
+    });
+    await user.selectOptions(overrideSelect, `PLA|${BONE_WHITE}`);
+
+    await moveToPrinter01(user);
+    await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());
+    await submit(user);
+
+    await waitFor(() => expect(posted).toHaveLength(1));
+    expect(posted[0].printer_id).toBe(1);
+    expect(posted[0].ams_mapping).toEqual([1]);
+    expect(posted[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, type: 'PLA', color: BONE_WHITE }),
+    ]);
+  });
+});

+ 19 - 0
frontend/src/__tests__/utils/printer.test.ts

@@ -176,3 +176,22 @@ describe('filterCompatibleQueueItems — force-color PLA variant (#2650)', () =>
     expect(filterCompatibleQueueItems([noIdxJob], loadedTypes, loaded, variants)).toHaveLength(1);
   });
 });
+
+describe('filterCompatibleQueueItems — printer-targeted jobs (#3133)', () => {
+  // A job moved from "Any P2S" to one P2S keeps its override. The printer is
+  // already chosen, so a colour it has not loaded must not hide the job from
+  // that printer's card — the scheduler maps the trays at dispatch.
+  const boneWhite = [{ slot_id: 1, type: 'PLA', color: '#F5F5DC', force_color_match: false }];
+  const loadedTypes = new Set(['PLA']);
+  const loadedBrown = new Set(['PLA:8b4513']);
+
+  it('keeps a specific-printer job whose override colour is not loaded', () => {
+    const item = { id: 1, printer_id: 3, filament_overrides: boneWhite } as unknown as PrintQueueItem;
+    expect(filterCompatibleQueueItems([item], loadedTypes, loadedBrown)).toHaveLength(1);
+  });
+
+  it('still filters the same job while it targets any printer of a model', () => {
+    const item = { id: 1, printer_id: null, target_model: 'P2S', filament_overrides: boneWhite } as unknown as PrintQueueItem;
+    expect(filterCompatibleQueueItems([item], loadedTypes, loadedBrown)).toHaveLength(0);
+  });
+});

+ 3 - 3
frontend/src/api/client.ts

@@ -2682,7 +2682,7 @@ export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   target_location?: string | null;  // Target location filter (only used with target_model)
-  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
+  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;
   archive_id?: number | null;
   library_file_id?: number | null;
   scheduled_time?: string | null;
@@ -2737,7 +2737,7 @@ export interface QueueVariantCreate {
   plate_id?: number | null;
   ams_mapping?: number[] | null;
   nozzle_mapping?: number[] | null;
-  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
+  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;
 }
 
 export interface PrintBatchCreate {
@@ -2776,7 +2776,7 @@ export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   target_location?: string | null;  // Target location filter (only used with target_model)
-  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
+  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;
   position?: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;

+ 110 - 20
frontend/src/components/PrintModal/index.tsx

@@ -41,6 +41,39 @@ import type {
 } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 
+/** Same filament: type ignoring case, colour as RRGGBB ignoring `#`, case and alpha. */
+function isSameFilament(a: { type: string; color: string }, b: { type: string; color: string }): boolean {
+  const hex = (c: string) => (c || '').replace('#', '').toLowerCase().slice(0, 6);
+  return (a.type || '').toUpperCase() === (b.type || '').toUpperCase() && hex(a.color) === hex(b.color);
+}
+
+/**
+ * The filament list as the tray matcher should see it: each overridden slot
+ * asks for the override's type and colour, not the 3MF's (#3133). Mirrors the
+ * scheduler's `_apply_filament_overrides` for a manual override — the 3MF's
+ * `tray_info_idx` names the replaced spool's SKU, so it is dropped and matching
+ * falls back to type + colour. An entry naming the slot's own filament is no
+ * swap — a virtual printer's force-colour entries are exactly that — so the
+ * slot keeps its idx and with it the PLA-variant pin (#2650). Slots with no
+ * override pass through unchanged.
+ */
+function withFilamentOverrides(
+  reqs: FilamentReqsData | undefined,
+  overrides: Record<number, { type: string; color: string }>,
+): FilamentReqsData | undefined {
+  const isSwap = (f: FilamentReqsData['filaments'][number]) => {
+    const override = overrides[f.slot_id];
+    return !!override && !isSameFilament(override, f);
+  };
+  if (!reqs?.filaments || !reqs.filaments.some(isSwap)) return reqs;
+  return {
+    ...reqs,
+    filaments: reqs.filaments.map((f) =>
+      isSwap(f) ? { ...f, type: overrides[f.slot_id].type, color: overrides[f.slot_id].color, tray_info_idx: '' } : f,
+    ),
+  };
+}
+
 /**
  * Unified PrintModal component that handles queue item creation and editing.
  * - 'create': Create a print queue item from an archive or library file
@@ -460,6 +493,16 @@ export function PrintModal({
   // Combine filament requirements from either source
   const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
 
+  // What the tray matching works from. An override chosen in model mode stays
+  // in force when the job is moved to a specific printer (#3133), so that
+  // printer's trays are matched against the requested filament rather than the
+  // one the 3MF was sliced with. The override panel keeps the original list —
+  // it shows "sliced brown, print Bone White".
+  const mappingFilamentReqs = useMemo(
+    () => withFilamentOverrides(effectiveFilamentReqs, filamentOverrides),
+    [effectiveFilamentReqs, filamentOverrides],
+  );
+
   // Fetch available filaments for model-based assignment (for filament override UI)
   const { data: availableFilaments } = useQuery({
     queryKey: ['available-filaments', targetModel, targetLocation],
@@ -540,7 +583,7 @@ export function PrintModal({
 
   // Get AMS mapping from hook (only when single printer selected)
   const { amsMapping } = useFilamentMapping(
-    effectiveFilamentReqs,
+    mappingFilamentReqs,
     printerStatus,
     manualMappings,
     singlePrinterPreferLowest,
@@ -597,6 +640,16 @@ export function PrintModal({
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [selectedPlateIds, perPlateReqQueries.map((q) => q.dataUpdatedAt).join('|')]);
 
+  // Per-plate twin of `mappingFilamentReqs`: slot ids are global to the file, so
+  // one override applies to every plate that prints that slot (#3133).
+  const mappingPerPlateReqs = useMemo(() => {
+    const byPlate = new Map<number, FilamentReqsData>();
+    for (const [plateId, reqs] of perPlateReqs) {
+      byPlate.set(plateId, withFilamentOverrides(reqs, filamentOverrides) ?? reqs);
+    }
+    return byPlate;
+  }, [perPlateReqs, filamentOverrides]);
+
   // 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>>>({});
@@ -629,7 +682,7 @@ export function PrintModal({
     const inventoryByTrayId = inventoryByTrayIdPerPrinter.get(effectivePrinterId);
 
     for (const plateId of selectedPlateIds) {
-      const reqs = perPlateReqs.get(plateId);
+      const reqs = mappingPerPlateReqs.get(plateId);
       if (!reqs) continue;
       const comparison = buildFilamentComparison(
         reqs,
@@ -648,7 +701,7 @@ export function PrintModal({
     printerStatus,
     inventoryByTrayIdPerPrinter,
     selectedPlateIds,
-    perPlateReqs,
+    mappingPerPlateReqs,
     manualMappingsByPlate,
     singlePrinterPreferLowest,
     selectedPrinters.length,
@@ -658,7 +711,7 @@ export function PrintModal({
   const multiPrinterMapping = useMultiPrinterFilamentMapping(
     selectedPrinters,
     printers,
-    effectiveFilamentReqs,
+    mappingFilamentReqs,
     manualMappings,
     perPrinterConfigs,
     setPerPrinterConfigs,
@@ -716,18 +769,27 @@ export function PrintModal({
     }
   }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
 
-  // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode)
+  // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode).
+  // `prevTargetModel` is the last model actually targeted, so it skips nulls:
+  // "Any P2S" -> "Specific Printer" empties targetModel without naming another
+  // model, and the override is the job's filament, not a tray on some printer —
+  // it survives the switch and is matched against the chosen printer (#3133).
+  // Going P2S -> (none) -> X1C still compares P2S with X1C and clears.
   const [prevTargetModel, setPrevTargetModel] = useState(targetModel);
   const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);
   useEffect(() => {
-    if (targetModel !== prevTargetModel || selectedPlate !== prevPlateForOverrides) {
-      setPrevTargetModel(targetModel);
-      setPrevPlateForOverrides(selectedPlate);
-      // Don't clear on initial render in edit mode (values are initialized from queueItem)
-      if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
-        setFilamentOverrides({});
-        setForceColorMatch({});
-      }
+    const modelChanged = targetModel !== null && targetModel !== prevTargetModel;
+    const plateChanged = selectedPlate !== prevPlateForOverrides;
+    if (!modelChanged && !plateChanged) return;
+    if (modelChanged) setPrevTargetModel(targetModel);
+    if (plateChanged) setPrevPlateForOverrides(selectedPlate);
+    // A first model after none is a choice, not a change: nothing was picked
+    // against another model's filaments. That also covers the initial render in
+    // edit mode, where the values are initialized from queueItem.
+    if (modelChanged && !plateChanged && prevTargetModel === null) return;
+    if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
+      setFilamentOverrides({});
+      setForceColorMatch({});
     }
   }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
 
@@ -1016,8 +1078,22 @@ export function PrintModal({
     // 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).
+    // The dialog keeps an override as type + colour only, so an entry that comes
+    // back unchanged gets the variant id the item already carried re-attached. A
+    // virtual printer writes force-colour entries with the 3MF's tray_info_idx to
+    // tell Basic, Matte and Silk PLA apart (#2650); saving the item — which a
+    // specific-printer edit now does for the overrides too (#3133) — must not
+    // quietly drop that pin. A changed entry is a swap and has no idx to keep.
+    const storedOverrideBySlot = new Map(
+      (mode === 'edit-queue-item' ? queueItem?.filament_overrides ?? [] : []).map((o) => [o.slot_id, o]),
+    );
+    const storedVariantFor = (slotId: number, type: string, color: string) => {
+      const stored = storedOverrideBySlot.get(slotId);
+      return stored?.tray_info_idx && isSameFilament(stored, { type, color }) ? { tray_info_idx: stored.tray_info_idx } : {};
+    };
+
     const buildFilamentOverridesArray = (reqs: FilamentReqsData | undefined) => {
-      const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = [];
+      const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; tray_info_idx?: string; force_color_match: boolean }> = [];
 
       // Process all slots from filament requirements (to capture force_color_match defaults)
       if (reqs?.filaments) {
@@ -1029,7 +1105,7 @@ export function PrintModal({
 
           // Include slot if user changed the filament OR force_color_match is enabled
           if (userOverride || isForceColor) {
-            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), force_color_match: isForceColor });
+            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), ...storedVariantFor(req.slot_id, effectiveType, effectiveColor), force_color_match: isForceColor });
           }
         }
       } else {
@@ -1037,7 +1113,7 @@ export function PrintModal({
         for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {
           const id = parseInt(slotId, 10);
           const isForceColor = forceColorMatch[id] ?? false;
-          entries.push({ slot_id: id, type, color, color_name: getColorName(color), force_color_match: isForceColor });
+          entries.push({ slot_id: id, type, color, color_name: getColorName(color), ...storedVariantFor(id, type, color), force_color_match: isForceColor });
         }
       }
 
@@ -1057,6 +1133,16 @@ export function PrintModal({
         ? buildFilamentOverridesArray(perPlateReqs.get(plateId))
         : filamentOverridesArray;
 
+    // A specific-printer job carries only the slots the user actually changed
+    // (#3133): the tray mapping was matched against them, and they are what the
+    // scheduler needs if it has to recompute that mapping at dispatch. The
+    // force-colour flags on their own stay behind — printer mode never sent
+    // them, and changing that is not what this is for.
+    const printerOverridesForPlate = (plateId: number | null) => {
+      const entries = overridesForPlate(plateId)?.filter((o) => filamentOverrides[o.slot_id]);
+      return entries && entries.length > 0 ? entries : undefined;
+    };
+
     // Cross-model alternatives (#671): ONE item carrying a candidate per file,
     // in the order the user arranged. This returns before the plate/printer
     // fan-out below because it deliberately fans out to nothing — the whole
@@ -1175,7 +1261,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' ? overridesForPlate(plateId) : undefined,
+      filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : printerOverridesForPlate(plateId),
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -1280,6 +1366,10 @@ export function PrintModal({
                 printer_id: printerId,
                 target_model: null,
                 target_location: null,
+                // null, not undefined: omitting the field left a model job's
+                // overrides on the row after it moved to a printer, whatever the
+                // user did with them here (#3133).
+                filament_overrides: printerOverridesForPlate(plateId) ?? null,
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 auto_off_after: scheduleOptions.autoOffAfter,
                 gcode_injection: scheduleOptions.gcodeInjection,
@@ -1667,7 +1757,7 @@ export function PrintModal({
                 // 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}
+                filamentReqs={isMultiPlateSelection ? undefined : mappingFilamentReqs}
                 onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
                 onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
                 assignmentMode={assignmentMode}
@@ -1767,7 +1857,7 @@ export function PrintModal({
             {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
               <FilamentMapping
                 printerId={effectivePrinterId!}
-                filamentReqs={effectiveFilamentReqs}
+                filamentReqs={mappingFilamentReqs}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
                 onEstimatedCostChange={setEstimatedCost}
@@ -1790,7 +1880,7 @@ export function PrintModal({
                 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);
+              const plateReqs = mappingPerPlateReqs.get(plateId);
               if (!plateReqs) return null;
               return (
                 <FilamentMapping

+ 7 - 0
frontend/src/utils/printer.ts

@@ -95,6 +95,13 @@ export function filterCompatibleQueueItems(
   loadedVariants?: Set<string>
 ): PrintQueueItem[] {
   return items.filter(item => {
+    // A job bound to one printer has no printer left to choose, and the backend
+    // never gates it on filament: the scheduler maps its trays at dispatch. Such
+    // a job can carry overrides — one moved from "Any P2S" to a specific P2S
+    // keeps its colour (#3133) — and filtering on them would hide a job that is
+    // going to run from the card of the printer it is going to run on.
+    if (item.printer_id != null) return true;
+
     // Type check: all required filament types must be loaded
     if (item.required_filament_types && item.required_filament_types.length > 0 && loadedFilamentTypes !== undefined) {
       if (!item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()))) {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DZtvSvOz.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-CuU0coVt.js"></script>
+    <script type="module" crossorigin src="/assets/index-DZtvSvOz.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов