mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user