Choose which rack nozzle each filament prints from on an H2C (#1784)

The Vortek rack holds six hotends, and a multi-colour plate is sliced to
use a different one per colour so it can skip the purge. Which of the six
each colour takes is not in the 3MF. The same plate, sliced and sent twice
from Bambu Studio with a different choice each time, produces two files
that differ only in rounding in the last digit of a few extrusion figures
-- the filament grouping, the toolchange stream, the 120 nozzle-change
markers and project_settings.config are all identical. The choice travels
only in the dispatched nozzle_mapping.

Bambuddy had no way to state it, so those plates went out with no nozzle
assignment at all and the printer chose for itself. That is what levelled
on one hotend and printed with another, millimetres above the plate.

Every rack-bound filament now carries a position picker beside its AMS
slot dropdown, listing all six with the nozzle each holds. An empty
position, or one holding the wrong diameter or flow type, is shown greyed
out with the reason rather than hidden, so someone looking for position 4
finds it. The choice is per filament *group* rather than per slot, because
a group is one hotend: two filaments the slicer grouped together share it
and cannot point at different positions.

Nothing has to be picked. Positions are assigned automatically, preferring
one already loaded with that colour, which on the plate this was built
against reproduces Bambu Studio's own pick exactly.

A nozzle currently picked up onto the carriage is offered too. The
firmware drops its rack position from the report entirely rather than
sending a placeholder (#943), and refusing it would rule out the position
most likely to be wanted -- the one the last print left mounted. Only
recoverable when exactly one position is missing; two gaps are genuinely
ambiguous and stay unavailable.

Positions are re-checked at dispatch, not just when queued, because the
rack can be re-loaded in between. The two failure modes differ on purpose:
an explicitly chosen position that no longer fits stops the print, names
what the position now holds, and deletes the uploaded file from the SD
card so it cannot be started by hand either -- an operator who named a
hotend must not silently get a different one. An automatic assignment that
cannot be made instead falls back to letting the firmware choose, which is
what happened before any of this existed.

The pick is stored as {group: position} rather than as the expanded
nozzle_mapping, though that is what goes on the wire. That column means
"Bambu Studio decided, forward verbatim", and only the group-and-position
form can be re-checked against what is actually mounted at dispatch.

The existing multi-rack refusal in extract_nozzle_mapping_from_3mf stays.
It still guards the #2800 fallback, which can only ever name one rack id.

Measured on the maintainer's H2C: rack position n is physical nozzle id
15 + n, confirmed by cross-referencing two captured dispatches against
Bambu Studio's own dialog. extruder_max_nozzle_count names which carriage
is the rack straight from the file, and is read rather than assumed -- a
fourth independent confirmation of the carriage indices fixed in 45dc139.

The print dialog is also wider, on every printer. Its filament rows carry
the most horizontal content in it and adding a picker truncated names to
"Bamb...". The column widths themselves only change on a rack machine.

Tests: 44 unit covering the plan, the resolver, the mounted-nozzle
recovery and every refusal; 9 dispatch integration asserting the two real
captures end to end; 7 API round-trip; 33 frontend. The API ones exist
because two integration bugs got through a green suite that tested the
pieces and not the seams -- the group data reached only one of the three
filament-requirements routes, and the field was declared on every schema
except the create one, where Pydantic dropped it in silence.
This commit is contained in:
maziggy
2026-08-14 11:27:50 +02:00
parent 4d458a5202
commit 3954d3a7e6
39 changed files with 2428 additions and 115 deletions
@@ -0,0 +1,230 @@
/**
* The per-group rack position picker in the print dialog (#1784).
*
* On an H2C the rack carriage hosts six hotends and the 3MF does not say which
* one a filament group should use — proven by dispatching one plate twice from
* BambuStudio with different picks and finding the two files identical bar
* float noise. So the dialog has to ask, and the answer travels to the printer
* as `nozzle_mapping`.
*
* The picker is per *group*, not per slot: two slots sharing a group share one
* hotend and cannot point at different positions.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
import type { PrinterStatus } from '../../api/client';
// The maintainer's own plate: three PLA filaments in groups 2/0/1, with groups
// 1 and 2 on the rack and group 0 on the fixed hotend.
const group = (over = {}) => ({
on_rack: true,
nozzle_diameter: '0.40',
volume_type: 'High Flow',
filament_color: '',
...over,
});
const benchyReqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#DE4343', used_grams: 7, used_meters: 2.1,
group_id: 2, group: group({ filament_color: '#DE4343' }) },
{ slot_id: 2, type: 'PLA', color: '#F4EE2A', used_grams: 7, used_meters: 2.2,
group_id: 0, group: group({ on_rack: false, filament_color: '#F4EE2A' }) },
{ slot_id: 3, type: 'PLA', color: '#0078BF', used_grams: 10, used_meters: 3.3,
group_id: 1, group: group({ filament_color: '#0078BF' }) },
],
};
const rackSlot = (id: number, over = {}) => ({
id,
nozzle_type: 'HH01',
nozzle_diameter: '0.4',
wear: null,
stat: null,
max_temp: 300,
serial_number: '',
filament_color: '',
filament_id: '',
filament_type: '',
...over,
});
function createStatus(nozzleRack: unknown[]): PrinterStatus {
return {
id: 1,
name: 'H2C-1',
connected: true,
state: 'IDLE',
ams: [
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'DE4343', tray_info_idx: 'GFA01' },
{ id: 1, tray_type: 'PLA', tray_color: 'F4EE2A', tray_info_idx: 'GFA00' },
{ id: 2, tray_type: 'PLA', tray_color: '0078BF', tray_info_idx: 'GFA01' },
],
},
],
vt_tray: [],
ams_extruder_map: {},
nozzle_rack: nozzleRack,
...{},
} as unknown as PrinterStatus;
}
/** Full rack, plus the fixed carriage the printer always reports. */
const fullRack = [1, 2, 3, 4, 5, 6].map((p) => rackSlot(15 + p)).concat(rackSlot(1));
function mount(props: Record<string, unknown> = {}, rack = fullRack) {
server.use(
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus(rack))),
http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
);
return render(
<FilamentMapping
printerId={1}
filamentReqs={benchyReqs}
manualMappings={{}}
onManualMappingChange={() => {}}
currencySymbol="$"
defaultCostPerKg={20}
defaultExpanded
{...props}
/>,
);
}
/** The rack pickers, in slot order. */
async function pickers() {
return await waitFor(async () => {
const found = await screen.findAllByLabelText('Rack position');
expect(found.length).toBeGreaterThan(0);
return found as HTMLSelectElement[];
});
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('FilamentMapping — nozzle rack position picker', () => {
beforeEach(() => {
server.use(http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])));
});
it('offers a picker for each rack-bound group and none for the fixed one', async () => {
mount();
const selects = await pickers();
// Groups 2 and 1 are on the rack; group 0 is the fixed hotend and gets the
// plain L badge instead.
expect(selects).toHaveLength(2);
expect(screen.getByText('L')).toBeInTheDocument();
});
it('offers all six positions so a missing one is greyed out, not absent', async () => {
mount({}, [rackSlot(16), rackSlot(17), rackSlot(1)]);
const [first] = await pickers();
expect(first.querySelectorAll('option')).toHaveLength(6);
const disabled = [...first.querySelectorAll('option')].filter((o) => (o as HTMLOptionElement).disabled);
expect(disabled.map((o) => (o as HTMLOptionElement).value)).toEqual(['3', '4', '5', '6']);
});
it('disables a position holding the wrong nozzle', async () => {
mount({}, [rackSlot(16), rackSlot(17, { nozzle_diameter: '0.6' }), rackSlot(1)]);
const [first] = await pickers();
const option = [...first.querySelectorAll('option')].find(
(o) => (o as HTMLOptionElement).value === '2',
) as HTMLOptionElement;
expect(option.disabled).toBe(true);
expect(option.title).toBe('Holds a 0.6 HH01 nozzle; this filament needs 0.40 High Flow');
});
it('pre-selects the position already loaded with the group colour', async () => {
// Reproduces BambuStudio's own pick for this plate — dispatched [16, 1, 17]
// on 2026-08-14: red group to R1, blue group to R2.
const coloured = [
rackSlot(16, { filament_color: 'DE4343FF' }),
rackSlot(17, { filament_color: '0078BFFF' }),
...[3, 4, 5, 6].map((p) => rackSlot(15 + p)),
rackSlot(1),
];
mount({}, coloured);
const [red, blue] = await pickers();
expect(red.value).toBe('1');
expect(blue.value).toBe('2');
});
it('reports every group when one is changed, not just the edited one', async () => {
// Sending only the edited group would let the dispatcher re-assign the
// others around it and silently move a hotend the operator had accepted.
const onChange = vi.fn();
mount({ onNozzleRackChoiceChange: onChange });
const [red] = await pickers();
fireEvent.change(red, { target: { value: '4' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toEqual({ 1: 1, 2: 4 });
});
it('shows a saved pick rather than re-deriving one', async () => {
mount({ nozzleRackChoice: { 2: 5, 1: 6 }, onNozzleRackChoiceChange: vi.fn() });
const [red, blue] = await pickers();
expect(red.value).toBe('5');
expect(blue.value).toBe('6');
});
it('offers the mounted nozzle, which telemetry omits entirely', async () => {
// #943: the firmware drops a rack id while that nozzle is on the carriage.
// It is the likeliest pick of all — the last print left it there.
const mounted = [
...[1, 3, 4, 5, 6].map((p) => rackSlot(15 + p)),
rackSlot(1),
rackSlot(0), // the rack carriage, holding position 2's nozzle
];
mount({}, mounted);
const [first] = await pickers();
const option = [...first.querySelectorAll('option')].find(
(o) => (o as HTMLOptionElement).value === '2',
) as HTMLOptionElement;
expect(option.disabled).toBe(false);
});
it('renders no picker on a printer with no rack', async () => {
// Two carriages and nothing at 16..21 -- an H2D, or an H2C mid-report.
// The panel still maps filaments; it just offers no rack position, and
// no L/R badge either, since this plate carries no nozzle_id.
mount({}, [rackSlot(0), rackSlot(1)]);
await waitFor(() => expect(screen.getAllByText(/PLA/).length).toBeGreaterThan(0));
expect(screen.queryAllByLabelText('Rack position')).toHaveLength(0);
expect(screen.queryByText('R')).not.toBeInTheDocument();
});
it('leaves a dual-nozzle plate on its L/R badges', async () => {
// No group data at all — an H2D file. The pre-existing badge must survive.
const h2dReqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 5, used_meters: 2, nozzle_id: 1 },
{ slot_id: 2, type: 'PLA', color: '#00FF00', used_grams: 5, used_meters: 2, nozzle_id: 0 },
],
};
mount({ filamentReqs: h2dReqs });
await screen.findByText('L');
expect(screen.getByText('R')).toBeInTheDocument();
expect(screen.queryAllByLabelText('Rack position')).toHaveLength(0);
});
});
@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import type { NozzleRackSlot } from '../../api/client';
import type { RackGroupInfo } from '../../components/PrintModal/types';
import {
RACK_POSITIONS,
autoAssignRackPositions,
isRackSlotEligible,
rackByPosition,
rackOptionsForGroup,
rackPositionToNozzleId,
} from '../../utils/nozzleRack';
/**
* Mirrors backend/tests/unit/test_nozzle_rack_positions_1784.py. The two
* implementations have to agree or the picker greys out an option the
* dispatcher would have accepted, or worse offers one it will refuse.
*/
const slot = (id: number, over: Partial<NozzleRackSlot> = {}): NozzleRackSlot => ({
id,
nozzle_type: 'HH01',
nozzle_diameter: '0.4',
wear: null,
stat: null,
max_temp: 300,
serial_number: '',
filament_color: '',
filament_id: '',
filament_type: '',
...over,
});
/** Live rack with a nozzle at each named 1-based position, plus the carriages. */
const rack = (present: number[] = [1, 2, 3, 4, 5, 6], over: Record<number, Partial<NozzleRackSlot>> = {}) => [
slot(1),
...present.map((p) => slot(15 + p, over[p] ?? {})),
];
const group = (over: Partial<RackGroupInfo> = {}): RackGroupInfo => ({
on_rack: true,
nozzle_diameter: '0.40',
volume_type: 'High Flow',
filament_color: '',
...over,
});
const t = (key: string) => key;
describe('rack position numbering', () => {
it('maps position n to physical nozzle id 15 + n', () => {
expect(RACK_POSITIONS.map(rackPositionToNozzleId)).toEqual([16, 17, 18, 19, 20, 21]);
});
it.each([0, -1, 7, 1.5])('rejects %s as a position', (position) => {
expect(rackPositionToNozzleId(position)).toBeNull();
});
});
describe('reading the live rack', () => {
it('recovers the mounted nozzle from the lone gap', () => {
// Measured 2026-08-14 09:02: IDs [16, 1, 21, 19, 18, 0, 20] — id 17 absent
// because that nozzle was picked up onto the carriage (#943).
const slots = [...rack([1, 3, 4, 5, 6]), slot(0)];
const byPosition = rackByPosition(slots);
expect([...byPosition.keys()].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
expect(byPosition.get(2)!.id).toBe(0);
});
it('leaves two gaps absent, because which one is mounted is unknowable', () => {
const byPosition = rackByPosition([...rack([1, 4, 5, 6]), slot(0)]);
expect([...byPosition.keys()].sort((a, b) => a - b)).toEqual([1, 4, 5, 6]);
});
it('fills no gap from an empty carriage', () => {
const empty = slot(0, { nozzle_diameter: '', nozzle_type: '' });
expect(rackByPosition([...rack([1, 3, 4, 5, 6]), empty]).has(2)).toBe(false);
});
it('ignores the fixed carriage entirely', () => {
expect(rackByPosition([slot(1)]).size).toBe(0);
});
});
describe('eligibility', () => {
it('matches a padded slice diameter against an unpadded printer one', () => {
expect(isRackSlotEligible(slot(16, { nozzle_diameter: '0.4' }), group())).toBe(true);
});
it('rejects the wrong diameter', () => {
expect(isRackSlotEligible(slot(16, { nozzle_diameter: '0.6' }), group())).toBe(false);
});
it('rejects the wrong flow type', () => {
expect(isRackSlotEligible(slot(16, { nozzle_type: 'HS' }), group())).toBe(false);
});
it('does not rule out a printer that reports no flow type', () => {
expect(isRackSlotEligible(slot(16, { nozzle_type: '' }), group())).toBe(true);
});
it('rejects an empty position', () => {
expect(isRackSlotEligible(slot(16, { nozzle_diameter: '', nozzle_type: '' }), group())).toBe(false);
});
it('rejects a position that is not there at all', () => {
expect(isRackSlotEligible(undefined, group())).toBe(false);
});
});
describe('the options offered for a group', () => {
it('always offers all six, so a position is greyed out rather than missing', () => {
const options = rackOptionsForGroup(rack([1, 2]), group(), t);
expect(options).toHaveLength(6);
expect(options.filter((o) => o.eligible).map((o) => o.position)).toEqual([1, 2]);
});
it('says why an empty position cannot be used', () => {
const options = rackOptionsForGroup(rack([1]), group(), t);
expect(options[1].reason).toBe('printModal.rackEmptyPosition');
});
it('says why a wrong nozzle cannot be used', () => {
const options = rackOptionsForGroup(rack([1, 2], { 2: { nozzle_diameter: '0.6' } }), group(), t);
expect(options[1].reason).toBe('printModal.rackWrongNozzle');
});
});
describe('auto-assignment', () => {
const groups = new Map<number, RackGroupInfo>([
[0, group({ on_rack: false, filament_color: '#F4EE2A' })],
[1, group({ filament_color: '#0078BF' })],
[2, group({ filament_color: '#DE4343' })],
]);
it('prefers the position already loaded with the group colour', () => {
// Reproduces BambuStudio's own pick for this plate: group 2 (red) to R1,
// group 1 (blue) to R2 — dispatched as [16, 1, 17] on 2026-08-14.
const loaded = rack([1, 2, 3, 4, 5, 6], {
1: { filament_color: 'DE4343FF' },
2: { filament_color: '0078BFFF' },
});
expect(autoAssignRackPositions(loaded, groups)).toEqual({ 1: 2, 2: 1 });
});
it('falls back to the lowest free position when no colour matches', () => {
expect(autoAssignRackPositions(rack(), groups)).toEqual({ 1: 1, 2: 2 });
});
it('never reassigns a position the operator pinned', () => {
expect(autoAssignRackPositions(rack(), groups, { 1: 4 })).toEqual({ 1: 4, 2: 1 });
});
it('gives up rather than half-assigning when a group cannot be placed', () => {
expect(autoAssignRackPositions(rack([1]), groups)).toBeNull();
});
it('gives up when a pinned position is no longer eligible', () => {
expect(autoAssignRackPositions(rack([1, 2]), groups, { 1: 5 })).toBeNull();
});
it('assigns nothing, successfully, when no group needs the rack', () => {
const fixedOnly = new Map<number, RackGroupInfo>([[0, group({ on_rack: false })]]);
expect(autoAssignRackPositions(rack([]), fixedOnly)).toEqual({});
});
});
+12
View File
@@ -2429,6 +2429,10 @@ export interface PrintQueueItem {
// Auto-print G-code injection
gcode_injection?: boolean;
cleanup_library_after_dispatch?: boolean;
/** Which rack position each filament group prints from, on a nozzle-rack
* machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
* the live rack at dispatch; omit to have the dispatcher assign them. */
nozzle_rack_choice?: Record<number, number> | null;
}
export interface PrintBatchPlateTarget {
@@ -2534,6 +2538,10 @@ export interface PrintQueueItemCreate {
// defeats the point) and with archive_id/library_file_id (these ARE the files).
// Order is priority — index 0 wins when several printers are idle at once.
variants?: QueueVariantCreate[];
/** Which rack position each filament group prints from, on a nozzle-rack
* machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
* the live rack at dispatch; omit to have the dispatcher assign them. */
nozzle_rack_choice?: Record<number, number> | null;
}
/** One candidate file for a cross-model queue item (#671). */
@@ -2605,6 +2613,10 @@ export interface PrintQueueItemUpdate {
gcode_injection?: boolean;
cost_center_id?: number | null;
estimated_cost?: number | null;
/** Which rack position each filament group prints from, on a nozzle-rack
* machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
* the live rack at dispatch; omit to have the dispatcher assign them. */
nozzle_rack_choice?: Record<number, number> | null;
}
export interface PrintQueueBulkUpdate {
@@ -7,7 +7,8 @@ import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers';
import { getColorName } from '../../utils/colors';
import { useFilamentLabels } from './useFilamentLabels';
import type { FilamentMappingProps } from './types';
import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
import type { FilamentMappingProps, RackGroupInfo } from './types';
/**
* Filament mapping UI for comparing required filaments with loaded AMS slots.
@@ -28,6 +29,8 @@ export function FilamentMapping({
onForceColorMatchChange,
plateLabel,
archiveAmsMapping,
nozzleRackChoice,
onNozzleRackChoiceChange,
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -194,6 +197,41 @@ export function FilamentMapping({
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
// Nozzle rack (#1784). The 3MF names a filament *group* per slot and says
// which groups need a hotend off the rack; which of the six positions each
// takes is the operator's to choose and is stated nowhere in the file. A
// group, not a slot, is the unit of choice — two slots sharing a group share
// one hotend and cannot point at different positions.
const rackGroups = useMemo(() => {
const groups = new Map<number, RackGroupInfo>();
for (const f of filamentReqs?.filaments ?? []) {
if (f.group_id != null && f.group) groups.set(f.group_id, f.group);
}
return groups;
}, [filamentReqs]);
const hasRack = (printerStatus?.nozzle_rack?.some((n) => n.id >= 16) ?? false)
&& [...rackGroups.values()].some((g) => g.on_rack);
// What the dispatcher would assign if nothing were picked, shown as the
// pre-selection so the dialog states what will happen rather than leaving
// every picker blank. Explicit picks are pinned and the rest fill in around
// them, exactly as the backend does it.
const effectiveRackChoice = useMemo(() => {
if (!hasRack) return {};
return (
autoAssignRackPositions(printerStatus?.nozzle_rack, rackGroups, nozzleRackChoice ?? {})
?? (nozzleRackChoice ?? {})
);
}, [hasRack, printerStatus?.nozzle_rack, rackGroups, nozzleRackChoice]);
const pickRackPosition = (groupId: number, position: number) => {
if (!onNozzleRackChoiceChange) return;
// Every group is written back, not just the edited one: leaving the others
// implicit would let the dispatcher re-assign them around the new pick and
// silently move a hotend the operator had already seen and accepted.
onNozzleRackChoiceChange({ ...effectiveRackChoice, [groupId]: position });
};
// Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic
// (any slot can be routed to either extruder), so the per-nozzle dropdown
// filter is suppressed. fila_switch.in_slots[track] = currently fed slot,
@@ -322,7 +360,17 @@ export function FilamentMapping({
<div key={idx} className="space-y-1">
<div
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
style={{
// The rack picker sits inside the required-filament cell, so
// on a rack machine that cell has to carry the name *and* an
// ~85px dropdown. Raising only the floor (not the fraction)
// keeps every other printer's layout exactly as it was, and
// keeps the AMS dropdown — which names type, colour and
// remaining weight — the widest column.
gridTemplateColumns: hasRack
? '16px minmax(210px, 1.4fr) auto 2fr 16px'
: '16px minmax(70px, 1fr) auto 2fr 16px',
}}
>
{/* Required color */}
<span title={`Required: ${resolvedName} - ${colorLabel}`}>
@@ -332,14 +380,44 @@ export function FilamentMapping({
truncates; the gram usage is pinned (shrink-0) so it never
clips on narrow/mobile widths (#2669). */}
<span className="text-white flex items-center gap-1 min-w-0">
{isDualNozzle && item.nozzle_id != null && (
{hasRack && item.group_id != null && item.group ? (
item.group.on_rack ? (
<select
value={effectiveRackChoice[item.group_id] ?? ''}
onChange={(e) => pickRackPosition(item.group_id!, Number(e.target.value))}
disabled={!onNozzleRackChoiceChange}
title={t('printModal.rackPositionTooltip')}
aria-label={t('printModal.rackPosition')}
className="shrink-0 bg-bambu-dark-tertiary text-white text-[10px] font-bold rounded px-1 py-0.5 border border-bambu-dark-tertiary focus:border-bambu-green outline-none disabled:opacity-60"
>
{rackOptionsForGroup(printerStatus?.nozzle_rack, item.group, t).map((option) => (
<option
key={option.position}
value={option.position}
disabled={!option.eligible}
title={option.reason}
>
R{option.position}
{option.diameter ? ` · ${option.diameter}` : ''}
</option>
))}
</select>
) : (
<span
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
title={t('printModal.leftNozzleTooltip')}
>
{t('printModal.leftNozzle')}
</span>
)
) : isDualNozzle && item.nozzle_id != null ? (
<span
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
>
{item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
</span>
)}
) : null}
<span className="truncate min-w-0" title={resolvedName}>{resolvedName}</span>
<span className="text-bambu-gray shrink-0 whitespace-nowrap">({item.used_grams}g)</span>
</span>
+48 -1
View File
@@ -603,6 +603,23 @@ export function PrintModal({
// 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>>>({});
// Rack position per filament group (#1784), and one set per plate for the
// per-plate panels — each plate has its own groups.
const [nozzleRackChoice, setNozzleRackChoice] = useState<Record<number, number>>(() => {
// Re-opening an item shows the positions it was queued with, so editing
// one filament does not silently drop the rest.
if (mode === 'edit-queue-item' && queueItem?.nozzle_rack_choice) {
const seeded: Record<number, number> = {};
for (const [groupId, position] of Object.entries(queueItem.nozzle_rack_choice)) {
const group = Number(groupId);
if (Number.isInteger(group) && Number.isInteger(position)) seeded[group] = position;
}
return seeded;
}
return {};
});
const [nozzleRackChoiceByPlate, setNozzleRackChoiceByPlate] =
useState<Record<number, Record<number, number>>>({});
// Only ever computed for a single target printer: a tray id means nothing on a
// different printer, so a fan-out across printers must not reuse these.
@@ -1132,6 +1149,15 @@ export function PrintModal({
};
// Common queue data for create and edit modes
// One panel per plate when several are selected, one shared panel
// otherwise -- the same split the AMS mappings use above.
const rackChoiceForPlate = (plateId: number | null): Record<number, number> | undefined => {
const choice = plateId != null && isMultiPlateSelection
? nozzleRackChoiceByPlate[plateId]
: nozzleRackChoice;
return choice && Object.keys(choice).length > 0 ? choice : undefined;
};
const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
const plateEstimatedCost =
@@ -1153,6 +1179,11 @@ export function PrintModal({
// re-flag the item on its first dispatch tick (#1698-followup).
skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
ams_mapping: printerId ? getMappingForPrinter(printerId, plateId) : undefined,
// Rack positions per filament group (#1784). Only sent in printer mode:
// in model mode the target printer is not known yet, and the rack it
// will be dispatched to cannot be validated against here. The dispatcher
// assigns them itself in that case.
nozzle_rack_choice: printerId ? rackChoiceForPlate(plateId) : undefined,
plate_id: plateId,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -1245,6 +1276,10 @@ export function PrintModal({
gcode_injection: scheduleOptions.gcodeInjection,
manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
ams_mapping: printerMapping,
// null, not undefined: an operator who cleared their picks
// means "assign these again", and undefined would leave the
// stale ones on the row (#1784).
nozzle_rack_choice: rackChoiceForPlate(plateId) ?? null,
plate_id: plateId,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -1491,8 +1526,14 @@ export function PrintModal({
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={isSubmitting ? undefined : onClose}
>
{/* 4xl rather than the 2xl this was: the filament rows carry the most
horizontal content in the dialog — a required name, a nozzle picker on
rack machines, and an AMS slot dropdown naming type, colour and
remaining weight — and anything narrower truncated the name to
"Bamb..." (#1784). 4xl is 896px, so it still fits a 1024-wide laptop
with the surrounding padding, and `w-full` keeps it fluid below that. */}
<Card
className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
className="w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<CardContent className="p-0">
@@ -1721,6 +1762,8 @@ export function PrintModal({
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
}
archiveAmsMapping={archiveSlicerAmsMapping}
nozzleRackChoice={nozzleRackChoice}
onNozzleRackChoiceChange={setNozzleRackChoice}
/>
)}
@@ -1753,6 +1796,10 @@ export function PrintModal({
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
}
archiveAmsMapping={archiveSlicerAmsMapping}
nozzleRackChoice={nozzleRackChoiceByPlate[plateId] ?? {}}
onNozzleRackChoiceChange={(choice) =>
setNozzleRackChoiceByPlate((prev) => ({ ...prev, [plateId]: choice }))
}
/>
);
})}
@@ -222,9 +222,38 @@ export interface FilamentReqsData {
* = user custom). Used to resolve the "original" filament label in
* FilamentOverride against the builtin + cloud user-preset maps. #1718. */
tray_info_idx?: string;
/** Which filament group this slot prints in, on a nozzle-rack machine
* (#1784). The group is the slicer's logical nozzle, so it — not the slot
* — is what a rack position is chosen for. Absent on every other model. */
group_id?: number;
/** What that group needs of a hotend. Only groups with `on_rack` get a
* position picker; the rest are on the fixed carriage and have no choice
* to make. */
group?: RackGroupInfo;
}>;
}
/** A filament group's hotend requirements, from the 3MF (#1784). */
export interface RackGroupInfo {
on_rack: boolean;
nozzle_diameter: string;
volume_type: string;
filament_color: string;
}
/** One position on the H2C's six-slot nozzle rack, as offered to the user. */
export interface RackPositionOption {
/** 1-based, the way the printer card, BambuStudio and the operator count. */
position: number;
diameter: string;
nozzleType: string;
filamentColor: string;
/** False when the position is empty or holds the wrong nozzle for the group. */
eligible: boolean;
/** Why not, when `eligible` is false — shown as the option's title. */
reason?: string;
}
/**
* Props for the FilamentMapping component.
*/
@@ -257,6 +286,13 @@ export interface FilamentMappingProps {
* auto-match. Undefined/omitted when the archive has no saved mapping —
* the toggle is hidden and behaviour is unchanged. */
archiveAmsMapping?: number[];
/** The operator's rack-position pick per filament group (#1784), keyed by
* group id. Only meaningful on a nozzle-rack model; omit elsewhere and no
* picker is rendered. A group absent from the object is assigned a position
* by the dispatcher against the rack as it stands at dispatch. */
nozzleRackChoice?: Record<number, number>;
/** Called when a rack position is picked for a group. */
onNozzleRackChoiceChange?: (choice: Record<number, number>) => void;
}
/**
+8
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { getColorName } from '../utils/colors';
import type { RackGroupInfo } from '../components/PrintModal/types';
import {
normalizeColor,
normalizeColorForCompare,
@@ -157,6 +158,13 @@ export interface FilamentRequirement {
tray_info_idx?: string;
/** Target nozzle for dual-nozzle printers (0=right, 1=left) */
nozzle_id?: number;
/** Filament group this slot prints in, on a nozzle-rack machine (#1784).
* The group is the slicer's logical nozzle, so it is what a rack position
* gets chosen for. Absent on every other model. */
group_id?: number;
/** What that group needs of a hotend, for filtering the rack positions it
* can be sent to. */
group?: RackGroupInfo;
}
/**
+4
View File
@@ -4925,6 +4925,10 @@ export default {
rightNozzle: 'R',
leftNozzleTooltip: 'Linke Düse',
rightNozzleTooltip: 'Rechte Düse',
rackPosition: 'Wechslerposition',
rackPositionTooltip: 'Welche Düse im Wechsler dieses Filament druckt. Die Positionen sind wie am Drucker nummeriert.',
rackEmptyPosition: 'Diese Wechslerposition ist leer',
rackWrongNozzle: 'Enthält eine {{has}}-Düse; dieses Filament benötigt {{needs}}',
filamentOverride: 'Filament-Überschreibung',
filamentOverrideHint: 'Filamente für modellbasierte Zuweisung optional überschreiben. Der Planer wird gegen die ausgewählten Filamente statt der ursprünglichen 3MF-Werte abgleichen.',
originalFilament: 'Original',
+4
View File
@@ -4968,6 +4968,10 @@ export default {
rightNozzle: 'R',
leftNozzleTooltip: 'Left nozzle',
rightNozzleTooltip: 'Right nozzle',
rackPosition: 'Rack position',
rackPositionTooltip: 'Which nozzle on the rack prints this filament. Positions are numbered as on the printer.',
rackEmptyPosition: 'This rack position is empty',
rackWrongNozzle: 'Holds a {{has}} nozzle; this filament needs {{needs}}',
filamentOverride: 'Filament Override',
filamentOverrideHint: 'Optionally override filaments for model-based assignment. The scheduler will match against your selected filaments instead of the original 3MF values.',
originalFilament: 'Original',
+4
View File
@@ -4932,6 +4932,10 @@ export default {
rightNozzle: 'D',
leftNozzleTooltip: 'Boquilla izquierda',
rightNozzleTooltip: 'Boquilla derecha',
rackPosition: 'Posición del carro',
rackPositionTooltip: 'Qué boquilla del carro imprime este filamento. Las posiciones se numeran como en la impresora.',
rackEmptyPosition: 'Esta posición del carro está vacía',
rackWrongNozzle: 'Tiene una boquilla {{has}}; este filamento necesita {{needs}}',
filamentOverride: 'Anulación de filamento',
filamentOverrideHint: 'Anule opcionalmente los filamentos para la asignación basada en el modelo. El planificador comparará con los filamentos que ha seleccionado en lugar de los valores originales del 3MF.',
originalFilament: 'Original',
+4
View File
@@ -4914,6 +4914,10 @@ export default {
rightNozzle: 'D',
leftNozzleTooltip: 'Buse gauche',
rightNozzleTooltip: 'Buse droite',
rackPosition: 'Position du rack',
rackPositionTooltip: "Quelle buse du rack imprime ce filament. Les positions sont numérotées comme sur l'imprimante.",
rackEmptyPosition: 'Cette position du rack est vide',
rackWrongNozzle: 'Contient une buse {{has}} ; ce filament nécessite {{needs}}',
filamentOverride: 'Remplacement de filament',
filamentOverrideHint: 'Remplacez optionnellement les filaments pour l\'affectation par modèle. Le planificateur utilisera vos filaments sélectionnés au lieu des valeurs 3MF d\'origine.',
originalFilament: 'Original',
+4
View File
@@ -4913,6 +4913,10 @@ export default {
rightNozzle: 'R',
leftNozzleTooltip: 'Ugello sinistro',
rightNozzleTooltip: 'Ugello destro',
rackPosition: 'Posizione nel rack',
rackPositionTooltip: 'Quale ugello del rack stampa questo filamento. Le posizioni sono numerate come sulla stampante.',
rackEmptyPosition: 'Questa posizione del rack è vuota',
rackWrongNozzle: 'Contiene un ugello {{has}}; questo filamento richiede {{needs}}',
filamentOverride: 'Sostituzione filamento',
filamentOverrideHint: 'Sostituisci opzionalmente i filamenti per l\'assegnazione basata sul modello. Lo scheduler abbinerà i filamenti selezionati invece dei valori 3MF originali.',
originalFilament: 'Originale',
+4
View File
@@ -4925,6 +4925,10 @@ export default {
rightNozzle: 'R',
leftNozzleTooltip: '左ノズル',
rightNozzleTooltip: '右ノズル',
rackPosition: 'ラック位置',
rackPositionTooltip: 'このフィラメントを印刷するラック上のノズル。位置番号はプリンター本体と同じです。',
rackEmptyPosition: 'このラック位置は空です',
rackWrongNozzle: '{{has}} ノズルが装着されています。このフィラメントには {{needs}} が必要です',
filamentOverride: 'フィラメントオーバーライド',
filamentOverrideHint: 'モデルベースの割り当てに使用するフィラメントをオプションで上書きします。スケジューラは元の3MF値ではなく、選択したフィラメントに基づいてマッチングします。',
originalFilament: 'オリジナル',
+4
View File
@@ -4695,6 +4695,10 @@ export default {
rightNozzle: 'R',
leftNozzleTooltip: '왼쪽 노즐',
rightNozzleTooltip: '오른쪽 노즐',
rackPosition: '랙 위치',
rackPositionTooltip: '이 필라멘트를 출력할 랙의 노즐입니다. 위치 번호는 프린터와 동일합니다.',
rackEmptyPosition: '이 랙 위치는 비어 있습니다',
rackWrongNozzle: '{{has}} 노즐이 장착되어 있습니다. 이 필라멘트에는 {{needs}}이(가) 필요합니다',
filamentOverride: '필라멘트 재정의',
filamentOverrideHint: '선택적으로 모델 기반 할당을 위해 필라멘트를 재정의합니다. 스케줄러는 원래 3MF 값 대신 선택한 필라멘트와 일치시킵니다.',
originalFilament: '원래',
+4
View File
@@ -4913,6 +4913,10 @@ export default {
rightNozzle: 'R',
leftNozzleTooltip: 'Bico esquerdo',
rightNozzleTooltip: 'Bico direito',
rackPosition: 'Posição no rack',
rackPositionTooltip: 'Qual bico do rack imprime este filamento. As posições são numeradas como na impressora.',
rackEmptyPosition: 'Esta posição do rack está vazia',
rackWrongNozzle: 'Tem um bico {{has}}; este filamento precisa de {{needs}}',
filamentOverride: 'Substituição de Filamento',
filamentOverrideHint: 'Substitua opcionalmente os filamentos para atribuição baseada em modelo. O agendador usará os filamentos selecionados em vez dos valores originais do 3MF.',
originalFilament: 'Original',
+4
View File
@@ -4684,6 +4684,10 @@ export default {
rightNozzle: "П",
leftNozzleTooltip: "Левое сопло",
rightNozzleTooltip: "Правое сопло",
rackPosition: "Позиция в стойке",
rackPositionTooltip: "Каким соплом из стойки печатать этот филамент. Позиции нумеруются так же, как на принтере.",
rackEmptyPosition: "Эта позиция в стойке пуста",
rackWrongNozzle: "Установлено сопло {{has}}; этому филаменту нужно {{needs}}",
filamentOverride: "Переопределение филаментов",
filamentOverrideHint: "При необходимости замените филаменты для назначения по модели. Планировщик будет сопоставлять выбранные филаменты вместо исходных значений из 3MF.",
originalFilament: "Исходный",
+4
View File
@@ -4902,6 +4902,10 @@ export default {
rightNozzle: 'R',
leftNozzleTooltip: 'Sol nozul',
rightNozzleTooltip: 'Sağ nozul',
rackPosition: 'Rack konumu',
rackPositionTooltip: 'Bu filamenti rack üzerindeki hangi nozulun basacağı. Konumlar yazıcıdaki gibi numaralandırılmıştır.',
rackEmptyPosition: 'Bu rack konumu boş',
rackWrongNozzle: '{{has}} nozul takılı; bu filament {{needs}} gerektiriyor',
filamentOverride: 'Filament Geçersiz Kılma',
filamentOverrideHint: 'Model tabanlı atama için isteğe bağlı olarak filamentleri geçersiz kıl. Planlayıcı, orijinal 3MF değerleri yerine seçtiğiniz filamentlere göre eşleşecek.',
originalFilament: 'Orijinal',
+4
View File
@@ -4967,6 +4967,10 @@ export default {
rightNozzle: "Р",
leftNozzleTooltip: "Ліве сопло",
rightNozzleTooltip: "Праве сопло",
rackPosition: "Позиція у стійці",
rackPositionTooltip: "Яким соплом зі стійки друкувати цей філамент. Позиції нумеруються так само, як на принтері.",
rackEmptyPosition: "Ця позиція у стійці порожня",
rackWrongNozzle: "Встановлено сопло {{has}}; цьому філаменту потрібно {{needs}}",
filamentOverride: "Перевизначення філаменту",
filamentOverrideHint: "Необов’язково замініть філаменти для призначення на основі моделі. Планувальник зіставлятиметься з вибраними філаментами замість вихідних значень 3MF.",
originalFilament: "Оригінал",
+4
View File
@@ -4913,6 +4913,10 @@ export default {
rightNozzle: '右',
leftNozzleTooltip: '左喷嘴',
rightNozzleTooltip: '右喷嘴',
rackPosition: '刀架位置',
rackPositionTooltip: '由刀架上的哪个喷嘴打印此耗材。位置编号与打印机上一致。',
rackEmptyPosition: '此刀架位置为空',
rackWrongNozzle: '装有 {{has}} 喷嘴;此耗材需要 {{needs}}',
filamentOverride: '耗材覆盖',
filamentOverrideHint: '可选覆盖用于基于模型的耗材分配。调度器将使用您选择的耗材而不是原始 3MF 值进行匹配。',
originalFilament: '原始',
+4
View File
@@ -4913,6 +4913,10 @@ export default {
rightNozzle: '右',
leftNozzleTooltip: '左噴嘴',
rightNozzleTooltip: '右噴嘴',
rackPosition: '刀架位置',
rackPositionTooltip: '由刀架上的哪個噴嘴列印此耗材。位置編號與印表機上一致。',
rackEmptyPosition: '此刀架位置為空',
rackWrongNozzle: '裝有 {{has}} 噴嘴;此耗材需要 {{needs}}',
filamentOverride: '耗材覆蓋',
filamentOverrideHint: '可選覆蓋用於基於模型的耗材分配。排程器將使用您選擇的耗材而不是原始 3MF 值進行匹配。',
originalFilament: '原始',
+173
View File
@@ -0,0 +1,173 @@
import type { NozzleRackSlot } from '../api/client';
import type { RackGroupInfo, RackPositionOption } from '../components/PrintModal/types';
/**
* H2C nozzle-rack position helpers (#1784).
*
* A rack position as everyone counts it — the printer card, BambuStudio, the
* operator — is 1-based, and the physical nozzle ID the printer is sent is 15
* higher. Measured 2026-08-14: the same plate dispatched with R1+R2 picked sent
* `[16, 1, 17]`, and with R1+R3 picked sent `[16, 1, 18]`.
*
* The eligibility rule here mirrors `_rack_slot_is_eligible` in
* `backend/app/services/bambu_mqtt.py`. It is duplicated rather than fetched
* because the picker has to grey out an option as the user looks at it, but the
* backend re-checks the same rule at dispatch and refuses the print if it no
* longer holds — so this copy being briefly stale can only mislead, never
* misprint.
*/
/** Physical nozzle IDs of the six rack positions. */
export const RACK_POSITION_BASE = 15;
export const RACK_SIZE = 6;
export const RACK_POSITIONS = Array.from({ length: RACK_SIZE }, (_, i) => i + 1);
/** The two carriage entries the printer reports alongside the rack itself. */
const RACK_CARRIAGE_NOZZLE_ID = 0;
export function rackPositionToNozzleId(position: number): number | null {
if (!Number.isInteger(position) || position < 1 || position > RACK_SIZE) return null;
return RACK_POSITION_BASE + position;
}
/**
* Live rack contents by 1-based position, including the mounted nozzle.
*
* The firmware omits a rack ID entirely while that nozzle is picked up onto the
* carriage (#943) rather than sending an empty placeholder, so taking the gap at
* face value would grey out the nozzle most likely to be wanted — the one the
* last print left mounted. A single gap alongside a loaded carriage is that
* carriage's nozzle; two or more gaps are genuinely ambiguous (four nozzles in
* six positions looks identical) and stay absent.
*/
export function rackByPosition(slots: NozzleRackSlot[] | undefined): Map<number, NozzleRackSlot> {
const byPosition = new Map<number, NozzleRackSlot>();
let carriage: NozzleRackSlot | undefined;
for (const slot of slots ?? []) {
if (slot.id === RACK_CARRIAGE_NOZZLE_ID) {
carriage = slot;
continue;
}
const position = slot.id - RACK_POSITION_BASE;
if (position >= 1 && position <= RACK_SIZE) byPosition.set(position, slot);
}
const missing = RACK_POSITIONS.filter((p) => !byPosition.has(p));
if (missing.length === 1 && carriage && (carriage.nozzle_diameter || carriage.nozzle_type)) {
byPosition.set(missing[0], carriage);
}
return byPosition;
}
/** Whether a live rack slot can print a group wanting this nozzle. */
export function isRackSlotEligible(
slot: NozzleRackSlot | undefined,
group: Pick<RackGroupInfo, 'nozzle_diameter' | 'volume_type'>,
): boolean {
if (!slot) return false;
if (!slot.nozzle_diameter && !slot.nozzle_type) return false;
// "0.40" and "0.4" name the same nozzle — the 3MF pads, the printer does not.
const slotDiameter = Number.parseFloat(slot.nozzle_diameter);
const wantedDiameter = Number.parseFloat(group.nozzle_diameter);
if (!Number.isFinite(slotDiameter) || !Number.isFinite(wantedDiameter)) return false;
if (Math.abs(slotDiameter - wantedDiameter) > 0.005) return false;
// Flow type: the printer reports a code ("HS", "HH01"), the slice a name
// ("High Flow"). Compared only when both are stated, so a printer that omits
// the code is not thereby ruled out.
const wanted = (group.volume_type || '').trim().toLowerCase();
if (wanted && slot.nozzle_type) {
const isHighFlow = slot.nozzle_type.toUpperCase().startsWith('HH');
if (wanted.startsWith('high flow') !== isHighFlow) return false;
}
return true;
}
/**
* The six positions as pickable options for one group, each with the reason it
* cannot be used when it cannot. Always returns all six: an operator looking for
* position 4 should find it greyed out with an explanation, not missing.
*/
export function rackOptionsForGroup(
slots: NozzleRackSlot[] | undefined,
group: RackGroupInfo,
translate: (key: string, opts?: Record<string, unknown>) => string,
): RackPositionOption[] {
const byPosition = rackByPosition(slots);
return RACK_POSITIONS.map((position) => {
const slot = byPosition.get(position);
const eligible = isRackSlotEligible(slot, group);
let reason: string | undefined;
if (!eligible) {
reason = slot
? translate('printModal.rackWrongNozzle', {
has: `${slot.nozzle_diameter || '?'} ${slot.nozzle_type || ''}`.trim(),
needs: `${group.nozzle_diameter} ${group.volume_type}`.trim(),
})
: translate('printModal.rackEmptyPosition');
}
return {
position,
diameter: slot?.nozzle_diameter ?? '',
nozzleType: slot?.nozzle_type ?? '',
filamentColor: slot?.filament_color ?? '',
eligible,
reason,
};
});
}
/**
* A position for every rack-bound group, preferring one already loaded with the
* group's own colour. Mirrors the dispatcher's assignment so the dialog shows
* what will actually happen rather than leaving every picker blank.
*
* Returns null when some group cannot be placed — the caller then leaves the
* pickers empty and lets the backend fall back, rather than showing a partial
* assignment that reads as a decision.
*/
export function autoAssignRackPositions(
slots: NozzleRackSlot[] | undefined,
groups: Map<number, RackGroupInfo>,
pinned: Record<number, number> = {},
): Record<number, number> | null {
const byPosition = rackByPosition(slots);
const assigned: Record<number, number> = {};
const taken = new Set<number>();
const rackGroupIds = [...groups.keys()].filter((id) => groups.get(id)!.on_rack).sort((a, b) => a - b);
// Explicit picks are placed first so an auto-assignment yields to them
// instead of claiming a position the operator asked for.
for (const groupId of rackGroupIds) {
const position = pinned[groupId];
if (position == null) continue;
if (taken.has(position) || !isRackSlotEligible(byPosition.get(position), groups.get(groupId)!)) return null;
assigned[groupId] = position;
taken.add(position);
}
for (const groupId of rackGroupIds) {
if (assigned[groupId] != null) continue;
const group = groups.get(groupId)!;
const eligible = RACK_POSITIONS.filter((p) => !taken.has(p) && isRackSlotEligible(byPosition.get(p), group));
if (eligible.length === 0) return null;
const wanted = normaliseColor(group.filament_color);
const preferred = wanted
? eligible.find((p) => normaliseColor(byPosition.get(p)?.filament_color) === wanted)
: undefined;
const chosen = preferred ?? eligible[0];
assigned[groupId] = chosen;
taken.add(chosen);
}
return assigned;
}
/** Hex colours arrive as `#RRGGBB` from the 3MF and `RRGGBBAA` from the printer. */
function normaliseColor(value: string | undefined): string {
return (value ?? '').trim().replace(/^#/, '').slice(0, 6).toUpperCase();
}