fix(print-modal): give each plate its own Filament Override, and stop

queueing plates we cannot map (#2552)

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

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

Reviewing the per-plate machinery turned up four more holes, all closed here:
a manual tray pick survived a change of printer, and a global tray id names a
different spool on a different machine; a plate whose filaments could not be
read was indistinguishable from one needing none and was queued with neither
mapping nor forced colours, so Print now waits for every selected plate to
answer and names the one it cannot read; the insufficient-filament check still
weighed the whole file against a mapping the plates no longer use, and now
follows what each plate dispatches, summing demand per tray; and the
per-printer tray editor no longer appears for a multi-plate fan-out, where its
choices were collected and then discarded.
This commit is contained in:
maziggy
2026-07-13 10:25:36 +02:00
parent b5da9be794
commit 095d63b24a
17 changed files with 615 additions and 222 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b2] - Unreleased
### Fixed
- **Filament Override vanished for a multi-plate selection in Any [model] mode — but only on the second visit (#2552, reporter @bondjw07)** — Open a sliced multi-plate `.gcode.3mf`, pick **Any [model]**, tick two plates, and the whole Filament Override section is gone. Tick one plate and it comes back. The reporter tied it to having queued or printed the file before, which is the real clue, but not the cause: what actually mattered was that the dialog had been opened once already, so the plates data was still in the cache. The filament requirements are fetched under a key that carries the selected plate, and that key is `null` as soon as two plates are ticked. On the first open the plates are not yet known, so for one render the modal cannot tell it is a multi-plate file and fetches the requirements for the whole file — the union of every plate's filaments — which the override panel then rendered from. On the next open the plates are already cached, the modal knows it is multi-plate from the first render, the whole-file fetch therefore never happens, and the panel had nothing to render. So the section's visibility was decided by a cache race, and the case that "worked" was showing you filaments from plates you had not selected. **Both halves are now wrong-free**: a multi-plate selection in model mode renders one **Filament Override — Plate N** panel per selected plate, each fetched for that plate and listing only the slots that plate actually prints, identical on a cold and a warm cache. A slot's chosen filament and its Force color match tick are shared across plates that print that slot — slot ids are global to the file, so slot 3 is the same filament wherever it appears — and each queued plate is sent only the overrides for its own slots, so a colour forced for plate 2 no longer holds plate 1 back (the API narrows them per plate as of #2551, and the modal no longer sends them wide in the first place). Measured on the old code: warm cache, two plates → zero override panels; cold cache → one panel listing both plates' filaments. Now: two panels, one slot each, either way. **Four further holes in the same per-plate machinery closed while reviewing it**: a manual tray pick on one plate survived a change of printer, and a global tray id names a different spool on a different machine — so the job went out on a tray nobody chose; a plate whose filaments could not be read (or had simply not loaded yet) was indistinguishable from a plate needing none, and was queued with no mapping and no forced colours, to print in whatever happened to be loaded — the Print button now waits for every selected plate to answer and says which one could not be read; the "not enough filament left" check still weighed the whole file's filaments against a mapping the plates no longer use, so it either failed to warn at all or warned about trays the print would not touch — it now follows what each plate actually dispatches, and sums the demand per tray, because 60 g left does not cover two plates of 40 g even though it covers either one of them; and the per-printer tray editor still appeared for a multi-plate fan-out, collecting tray choices that were then discarded.
- **Queueing several plates of one file mapped them all through the first plate's filaments — and hid the panel that would have shown you (#2551, reporter @bondjw07)** — Select one plate and the Filament Mapping panel appears; select a second and it vanishes, and in **Any [model]** mode it never appears at all. Both were deliberate, and one of them was covering a wrong-tray dispatch. **Why the panel hid.** It maps one set of 3MF slots onto one printer's AMS trays, so it needed a single plate and a single printer; `selectedPlates.size <= 1` hid it the moment you ticked a second plate. In model mode there is no printer selected, so there are no trays to map onto — that one is legitimate, and the scheduler computes the mapping per plate when it picks the printer. **What the hidden panel was hiding.** The modal kept posting an `ams_mapping` anyway. With two plates selected the modal has no single plate to ask about, so it falls back to the whole file's filament list — the **union of every plate** — and matched against that. Tray assignment is stateful: a tray claimed by one slot is not offered to the next. So for a file where plate 1 prints red on slot 1 and plate 2 prints red on slot 2, slot 1 took the only red spool and slot 2 fell through to a type-only match on **black** — and that one mapping, `[red, black]`, was sent with *both* plates. The scheduler uses a stored mapping verbatim and only computes its own when the item has none, so plate 2 printed in the wrong colour, decided by a panel the user was never shown. Measured, not deduced: driving the old modal with a real cache posts `ams_mapping: [0, 1]` for both plates. (It reproduces only with a realistic React Query cache — the test harness's `gcTime: 0` evicts the union and makes the modal look innocent, which is why this hid for so long.) **Now each plate maps itself.** Select several plates on one printer and you get one mapping panel **per plate**, named after it, each showing and mapping only the slots its own plate prints, each with its own tray overrides — pin plate 2's red to a different spool and plate 1 is untouched. Each queue item carries its own plate's mapping. Fanning several plates across several printers would be a panel per plate per printer; those items are queued with **no** mapping instead, and the scheduler maps each plate against the printer it actually dispatches to, which it already does correctly. **One matcher, not three.** The tray-matching logic existed twice (once in the hook, once in `computeAmsMapping`) and this needed a third caller, so it is now extracted once and both paths delegate to it — the per-plate panel and the per-printer fan-out cannot drift apart. Its 62 existing tests pass against the extraction unchanged. **Tests.** 3 on the matcher, pinning the exact divergence: each plate alone maps to the red tray, the union starves the second slot onto black, and a manual override on one plate does not leak into another. 4 on the modal: one panel per selected plate; each plate posts the mapping for its own slots (`[0]` and `[-1, 0]`, not the union's `[0, 1]`); a multi-printer fan-out posts none; a model-assigned job posts none. Mutation-verified against a production-like cache — the per-plate test fails with exactly the old `[0, 1]`, and removing the multi-printer guard leaks printer 1's trays onto printer 2.
- **Queueing several plates of one file with Force color match made every plate wait for every colour (#2551, reporter @bondjw07)** — A sliced multi-plate `.gcode.3mf`, each plate a single different PLA colour, queued to **Any X1C** with **Force color match** on. A printer with Army Blue loaded and idle should take the Army Blue plate. Instead every plate sat at Waiting on `PLA (Army Blue), PLA (Ash Grey), PLA (Sunshine Yellow)` — the colours of *all* the plates. Queue the same plates one at a time and it works, which is the tell. **One override list, handed to every plate.** The print dialog only tracks a selected plate when exactly one is selected; pick several and it asks the backend for the filaments of the *whole file*, which is the union across all plates. It builds its override list from that union — correctly, because the user does need to tick each colour once — and then posts **that same list** with each plate's queue item. A `force_color_match` entry means "do not dispatch until this printer has this exact colour loaded", and the scheduler enforces *all* of them, so each single-colour plate demanded the whole batch's palette. The reporter's own guess in the issue was exactly right. **The API is what fixes it.** The overrides are now narrowed to the slots the item's plate actually prints, at write time, on both create and edit — the backend is where the 3MF is, so this holds for every writer of the queue and not just the one dialog. Nothing changes for a single-plate job or for a whole-file job, where the union *is* the requirement. **A second, quieter version of the same bug.** Override types are merged into the item's `required_filament_types`, which is the gate that runs *before* colours are even considered. A shared list therefore also widened that gate: queue a PLA plate and a PETG plate together and the PLA one would refuse every printer that didn't also have PETG loaded, with no mention of colour anywhere in the reason. Narrowing the overrides closes that too. **Fails strict, never silent.** When the plate's slots can't be established — corrupt 3MF, source file gone — the overrides are kept whole rather than dropped. An item waiting on a colour it doesn't need is visible and fixable in ten seconds; an item that silently lost its forced colour prints in the wrong filament. **The plates already in your queue are repaired on upgrade.** Fixing the write path alone would have left every item queued before this release sitting exactly where it is — stuck, with a waiting reason that explains nothing — until the user worked out for himself that deleting and re-adding them was the cure. A startup migration re-scopes the pending items instead. Items that are already printing or done are left untouched: their overrides are a record of what they dispatched with, not an instruction. **Tests.** 6 cases on the API (each of three plates keeps only its own colour and its own slot id; a whole-file job still keeps all three; an unreadable 3MF keeps all three; a PLA plate's required types stay PLA when a PETG plate is queued alongside it; editing an item narrows its overrides too; moving an item to another plate re-scopes it). 5 on the repair (three stuck items each come back to their own colour; a second boot is a no-op; a printing item is not rewritten; a whole-file item keeps all three; a missing source file strips nothing). Mutation-verified — six of the eleven fail against the old code. The migration was run against a real PostgreSQL 16 as well as SQLite, twice over, to confirm it is dialect-neutral and idempotent.
- **A project's tags vanished from the edit dialog when you opened it from the projects list — and its priority was quietly reset when you saved (#2536, reporter @fireboyff)** — Editing a project from the Projects list showed an empty tags field; opening the same project first and editing it from inside showed the tags correctly. **One dialog, two callers.** `ProjectModal` is shared: the detail page hands it a full project, the list hands it a list item. The list endpoint's payload never carried `tags`, `due_date` or `priority`, so from the list the dialog seeded those three fields from `undefined` and rendered them blank. It compiled because the component read them through a cast (`project as ProjectListItem & { tags?: string }`), which asserts a field the type does not have — so TypeScript never pointed out that the value was always missing. The fields are now on `ProjectListResponse` and on `ProjectListItem`, the casts are gone, and the compiler enforces the two shapes agreeing from here on. **The part nobody reported.** The dialog does not send tags when the field is empty, so the tags themselves survived — they were only invisible. Priority is not so lucky: it is *always* sent, defaulting to `normal`. So editing a **high** or **urgent** project from the list silently demoted it, and the reporter would have had no reason to connect that to the empty field he did see. Fixing the payload fixes both, since the dialog now receives the real priority to send back. **Clearing a tag list also never worked, from either view.** An emptied field was sent as `undefined`, which drops the key from the request, and the backend only applied values that were not null — so the old tags came straight back. Tags and due date now behave like budget and URL already did: sent as null, cleared explicitly, and an omitted key still means "leave it alone". **Tests.** 4 backend cases (the list and the template list both carry the fields the dialog renders; a partial update does not disturb a stored priority or tags; an explicit null clears tags and due date) — mutation-verified, three of them fail against the old payload. 3 frontend cases pin the dialog: it prefills all three from a list item, it round-trips a stored `high` instead of submitting its default, and clearing the tags field sends null. The templates list was missing `target_parts_count` too, which the same dialog edits; that is fixed in passing.
@@ -1731,4 +1731,260 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
await waitFor(() => expect(queued.length).toBe(2));
queued.forEach((q) => expect(q.ams_mapping ?? null).toBeNull());
});
it('drops a plate\'s manual tray overrides when the printer changes', async () => {
// A manual override holds a global tray id, which names a different spool on a
// different printer. Carrying plate 1's "slot 1 -> tray 1" from the X1C over to
// the P1S would dispatch a tray the user never picked.
const queued: { plate_id: number; ams_mapping: number[] | null }[] = [];
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
queued.push((await request.json()) as { plate_id: number; ams_mapping: number[] | null });
return HttpResponse.json({ id: queued.length, status: 'pending' });
}),
);
const user = userEvent.setup();
render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
await selectBothPlates(user);
await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument());
// Force plate 1's slot 1 onto the black tray (1) instead of the auto-matched red (0).
await user.click(screen.getByText(/Filament Mapping — Plate 1/));
const traySelects = await waitFor(() => {
const found = screen.getAllByRole('combobox').filter((el) =>
Array.from((el as HTMLSelectElement).options).some((o) => o.value === '1'),
);
if (found.length === 0) throw new Error('no tray select rendered');
return found;
});
await user.selectOptions(traySelects[0], '1');
// Now move the job to the other printer.
await user.click(screen.getByText('X1 Carbon')); // deselect
await user.click(screen.getByText('P1S'));
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
await waitFor(() => expect(queued.length).toBe(2));
// Plate 1 auto-matches red (tray 0) on the new printer. Tray 1 is the stale pick.
const plate1 = queued.find((q) => q.plate_id === 1);
expect(plate1?.ams_mapping).toEqual([0]);
});
it('sums what the plates draw from one spool before warning about it', async () => {
// Both plates map to the same red tray. 60 g left covers either plate on its
// own (40 g), but not both — the check has to add them up, not test them
// one at a time.
server.use(
http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
const plateId = new URL(request.url).searchParams.get('plate_id');
if (plateId === '1') return HttpResponse.json({ filaments: [{ ...SLOT_1_RED, used_grams: 40 }] });
if (plateId === '2') return HttpResponse.json({ filaments: [{ ...SLOT_2_RED, used_grams: 40 }] });
return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
}),
http.get('/api/v1/inventory/assignments', () =>
HttpResponse.json([
{
id: 1, spool_id: 1, printer_id: 1, printer_name: 'X1 Carbon', ams_id: 0, tray_id: 0,
fingerprint_color: null, fingerprint_type: null, configured: true,
spool: { id: 1, label_weight: 1000, weight_used: 940 },
},
]),
),
);
const user = userEvent.setup();
render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
await selectBothPlates(user);
await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument());
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
// 80 g needed from a spool with 60 g left → the acknowledgement dialog, not a
// silent queue.
await waitFor(() => expect(screen.getByText(/Not enough filament/i)).toBeInTheDocument());
expect(screen.getByText(/needs 80g, remaining 60g/i)).toBeInTheDocument();
});
it('holds the Print button until every selected plate has answered', async () => {
// A plate whose filaments cannot be read has no mapping and no forced colours;
// queueing it anyway prints it in whatever happens to be loaded.
server.use(
http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
const plateId = new URL(request.url).searchParams.get('plate_id');
if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] });
if (plateId === '2') return new HttpResponse(null, { status: 500 });
return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
}),
);
const user = userEvent.setup();
render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
await selectBothPlates(user);
await waitFor(() =>
expect(document.querySelector('button[type="submit"]')).toBeDisabled(),
);
expect(screen.getByText(/could not be read/i)).toBeInTheDocument();
// Deselecting the unreadable plate frees the rest of the job.
await user.click(screen.getByText('Plate 2'));
await waitFor(() =>
expect(document.querySelector('button[type="submit"]')).not.toBeDisabled(),
);
});
});
describe('PrintModal — per-plate filament override in model mode (#2552)', () => {
const mockOnClose = vi.fn();
// The reporter's trigger — "only after the file was previously queued or printed"
// — is really "only once the plates query is warm in the cache". On a cold cache
// the plates data is undefined for the first render, so the whole-file filament
// requirements are fetched and left behind; the override panel then rendered from
// that union. On a warm cache the modal knows it is multi-plate from the first
// render, the whole-file query never runs, and the panel disappeared entirely.
// Both states are exercised here, and neither may depend on the cache.
const renderWithCache = (ui: React.ReactElement, prewarm?: (qc: QueryClient) => void) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
prewarm?.(queryClient);
return rtlRender(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<ToastProvider>{ui}</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>,
);
};
// Plate 1 prints slot 1 (red), plate 2 prints slot 2 (blue). Nothing is shared.
const PLATES = {
is_multi_plate: true,
plates: [
{ index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
{ index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#0000FF' }], print_time_seconds: 1800, filament_used_grams: 50 },
],
};
const SLOT_1_RED = { slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 };
const SLOT_2_BLUE = { slot_id: 2, type: 'PLA', color: '#0000FF', tray_info_idx: '', used_grams: 50 };
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)),
http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
const plateId = new URL(request.url).searchParams.get('plate_id');
if (plateId === '1') return HttpResponse.json({ filaments: [SLOT_1_RED] });
if (plateId === '2') return HttpResponse.json({ filaments: [SLOT_2_BLUE] });
return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_BLUE] });
}),
http.get('/api/v1/printers/available-filaments', () =>
HttpResponse.json([
{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
{ type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
]),
),
http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })),
);
});
const selectAnyX1CAndBothPlates = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
await user.click(screen.getByText('Plate 2')); // Plate 1 is auto-selected
await user.click(screen.getByRole('button', { name: /any model/i }));
// Switching to model mode pre-selects the first model, so the override panels
// are already up; the target-model select is the one offering the models.
const modelSelect = await waitFor(() => {
const select = screen
.getAllByRole('combobox')
.find((el) => (el as HTMLSelectElement).options[0]?.value === '' && (el as HTMLSelectElement).options[0]?.text === 'Select a model...');
if (!select) throw new Error('target model select not rendered');
return select as HTMLSelectElement;
});
await user.selectOptions(modelSelect, 'X1C');
};
it('shows one override panel per selected plate even when the plates query is already cached', async () => {
const user = userEvent.setup();
renderWithCache(
<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
// The file has been opened before: the plates are known on the first render,
// so the whole-file requirements query never runs. This is the state in which
// the override section used to vanish for a multi-plate selection.
(qc) => qc.setQueryData(['archive-plates', 1], PLATES),
);
await selectAnyX1CAndBothPlates(user);
await waitFor(() => {
expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument();
expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument();
});
});
it('shows the same per-plate panels on a cold cache', async () => {
const user = userEvent.setup();
renderWithCache(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
await selectAnyX1CAndBothPlates(user);
await waitFor(() => {
expect(screen.getByText(/Filament Override — Plate 1/)).toBeInTheDocument();
expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument();
});
// Not the whole-file union in a single unnamed panel.
expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
});
it('sends each plate only the overrides for the slots it prints', async () => {
type Queued = {
plate_id: number;
filament_overrides?: Array<{ slot_id: number; force_color_match: boolean }> | null;
};
const queued: Queued[] = [];
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
queued.push((await request.json()) as Queued);
return HttpResponse.json({ id: queued.length, status: 'pending' });
}),
);
const user = userEvent.setup();
renderWithCache(
<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
(qc) => qc.setQueryData(['archive-plates', 1], PLATES),
);
await selectAnyX1CAndBothPlates(user);
await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument());
// Force the colour on plate 2's only slot. Plate 1 does not print slot 2 and
// must not be held back waiting for a blue spool it never uses (#2551).
const forceBoxes = screen.getAllByRole('checkbox', { name: /force color match/i });
expect(forceBoxes).toHaveLength(2); // one slot per plate
await user.click(forceBoxes[1]);
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
await waitFor(() => expect(queued.length).toBe(2));
const plate1 = queued.find((q) => q.plate_id === 1);
const plate2 = queued.find((q) => q.plate_id === 2);
expect(plate1?.filament_overrides ?? null).toBeNull();
expect(plate2?.filament_overrides).toEqual([
expect.objectContaining({ slot_id: 2, force_color_match: true }),
]);
});
});
@@ -16,6 +16,12 @@ interface FilamentOverrideProps {
forceColorMatch?: Record<number, boolean>;
/** Called when a slot's force color match checkbox is toggled. */
onForceColorMatchChange?: (slotId: number, value: boolean) => void;
/** Names the plate these requirements belong to, when one panel is rendered per
* selected plate. Each plate prints its own subset of the file's slots (#2552). */
plateLabel?: string;
/** Whether to print the explanatory hint. A stack of per-plate panels only needs
* it once, above the first one. Defaults to true. */
showHint?: boolean;
}
/**
@@ -30,6 +36,8 @@ export function FilamentOverride({
onChange,
forceColorMatch,
onForceColorMatchChange,
plateLabel,
showHint = true,
}: FilamentOverrideProps) {
const { t } = useTranslation();
@@ -71,9 +79,13 @@ export function FilamentOverride({
return (
<div className="mb-4">
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
<span>{t('printModal.filamentOverride')}</span>
<span>
{plateLabel
? `${t('printModal.filamentOverride')} — ${plateLabel}`
: t('printModal.filamentOverride')}
</span>
</div>
<p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>
{showHint && <p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>}
<div className="bg-bambu-dark rounded-lg p-3 space-y-2">
{filaments.map((req, slotIdx) => {
const override = overrides[req.slot_id];
+169 -56
View File
@@ -442,9 +442,22 @@ export function PrintModal({
? api.getLibraryFileFilamentRequirements(libraryFileId!, plateId)
: api.getArchiveFilamentRequirements(archiveId!, plateId),
enabled: isLibraryFile ? !!libraryFileId : !!archiveId,
// Same policy as the single-plate query above: these keys are shared, and a
// retrying observer would leave the plate looking merely slow for seconds.
retry: false,
})),
});
// A plate that has not answered yet and a plate whose 3MF cannot be read look
// identical from here — both are simply absent from `perPlateReqs`. Neither may
// be treated as "this plate needs no filament": that would queue it with no
// mapping and no force-colour overrides, and it would print in whatever happens
// to be loaded. Both states gate submission instead (see `canSubmit`).
// `isPending` is "no data yet", not "a request is in flight" — a background
// refetch of a plate we already have must not disable the button under the user.
const perPlateReqsPending = perPlateReqQueries.some((q) => q.isPending);
const perPlateReqsFailed = perPlateReqQueries.some((q) => q.isError);
const perPlateReqs = useMemo(() => {
const byPlate = new Map<number, FilamentReqsData>();
selectedPlateIds.forEach((plateId, i) => {
@@ -452,9 +465,11 @@ export function PrintModal({
if (data) byPlate.set(plateId, data);
});
return byPlate;
// perPlateReqQueries is a fresh array each render; its data identity is what matters.
// Keyed on each query's last update stamp, not on the query objects (fresh every
// render) and not on a spread of their data (a dep array whose *length* changes
// with the plate count, which React treats as always-changed and warns about).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPlateIds, ...perPlateReqQueries.map((q) => q.data)]);
}, [selectedPlateIds, perPlateReqQueries.map((q) => q.dataUpdatedAt).join('|')]);
// Manual slot overrides are per plate: slot 3 of plate 1 and slot 3 of plate 2
// are different prints and may want different trays.
@@ -525,18 +540,22 @@ export function PrintModal({
}
}, [mode, printers, selectedPrinters.length]);
// Clear manual mappings and per-printer configs when printer or plate changes
// Clear manual mappings and per-printer configs when printer or plate changes.
// The per-plate mappings go with them: a manual override holds a global tray id,
// which names a different spool on a different printer.
useEffect(() => {
if (mode === 'edit-queue-item') {
// For edit mode, clear mappings if printer selection or plate changed from initial
const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
if (printersChanged || selectedPlate !== initialPlateId) {
setManualMappings({});
setManualMappingsByPlate({});
setPerPrinterConfigs({});
setInitialExpandApplied(new Set());
}
} else {
setManualMappings({});
setManualMappingsByPlate({});
setPerPrinterConfigs({});
setInitialExpandApplied(new Set());
}
@@ -650,6 +669,31 @@ export function PrintModal({
},
});
// Get mapping for a specific printer (per-printer override or default).
// A multi-plate submission maps each plate on its own — `amsMapping` and the
// per-printer mappings are both derived from the union of every selected
// plate's filaments, which is not this plate's print (#2551 follow-up).
// Without a per-plate mapping we send none at all and let the scheduler
// compute one at dispatch, which it already does per plate; a union mapping
// would be used verbatim and could feed a slot from the wrong tray.
const getMappingForPrinter = (printerId: number, plateId: number | null): number[] | undefined => {
if (isMultiPlateSelection) {
// Fanning several plates across several printers would be a mapping per
// plate *per printer*; those items go out without one and the scheduler
// maps each plate against the printer it actually picks.
if (plateId === null || selectedPrinters.length !== 1) return undefined;
return perPlateAmsMappings.get(plateId);
}
// For multi-printer selection, check if this printer has an override
if (selectedPrinters.length > 1) {
const printerConfig = perPrinterConfigs[printerId];
if (printerConfig && !printerConfig.useDefault) {
return multiPrinterMapping.getFinalMapping(printerId);
}
}
return amsMapping;
};
const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
e?.preventDefault();
@@ -660,9 +704,17 @@ export function PrintModal({
assignmentMode === 'printer'
) {
const warningItems: FilamentWarningItem[] = [];
const filamentReqs = effectiveFilamentReqs?.filaments ?? [];
if (filamentReqs.length > 0 && spoolAssignmentsByPrinter.size > 0) {
// The spool check follows what is actually dispatched: one job per selected
// plate, each with the mapping that plate's queue item carries. Two plates
// can also draw on the same spool, so the demand is summed per tray before
// it is weighed against what is left on it — 60 g left does not cover two
// plates of 40 g, even though it covers either one of them (#2551).
const plateJobs = isMultiPlateSelection
? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
: [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
if (plateJobs.some((job) => job.reqs.length > 0) && spoolAssignmentsByPrinter.size > 0) {
const getRemainingWeight = (labelWeight: number, weightUsed: number) => {
if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;
if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;
@@ -670,11 +722,6 @@ export function PrintModal({
};
for (const printerId of selectedPrinters) {
const printerMapping = selectedPrinters.length > 1
? multiPrinterMapping.getFinalMapping(printerId)
: amsMapping;
if (!printerMapping) continue;
const printerStatusForWarning = selectedPrinters.length > 1
? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
: printerStatus;
@@ -686,26 +733,36 @@ export function PrintModal({
if (!assignments) continue;
filamentReqs.forEach((req) => {
if (!req.slot_id || req.slot_id <= 0) return;
const globalTrayId = printerMapping[req.slot_id - 1];
if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
const gramsByTray = new Map<number, number>();
for (const job of plateJobs) {
// No mapping means the scheduler picks the trays at dispatch, against
// an AMS state we cannot see from here — nothing to weigh.
const printerMapping = getMappingForPrinter(printerId, job.plateId);
if (!printerMapping) continue;
const assignment = assignments.get(globalTrayId);
const spool = assignment?.spool;
if (!spool) return;
job.reqs.forEach((req) => {
if (!req.slot_id || req.slot_id <= 0) return;
const globalTrayId = printerMapping[req.slot_id - 1];
if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
gramsByTray.set(globalTrayId, (gramsByTray.get(globalTrayId) ?? 0) + req.used_grams);
});
}
for (const [globalTrayId, requiredGrams] of gramsByTray) {
const spool = assignments.get(globalTrayId)?.spool;
if (!spool) continue;
const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
if (remainingGrams === null) return;
if (remainingGrams >= req.used_grams) return;
if (remainingGrams === null) continue;
if (remainingGrams >= requiredGrams) continue;
warningItems.push({
printerName,
slotLabel: slotLabelByTray.get(globalTrayId) ?? `Slot ${req.slot_id}`,
requiredGrams: req.used_grams,
slotLabel: slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`,
requiredGrams,
remainingGrams,
});
});
}
}
}
@@ -741,40 +798,15 @@ export function PrintModal({
errors: [],
};
// Get mapping for a specific printer (per-printer override or default).
// A multi-plate submission maps each plate on its own — `amsMapping` and the
// per-printer mappings are both derived from the union of every selected
// plate's filaments, which is not this plate's print (#2551 follow-up).
// Without a per-plate mapping we send none at all and let the scheduler
// compute one at dispatch, which it already does per plate; a union mapping
// would be used verbatim and could feed a slot from the wrong tray.
const getMappingForPrinter = (printerId: number, plateId: number | null): number[] | undefined => {
if (isMultiPlateSelection) {
// Fanning several plates across several printers would be a mapping per
// plate *per printer*; those items go out without one and the scheduler
// maps each plate against the printer it actually picks.
if (plateId === null || selectedPrinters.length !== 1) return undefined;
return perPlateAmsMappings.get(plateId);
}
// For multi-printer selection, check if this printer has an override
if (selectedPrinters.length > 1) {
const printerConfig = perPrinterConfigs[printerId];
if (printerConfig && !printerConfig.useDefault) {
return multiPrinterMapping.getFinalMapping(printerId);
}
}
return amsMapping;
};
// Convert filament overrides from Record to array format for API.
// Include all slots that either have a user override or have force_color_match enabled
// (which is the default for model-based assignment).
const buildFilamentOverridesArray = () => {
const buildFilamentOverridesArray = (reqs: FilamentReqsData | undefined) => {
const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = [];
// Process all slots from filament requirements (to capture force_color_match defaults)
if (effectiveFilamentReqs?.filaments) {
for (const req of effectiveFilamentReqs.filaments) {
if (reqs?.filaments) {
for (const req of reqs.filaments) {
const userOverride = filamentOverrides[req.slot_id];
const isForceColor = forceColorMatch[req.slot_id] ?? false;
const effectiveType = userOverride?.type ?? req.type;
@@ -797,7 +829,18 @@ export function PrintModal({
return entries.length > 0 ? entries : undefined;
};
const filamentOverridesArray = buildFilamentOverridesArray();
const filamentOverridesArray = buildFilamentOverridesArray(effectiveFilamentReqs);
// A plate only carries the slots it prints (#2552). Slot ids are global to the
// file, so an override on slot 3 means the same filament in every plate that
// uses slot 3 — the per-plate list is a subset of the shared state, not a
// rewrite of it. No fallback to the whole-file list: it holds slots this plate
// never prints, and submission is gated on every selected plate having answered,
// so a plate is never missing here.
const overridesForPlate = (plateId: number | null) =>
isMultiPlateSelection && plateId !== null
? buildFilamentOverridesArray(perPlateReqs.get(plateId))
: filamentOverridesArray;
// Multi-plate auto-batch: when the user adds 2+ plates from one source in
// a single create submission, pre-create a PrintBatch and pass its
@@ -847,7 +890,7 @@ export function PrintModal({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
target_location: assignmentMode === 'model' ? targetLocation : null,
filament_overrides: assignmentMode === 'model' ? filamentOverridesArray : undefined,
filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : undefined,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -1031,8 +1074,23 @@ export function PrintModal({
// For multi-plate files, need at least one plate selected
if (isMultiPlate && selectedPlates.size === 0) return false;
// Every selected plate has to have answered before we can queue it: a plate
// still in flight would be sent with no mapping and no overrides, and one that
// failed to load cannot be mapped at all. Deselect the failing plate to queue
// the rest — the banner above says which state we are in.
if (perPlateReqsPending || perPlateReqsFailed) return false;
return true;
}, [selectedPrinters.length, assignmentMode, targetModel, isMultiPlate, selectedPlates.size, isPending]);
}, [
selectedPrinters.length,
assignmentMode,
targetModel,
isMultiPlate,
selectedPlates.size,
isPending,
perPlateReqsPending,
perPlateReqsFailed,
]);
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
@@ -1095,6 +1153,13 @@ export function PrintModal({
const showPerPlateFilamentMapping =
!!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
// Model mode has no printer and so no trays to map onto; what it offers instead
// is the filament each slot must be printed in, which the scheduler matches
// against whatever printer of the model it picks. Needs the model's loaded
// filaments to offer as alternatives.
const showFilamentOverride =
assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
// Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
// Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
// the toggle without a specific printer selected. For printer-mode we rely
@@ -1199,7 +1264,12 @@ export function PrintModal({
showInactive={mode === 'edit-queue-item'}
disableBusy={false}
printerMappingResults={multiPrinterMapping.printerResults}
filamentReqs={effectiveFilamentReqs}
// The per-printer tray editor inside the selector maps one filament
// list onto each printer. Several plates have several lists, and a
// fan-out across printers ships no mapping at all (the scheduler maps
// each plate against the printer it picks), so the editor would be
// collecting tray choices it then throws away. Withhold its input.
filamentReqs={isMultiPlateSelection ? undefined : effectiveFilamentReqs}
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
assignmentMode={assignmentMode}
@@ -1213,10 +1283,10 @@ export function PrintModal({
)}
{/* Filament override - shown in model mode when filament requirements are available */}
{assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
{showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
<FilamentOverride
filamentReqs={effectiveFilamentReqs}
availableFilaments={availableFilaments}
availableFilaments={availableFilaments!}
overrides={filamentOverrides}
onChange={setFilamentOverrides}
forceColorMatch={forceColorMatch}
@@ -1226,6 +1296,34 @@ export function PrintModal({
/>
)}
{/* Filament override, one panel per selected plate. `effectiveFilamentReqs`
is keyed on `selectedPlate`, which is null as soon as two plates are
picked, so a multi-plate selection used to render this panel from
whatever the whole-file query had left in the cache — the union of every
plate's filaments, or nothing at all once the plates query was warm and
the whole-file query therefore never ran, which is why the section
vanished on the second open of the dialog (#2552). */}
{showFilamentOverride && isMultiPlateSelection && selectedPlateIds.map((plateId, idx) => {
const plate = plates.find((p) => p.index === plateId);
const plateReqs = perPlateReqs.get(plateId);
if (!plateReqs) return null;
return (
<FilamentOverride
key={plateId}
plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
showHint={idx === 0}
filamentReqs={plateReqs}
availableFilaments={availableFilaments!}
overrides={filamentOverrides}
onChange={setFilamentOverrides}
forceColorMatch={forceColorMatch}
onForceColorMatchChange={(slotId, value) =>
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
}
/>
);
})}
{/* Compatibility warning when sliced model doesn't match selected printer */}
{slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
@@ -1252,6 +1350,21 @@ export function PrintModal({
</div>
)}
{/* A selected plate whose filaments could not be read cannot be mapped and
cannot carry its forced colours, so it is not queued silently — say so
and hold the button until the plate is deselected. */}
{perPlateReqsFailed && (
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg text-sm">
<AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
<p className="text-orange-700 dark:text-orange-400">
{t(
'printModal.plateFilamentsUnreadable',
"The filaments of a selected plate could not be read, so it can't be mapped. Deselect it to queue the others.",
)}
</p>
</div>
)}
{/* Filament mapping - only show when single printer selected */}
{showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
<FilamentMapping
+1
View File
@@ -4563,6 +4563,7 @@ export default {
selectPlate: 'Platte auswählen',
filamentMapping: 'Filamentzuordnung',
plateN: 'Platte {{n}}',
plateFilamentsUnreadable: 'Die Filamente einer ausgewählten Platte konnten nicht gelesen werden, sie lässt sich daher nicht zuordnen. Wähle sie ab, um die anderen einzureihen.',
totalCost: 'Gesamtkosten:',
slotRemainingShort: ' - {{grams}}g übrig',
printSettings: 'Druckeinstellungen',
+1
View File
@@ -4606,6 +4606,7 @@ export default {
selectPlate: 'Select Plate',
filamentMapping: 'Filament Mapping',
plateN: 'Plate {{n}}',
plateFilamentsUnreadable: 'The filaments of a selected plate could not be read, so it can\'t be mapped. Deselect it to queue the others.',
totalCost: 'Total cost:',
slotRemainingShort: ' - {{grams}}g left',
printSettings: 'Print Settings',
+1
View File
@@ -4571,6 +4571,7 @@ export default {
selectPlate: 'Seleccionar cama',
filamentMapping: 'Mapeo de filamentos',
plateN: 'Cama {{n}}',
plateFilamentsUnreadable: 'No se han podido leer los filamentos de una cama seleccionada, por lo que no se puede asignar. Deselecciónala para encolar las demás.',
totalCost: 'Coste total:',
slotRemainingShort: ' - quedan {{grams}} g',
printSettings: 'Ajustes de impresión',
+1
View File
@@ -4552,6 +4552,7 @@ export default {
selectPlate: 'Choisir le plateau',
filamentMapping: 'Mapping Filament',
plateN: 'Plateau {{n}}',
plateFilamentsUnreadable: 'Les filaments d\'un plateau sélectionné n\'ont pas pu être lus, il est donc impossible de l\'affecter. Désélectionnez-le pour mettre les autres en file.',
totalCost: 'Coût total :',
slotRemainingShort: ' - {{grams}}g rest.',
printSettings: 'Réglages d\'impression',
+1
View File
@@ -4551,6 +4551,7 @@ export default {
selectPlate: 'Seleziona piatto',
filamentMapping: 'Mappatura filamento',
plateN: 'Piatto {{n}}',
plateFilamentsUnreadable: 'Non è stato possibile leggere i filamenti di un piatto selezionato, quindi non può essere assegnato. Deselezionalo per accodare gli altri.',
totalCost: 'Costo totale:',
slotRemainingShort: ' - {{grams}}g rim.',
printSettings: 'Impostazioni stampa',
+1
View File
@@ -4563,6 +4563,7 @@ export default {
selectPlate: 'プレートを選択',
filamentMapping: 'フィラメントマッピング',
plateN: 'プレート {{n}}',
plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
totalCost: '合計コスト:',
slotRemainingShort: ' - 残{{grams}}g',
printSettings: '印刷設定',
+1
View File
@@ -4335,6 +4335,7 @@ export default {
selectPlate: '플레이트 선택',
filamentMapping: '필라멘트 매핑',
plateN: '플레이트 {{n}}',
plateFilamentsUnreadable: '선택한 플레이트의 필라멘트를 읽을 수 없어 매핑할 수 없습니다. 해당 플레이트를 선택 해제하면 나머지를 대기열에 추가할 수 있습니다.',
totalCost: '총 비용:',
slotRemainingShort: ' - {{grams}}g 남음',
printSettings: '인쇄 설정',
+1
View File
@@ -4551,6 +4551,7 @@ export default {
selectPlate: 'Selecionar Placa',
filamentMapping: 'Mapeamento de Filamento',
plateN: 'Placa {{n}}',
plateFilamentsUnreadable: 'Não foi possível ler os filamentos de uma placa selecionada, portanto ela não pode ser mapeada. Desmarque-a para enfileirar as demais.',
totalCost: 'Custo total:',
slotRemainingShort: ' - {{grams}}g rest.',
printSettings: 'Configurações de Impressão',
+1
View File
@@ -4541,6 +4541,7 @@ export default {
selectPlate: 'Plaka Seç',
filamentMapping: 'Filament Eşlemesi',
plateN: 'Plaka {{n}}',
plateFilamentsUnreadable: 'Seçili bir plakanın filamentleri okunamadı, bu yüzden eşleştirilemiyor. Diğerlerini kuyruğa almak için o plakanın seçimini kaldırın.',
totalCost: 'Toplam maliyet:',
slotRemainingShort: ' - {{grams}}g kaldı',
printSettings: 'Baskı Ayarları',
+1
View File
@@ -4551,6 +4551,7 @@ export default {
selectPlate: '选择板',
filamentMapping: '耗材映射',
plateN: '板 {{n}}',
plateFilamentsUnreadable: '无法读取所选盘的耗材信息,因此无法进行映射。取消选择该盘即可将其余盘加入队列。',
totalCost: '总成本:',
slotRemainingShort: ' - 剩余 {{grams}}g',
printSettings: '打印设置',
+1
View File
@@ -4551,6 +4551,7 @@ export default {
selectPlate: '選擇板',
filamentMapping: '耗材對應',
plateN: '板 {{n}}',
plateFilamentsUnreadable: '無法讀取所選盤的耗材資訊,因此無法進行對應。取消選取該盤即可將其餘盤加入佇列。',
totalCost: '總成本:',
slotRemainingShort: ' - 剩餘 {{grams}}g',
printSettings: '列印設定',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CCULKpLm.js"></script>
<script type="module" crossorigin src="/assets/index-D3e4re0S.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DASc8Ke0.css">
</head>
<body>