Browse Source

Queue: keep "Force color match" on a job for a specific printer

- With a specific printer picked, the print dialog showed the "Force
  color match" box under each slot but never saved it. Once the saved
  tray no longer fit at dispatch, the scheduler matched again against
  the sliced colour and took any spool of the type ("prefer lowest":
  the emptiest).
- A ticked slot is now saved as a force entry. Set to a tray picked by
  the user (or the one the job was saved with), it asks for that tray's
  filament; on a tray the dialog matched itself it keeps the sliced
  filament, so the job waits for it. A reopened job keeps its forced
  entry while the slot stays on the same tray.
- Several printers selected, or slots left unticked: unchanged.
maziggy 3 days ago
parent
commit
25ee4522b0

+ 1 - 0
CHANGELOG.md

@@ -219,6 +219,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **"Force color match" is kept on a job for a specific printer (#3445, reported by @frnzzle)** — With a specific printer picked, the print dialog showed the "Force color match" checkbox under each slot but never saved it: the job was added or saved with its trays only, and the box came back unticked. Once the tray picked for a slot no longer fit when the job came up, the scheduler matched the trays again against the colour the file was sliced with and, without that colour loaded, took any spool of the type, with "prefer lowest" the emptiest. A part sliced in white and set to black printed in green. A ticked slot is now saved with the filament of the tray picked for it, so the box stays ticked, and matching again takes only that filament or holds the job. Jobs for "Any" model were not affected.
 - **Configure accepts a setting id as the filament (#3444, reported by @Novastorm4332)** — A client of the REST API that configured a slot with a spool's slicer filament as `tray_info_idx` sent a Bambu setting id such as `GFSL99` to the printer, which knows only filament ids (`GFL99`): the slot came back without a colour or type and auto-unlink dropped the spool. Assigning the spool already converted it. Configure now converts it the same way, the setting id staying as the slot's setting id, and does the same for a K profile's filament id. Support and SUNLU ids, which also begin with GFS, are filament ids and stay as they are. Bambuddy's own Configure dialog was not affected.
 - **Configure accepts a setting id as the filament (#3444, reported by @Novastorm4332)** — A client of the REST API that configured a slot with a spool's slicer filament as `tray_info_idx` sent a Bambu setting id such as `GFSL99` to the printer, which knows only filament ids (`GFL99`): the slot came back without a colour or type and auto-unlink dropped the spool. Assigning the spool already converted it. Configure now converts it the same way, the setting id staying as the slot's setting id, and does the same for a K profile's filament id. Support and SUNLU ids, which also begin with GFS, are filament ids and stay as they are. Bambuddy's own Configure dialog was not affected.
 - **A project saved as .gcode.3mf is treated as a project (#3435, reported by @Mounting9612)** — Bambu Studio saves an unsliced project under whatever name it is given, and one called `Foo.gcode.3mf` went into the File Manager as a printable file. Printing it to a cost centre was refused with "Estimated cost is required for cost center prints", which says nothing about the cause; without billing it would have gone to the printer. A `.gcode.3mf` that holds no G-code is now stored as a project and offered for slicing, files already in the library are re-checked once on the update, and the queue refuses such a file with "… isn't sliced yet. Slice it first, then print the sliced file." The File Manager's Add to Queue judges a file by what it holds too, so a sliced plate exported as a plain `.3mf` can be queued from there.
 - **A project saved as .gcode.3mf is treated as a project (#3435, reported by @Mounting9612)** — Bambu Studio saves an unsliced project under whatever name it is given, and one called `Foo.gcode.3mf` went into the File Manager as a printable file. Printing it to a cost centre was refused with "Estimated cost is required for cost center prints", which says nothing about the cause; without billing it would have gone to the printer. A `.gcode.3mf` that holds no G-code is now stored as a project and offered for slicing, files already in the library are re-checked once on the update, and the queue refuses such a file with "… isn't sliced yet. Slice it first, then print the sliced file." The File Manager's Add to Queue judges a file by what it holds too, so a sliced plate exported as a plain `.3mf` can be queued from there.
 - **A spool swapped in mid-print shows on the printer card (#3434, reported by @0xCyberFortress)** — After a runout, a new spool loaded into the same slot and assigned in Bambuddy stayed invisible until the print ended: the printer reports the slot loaded but blank and refuses a filament setting for the slot it is printing from, so the card showed "?" and "No filament assigned". The card now shows the assigned spool on such a slot in its colour, with an amber marker, names it and offers Unassign, saying that the printer has not taken it yet. Bambuddy also stops sending that setting on every AMS change while the slot feeds the print, which the printer refused each time, and sends it once the print is over.
 - **A spool swapped in mid-print shows on the printer card (#3434, reported by @0xCyberFortress)** — After a runout, a new spool loaded into the same slot and assigned in Bambuddy stayed invisible until the print ended: the printer reports the slot loaded but blank and refuses a filament setting for the slot it is printing from, so the card showed "?" and "No filament assigned". The card now shows the assigned spool on such a slot in its colour, with an amber marker, names it and offers Unassign, saying that the printer has not taken it yet. Bambuddy also stops sending that setting on every AMS change while the slot feeds the print, which the printer refused each time, and sends it once the print is over.

+ 84 - 0
backend/tests/unit/test_printer_job_force_color_3445.py

@@ -0,0 +1,84 @@
+"""A specific-printer job's "Force color match" at dispatch (#3445).
+
+The reporter sliced a part in white, queued it for one P1S, picked the black
+tray for the slot and ticked "Force color match". The dialog dropped the tick:
+the job was saved with the tray only. By the next morning that tray no longer
+fit, the scheduler matched the trays again against the sliced white, found
+none, and with "prefer lowest" on took the green spool on type alone.
+
+The dialog now saves the ticked slot with the filament of the tray picked for
+it. These pin what the scheduler does with such an entry when it has to match
+again: the black spool, or nothing at all, never another colour.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+REQUIREMENTS = [{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "tray_info_idx": "GFA01"}]
+
+# What the dialog saves for the ticked slot mapped to the black tray.
+BLACK_FORCED = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01", "force_color_match": True}]
+
+# The reporter's printer 2 at dispatch: tray 1 empty now, green lower than black.
+TRAYS = [
+    {"global_tray_id": 2, "type": "PLA", "color": "000000FF", "tray_info_idx": "GFA01", "remain": 95},
+    {"global_tray_id": 3, "type": "PLA", "color": "68724DFF", "tray_info_idx": "GFA01", "remain": 73},
+]
+
+
+def _item(overrides):
+    item = MagicMock()
+    item.id = 74
+    item.printer_id = 2
+    item.ams_mapping = json.dumps([1])
+    item.skip_filament_check = False
+    item.filament_overrides = json.dumps(overrides) if overrides is not None else None
+    item.required_filament_types = None
+    item.manual_start = False
+    item.filament_short = False
+    item.waiting_reason = None
+    return item
+
+
+def _scheduler(trays):
+    scheduler = PrintScheduler()
+    scheduler._build_loaded_filaments = MagicMock(return_value=[dict(t) for t in trays])
+    scheduler._get_filament_requirements = AsyncMock(return_value=[dict(r) for r in REQUIREMENTS])
+    scheduler._get_bool_setting = AsyncMock(return_value=True)  # prefer lowest
+    scheduler._build_inventory_remain_overrides = AsyncMock(return_value=None)
+    scheduler._get_job_name = AsyncMock(return_value="part")
+    scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P1S"))
+    return scheduler
+
+
+async def _dispatch_mapping(scheduler, item):
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = MagicMock(raw_data={"ams": [{"id": 0}]}, ams_filament_backup=None)
+        await scheduler._ensure_ams_mapping(AsyncMock(), 2, item)
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+    return (json.loads(item.ams_mapping) if item.ams_mapping else None), blocked
+
+
+@pytest.mark.asyncio
+async def test_without_the_saved_tick_the_lowest_spool_of_the_type_is_taken():
+    """The report as it happened: the green spool."""
+    mapping, _ = await _dispatch_mapping(_scheduler(TRAYS), _item(None))
+    assert mapping == [3]
+
+
+@pytest.mark.asyncio
+async def test_the_saved_tick_takes_the_black_spool():
+    mapping, blocked = await _dispatch_mapping(_scheduler(TRAYS), _item(BLACK_FORCED))
+    assert mapping == [2]
+    assert blocked is False
+
+
+@pytest.mark.asyncio
+async def test_with_no_black_loaded_the_job_waits():
+    mapping, blocked = await _dispatch_mapping(_scheduler(TRAYS[1:]), _item(BLACK_FORCED))
+    assert mapping != [3]
+    assert blocked is True

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

@@ -3222,3 +3222,223 @@ describe('PrintModal default printer', () => {
     expect(screen.getByText('P1S').closest('button')).not.toHaveClass('border-bambu-green');
     expect(screen.getByText('P1S').closest('button')).not.toHaveClass('border-bambu-green');
   });
   });
 });
 });
+
+describe('PrintModal — "Force color match" on a specific printer is saved (#3445)', () => {
+  const mockOnClose = vi.fn();
+
+  const PRINTERS = [{ id: 1, name: 'Printer 01', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true }];
+  // Sliced in white; the printer holds green (tray 0) and black (tray 1), no white.
+  const SLOT_1_WHITE = { slot_id: 1, type: 'PLA', color: '#CBC6B8', tray_info_idx: 'GFA01', used_grams: 50 };
+
+  type Override = { slot_id: number; type: string; color: string; tray_info_idx?: string; force_color_match: boolean };
+  type Sent = { printer_id?: number | null; ams_mapping?: number[] | null; filament_overrides?: Override[] | null };
+  let patched: Sent[];
+  let posted: Sent[];
+
+  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_WHITE] })),
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({
+          connected: true,
+          state: 'IDLE',
+          ams: [
+            {
+              id: 0,
+              tray: [
+                { id: 0, tray_type: 'PLA', tray_color: '68724DFF', tray_info_idx: 'GFA01' },
+                { id: 1, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA01' },
+              ],
+            },
+          ],
+          vt_tray: [],
+        }),
+      ),
+      http.get('/api/v1/printers/:id/assignments', () => HttpResponse.json([])),
+      http.patch('/api/v1/queue/:id', async ({ request }) => {
+        patched.push((await request.json()) as Sent);
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        posted.push((await request.json()) as Sent);
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+    );
+  });
+
+  const printer01Item = (overrides: Partial<PrintQueueItem> = {}) =>
+    createMockQueueItem({ printer_id: 1, target_model: null, ams_mapping: [1], ...overrides } as Partial<PrintQueueItem>);
+
+  const BLACK_FORCED = { slot_id: 1, type: 'PLA', color: '#000000', tray_info_idx: 'GFA01', force_color_match: true };
+
+  const openMapping = async (user: ReturnType<typeof userEvent.setup>) => {
+    await user.click(await screen.findByText(/filament mapping/i));
+    return screen.findByRole('checkbox', { name: /force color match/i });
+  };
+
+  const submit = async (user: ReturnType<typeof userEvent.setup>) => {
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+  };
+
+  it("saves a ticked slot with the filament of the tray it was mapped to, not the sliced one", async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={printer01Item()} onClose={mockOnClose} />);
+
+    await user.click(await openMapping(user));
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].ams_mapping).toEqual([1]);
+    // Black, so a recompute at dispatch asks for black and never takes green.
+    expect(patched[0].filament_overrides).toEqual([expect.objectContaining(BLACK_FORCED)]);
+  });
+
+  it('shows the box ticked again when the job is reopened, and keeps it on save', async () => {
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Job"
+        queueItem={printer01Item({ filament_overrides: [{ ...BLACK_FORCED, color_name: 'Black' }] } as Partial<PrintQueueItem>)}
+        onClose={mockOnClose}
+      />,
+    );
+
+    expect(await openMapping(user)).toBeChecked();
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].filament_overrides).toEqual([expect.objectContaining(BLACK_FORCED)]);
+  });
+
+  it('saves an untick', async () => {
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Job"
+        queueItem={printer01Item({ filament_overrides: [BLACK_FORCED] } as Partial<PrintQueueItem>)}
+        onClose={mockOnClose}
+      />,
+    );
+
+    await user.click(await openMapping(user));
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    // Black stays asked for, as picked; only no longer strictly.
+    expect(patched[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, color: '#000000', force_color_match: false }),
+    ]);
+  });
+
+  it("keeps a reopened job's forced filament when its tray has been reloaded since", async () => {
+    // Tray 1 now holds a green third-party spool with no preset.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({
+          connected: true,
+          state: 'IDLE',
+          ams: [{ id: 0, tray: [{ id: 1, tray_type: 'PLA', tray_color: '68724DFF', tray_info_idx: '' }] }],
+          vt_tray: [],
+        }),
+      ),
+    );
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Job"
+        queueItem={printer01Item({ filament_overrides: [BLACK_FORCED] } as Partial<PrintQueueItem>)}
+        onClose={mockOnClose}
+      />,
+    );
+
+    expect(await openMapping(user)).toBeChecked();
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    // Still black, variant pin included: saving the schedule must not turn it green.
+    expect(patched[0].filament_overrides).toEqual([expect.objectContaining(BLACK_FORCED)]);
+  });
+
+  it("takes the new tray's filament when a reopened forced slot is moved to another tray", async () => {
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Job"
+        queueItem={printer01Item({ filament_overrides: [BLACK_FORCED] } as Partial<PrintQueueItem>)}
+        onClose={mockOnClose}
+      />,
+    );
+
+    await openMapping(user);
+    await user.click(await screen.findByRole('combobox', { name: /printer slot for/i }));
+    await user.click(within(await screen.findByRole('listbox')).getByRole('option', { name: /A1/ }));
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].ams_mapping).toEqual([0]);
+    expect(patched[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, color: '#68724D', force_color_match: true }),
+    ]);
+  });
+
+  it('waits for the sliced colour when the box is ticked on a tray the dialog matched itself', async () => {
+    const user = userEvent.setup();
+    render(
+      <PrintModal mode="create" archiveId={1} archiveName="Job" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />,
+    );
+
+    // No white loaded: the dialog stands in a PLA of another colour. The tick
+    // asks for white, not for that stand-in.
+    await user.click(await screen.findByRole('checkbox', { name: /force color match/i }));
+    await submit(user);
+
+    await waitFor(() => expect(posted).toHaveLength(1));
+    expect(posted[0].filament_overrides).toEqual([
+      expect.objectContaining({ slot_id: 1, type: 'PLA', color: '#CBC6B8', force_color_match: true }),
+    ]);
+  });
+
+  it('sends nothing for a slot left unticked and unchanged', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={printer01Item()} onClose={mockOnClose} />);
+
+    await openMapping(user);
+    await submit(user);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].filament_overrides ?? null).toBeNull();
+  });
+
+  it('saves the tick on a new job queued for one printer', async () => {
+    const user = userEvent.setup();
+    render(
+      <PrintModal mode="create" archiveId={1} archiveName="Job" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />,
+    );
+
+    // Pick black for the white slot, then tick it. A preselected printer opens
+    // the mapping panel expanded.
+    await user.click(await screen.findByRole('combobox', { name: /printer slot for/i }));
+    const listbox = await screen.findByRole('listbox');
+    await user.click(within(listbox).getByRole('option', { name: /A2/ }));
+    await user.click(screen.getByRole('checkbox', { name: /force color match/i }));
+    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(BLACK_FORCED)]);
+  });
+});

+ 49 - 12
frontend/src/components/PrintModal/index.tsx

@@ -1015,6 +1015,11 @@ export function PrintModal({
     if (printerId !== queueItem.printer_id || plateId !== initialPlateId) return undefined;
     if (printerId !== queueItem.printer_id || plateId !== initialPlateId) return undefined;
     return queueItem.ams_mapping[slotId - 1];
     return queueItem.ams_mapping[slotId - 1];
   };
   };
+  // The live status the mapping of this printer was matched against.
+  const statusForPrinter = (printerId: number) =>
+    selectedPrinters.length > 1
+      ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
+      : printerStatus;
   const mappingSubstitutesMaterial = (
   const mappingSubstitutesMaterial = (
     printerId: number,
     printerId: number,
     plateId: number | null,
     plateId: number | null,
@@ -1025,10 +1030,7 @@ export function PrintModal({
       ? plateId != null ? mappingPerPlateReqs.get(plateId)?.filaments : undefined
       ? plateId != null ? mappingPerPlateReqs.get(plateId)?.filaments : undefined
       : mappingFilamentReqs?.filaments;
       : mappingFilamentReqs?.filaments;
     if (!reqs) return false;
     if (!reqs) return false;
-    const status = selectedPrinters.length > 1
-      ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
-      : printerStatus;
-    const loaded = buildLoadedFilaments(status);
+    const loaded = buildLoadedFilaments(statusForPrinter(printerId));
     return reqs.some((req) => {
     return reqs.some((req) => {
       const slotId = req.slot_id ?? 0;
       const slotId = req.slot_id ?? 0;
       const tray = slotId > 0 ? mapping[slotId - 1] : undefined;
       const tray = slotId > 0 ? mapping[slotId - 1] : undefined;
@@ -1279,13 +1281,48 @@ export function PrintModal({
         ? buildFilamentOverridesArray(perPlateReqs.get(plateId))
         ? buildFilamentOverridesArray(perPlateReqs.get(plateId))
         : filamentOverridesArray;
         : filamentOverridesArray;
 
 
-    // A specific-printer job carries only the slots the user actually changed
+    // A specific-printer job carries the slots the user actually changed
     // (#3133): the tray mapping was matched against them, and they are what the
     // (#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]);
+    // scheduler needs if it has to recompute that mapping at dispatch. And the
+    // slots ticked "Force color match" (#3445), which were dropped on save, so a
+    // recompute fell back to the sliced colour and took any spool of the type.
+    //
+    // A ticked slot set to a tray of the user's choosing asks for that tray's
+    // filament: it is the alternative picked for the slot. Only a tray chosen
+    // in this panel, or the one the job was saved with: one the dialog matched
+    // on its own may be a stand-in of another colour, and ticking the box there
+    // means wait for the colour asked for. A job opened with a forced entry
+    // keeps it while its slot stays on the tray it was saved with, whatever
+    // that tray holds now (it may have been reloaded since). Everything else
+    // keeps the filament the dialog asks for.
+    const chosenTray = (printerId: number, plateId: number | null, slotId: number): number | undefined => {
+      if (selectedPrinters.length !== 1) return undefined;
+      const picks = isMultiPlateSelection
+        ? plateId != null ? manualMappingsByPlate[plateId] : undefined
+        : sharedMappingPrinterId === null || sharedMappingPrinterId === printerId ? manualMappings : undefined;
+      const tray = picks?.[slotId];
+      if (tray == null) return undefined;
+      const keptForced =
+        storedOverrideBySlot.get(slotId)?.force_color_match === true && openedWithTray(printerId, plateId, slotId) === tray;
+      return keptForced ? undefined : tray;
+    };
+    const printerOverridesForPlate = (printerId: number | null, plateId: number | null) => {
+      const loaded = printerId != null ? buildLoadedFilaments(statusForPrinter(printerId)) : [];
+      const entries = overridesForPlate(plateId)
+        ?.filter((o) => filamentOverrides[o.slot_id] || o.force_color_match)
+        .map((o) => {
+          const tray = o.force_color_match && printerId != null ? chosenTray(printerId, plateId, o.slot_id) : undefined;
+          const filament = tray != null ? loaded.find((f) => f.globalTrayId === tray) : undefined;
+          if (!filament) return o;
+          return {
+            slot_id: o.slot_id,
+            type: filament.type,
+            color: filament.color,
+            color_name: filament.colorName,
+            ...(filament.trayInfoIdx ? { tray_info_idx: filament.trayInfoIdx } : {}),
+            force_color_match: true,
+          };
+        });
       return entries && entries.length > 0 ? entries : undefined;
       return entries && entries.length > 0 ? entries : undefined;
     };
     };
 
 
@@ -1405,7 +1442,7 @@ export function PrintModal({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
       target_location: assignmentMode === 'model' ? targetLocation : null,
       target_location: assignmentMode === 'model' ? targetLocation : null,
-      filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : printerOverridesForPlate(plateId),
+      filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : printerOverridesForPlate(printerId, plateId),
       // Use library_file_id for library files, archive_id for archives
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -1517,7 +1554,7 @@ export function PrintModal({
                 // null, not undefined: omitting the field left a model job's
                 // null, not undefined: omitting the field left a model job's
                 // overrides on the row after it moved to a printer, whatever the
                 // overrides on the row after it moved to a printer, whatever the
                 // user did with them here (#3133).
                 // user did with them here (#3133).
-                filament_overrides: printerOverridesForPlate(plateId) ?? null,
+                filament_overrides: printerOverridesForPlate(printerId, plateId) ?? null,
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 auto_off_after: scheduleOptions.autoOffAfter,
                 auto_off_after: scheduleOptions.autoOffAfter,
                 gcode_injection: scheduleOptions.gcodeInjection,
                 gcode_injection: scheduleOptions.gcodeInjection,