mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
When the printer reports ams_filament_backup=True, compute_deficit_for_queue_item pools remaining_grams across spools matching (preset, colour) on the same printer (scoped per extruder on dual-nozzle) before declaring a per-slot shortfall. Identity is strict: same slicer_filament preset AND same colour (alpha-normalised). Two PETG HF spools in different colours are NOT pooled — the firmware would swap correctly but the print would change colour mid-run. Spoolman side mirrors the rule via filament.id + color_hex. Backup OFF falls back to the pre-PR per-slot accounting line-for-line. 8 new test cases in TestFilamentDeficitBackupAware pin pool covers, pool insufficient, different presets, backup-OFF regression, dual- extruder side scoping, no-preset never pairs, colour-strict, and alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py cases stay green. feat(printers): AMS Filament Backup modal with BS-style ring per pair Badge click on the Filaments section header (#1766) now opens a modal: filament-colour ring per backup pair, material name + rotation count in the centre, slot labels distributed around the colour band on contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill widget. Lone slots are intentionally not listed. R / L badges per ring when the extruder map carries two distinct values; collapses to no- badge rendering for single-nozzle printers misflagged as dual. Esc keypress closes the modal. Theme-aware via CSS variables matching AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers defensively dedupes duplicate ams.id entries observed on switch-VP aggregations. 10 modal render cases pin: Esc closes / unmount nulls the listener / ring renders for pairs and omits lone slots / R-L badges only when extruder map has distinct values / empty state / toggle gating. 13 frontend cases pin computeBackupGroups identity rules. feat(printers): active-print P-N pill on AMS slot tiles during RUNNING While the printer is mid-print, each AMS slot tile referenced by status.ams_mapping carries a small "P1 / P2 / P3" pill in the top- right corner, naming which print-slot is mapped to that AMS slot. Catches the #1762 comment-2 scenario: a queue job set for "any X1C" staged to a printer with mismatched filament, no way to verify mid- print. Same wire data (status.ams_mapping is already on the wire) — the addition is purely surface. The existing ring-bambu-green highlight for effectiveTrayNow keeps its meaning (currently extruding RIGHT NOW); the pill is the per-slot static assignment for the active print. chore(scheduler): log Print Anyway short-circuit at INFO _block_on_filament_deficit logs at INFO when it honours item.skip_filament_check, so a future "Print Anyway didn't work" report (third commenter on #1762 hit this shape) has actionable evidence in the standard support bundle without DEBUG. Bundled because the deficit fix makes the original symptom disappear for users with backup ON.
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
/**
|
|
* AMS (Automatic Material System) helper utilities for Bambu Lab printers.
|
|
* These functions handle color normalization, slot labeling, and tray ID calculations
|
|
* for AMS, AMS-HT, and external spool configurations.
|
|
*/
|
|
import { parseUTCDate } from './date';
|
|
|
|
/**
|
|
* Normalize color format from various sources for CSS rendering.
|
|
* API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
|
|
* Result is "#RRGGBB" for opaque colors and "#RRGGBBAA" when alpha < FF —
|
|
* CSS accepts both forms on `fill` / `backgroundColor`, and preserving alpha
|
|
* lets transparent filaments render translucent instead of collapsing to
|
|
* solid black (#1545). Comparison helpers use normalizeColorForCompare which
|
|
* still strips alpha, so type/colour matching is unaffected.
|
|
*/
|
|
export function normalizeColor(color: string | null | undefined): string {
|
|
if (!color) return '#808080';
|
|
const clean = color.replace('#', '');
|
|
if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
|
|
return `#${clean.substring(0, 8)}`;
|
|
}
|
|
return `#${clean.substring(0, 6)}`;
|
|
}
|
|
|
|
/**
|
|
* Normalize color for comparison (case-insensitive, strip hash and alpha).
|
|
*/
|
|
export function normalizeColorForCompare(color: string | undefined): string {
|
|
if (!color) return '';
|
|
return color.replace('#', '').toLowerCase().substring(0, 6);
|
|
}
|
|
|
|
/**
|
|
* Filament type equivalence groups.
|
|
* Types within the same group are interchangeable on the printer side
|
|
* (e.g., Bambu Lab firmware treats PA-CF and PA12-CF as compatible).
|
|
*/
|
|
const FILAMENT_TYPE_GROUPS: string[][] = [
|
|
['PA-CF', 'PA12-CF', 'PAHT-CF'],
|
|
];
|
|
|
|
const _equivalenceMap: Record<string, string> = {};
|
|
for (const group of FILAMENT_TYPE_GROUPS) {
|
|
const canonical = group[0];
|
|
for (const t of group) {
|
|
_equivalenceMap[t.toUpperCase()] = canonical.toUpperCase();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the canonical filament type for equivalence matching.
|
|
* Types in the same group (e.g., PA-CF / PA12-CF / PAHT-CF) return the same canonical type.
|
|
*/
|
|
export function canonicalFilamentType(type: string | undefined): string {
|
|
if (!type) return '';
|
|
const upper = type.toUpperCase();
|
|
return _equivalenceMap[upper] ?? upper;
|
|
}
|
|
|
|
/**
|
|
* Check if two filament types are compatible (same type or same equivalence group).
|
|
*/
|
|
export function filamentTypesCompatible(a: string | undefined, b: string | undefined): boolean {
|
|
return canonicalFilamentType(a) === canonicalFilamentType(b);
|
|
}
|
|
|
|
/**
|
|
* Check if two colors are visually similar within a threshold.
|
|
* Uses RGB component comparison with configurable tolerance.
|
|
* @param color1 - First hex color
|
|
* @param color2 - Second hex color
|
|
* @param threshold - Maximum difference per RGB component (default: 40)
|
|
*/
|
|
export function colorsAreSimilar(
|
|
color1: string | undefined,
|
|
color2: string | undefined,
|
|
threshold = 40
|
|
): boolean {
|
|
const hex1 = normalizeColorForCompare(color1);
|
|
const hex2 = normalizeColorForCompare(color2);
|
|
if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
|
|
|
|
const r1 = parseInt(hex1.substring(0, 2), 16);
|
|
const g1 = parseInt(hex1.substring(2, 4), 16);
|
|
const b1 = parseInt(hex1.substring(4, 6), 16);
|
|
const r2 = parseInt(hex2.substring(0, 2), 16);
|
|
const g2 = parseInt(hex2.substring(2, 4), 16);
|
|
const b2 = parseInt(hex2.substring(4, 6), 16);
|
|
|
|
return (
|
|
Math.abs(r1 - r2) <= threshold &&
|
|
Math.abs(g1 - g2) <= threshold &&
|
|
Math.abs(b1 - b2) <= threshold
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Format slot label for display in the UI.
|
|
* @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
|
|
* @param trayId - Tray/slot ID within the AMS unit (0-3)
|
|
* @param isHt - Whether this is an AMS-HT unit (single tray)
|
|
* @param isExternal - Whether this is the external spool holder
|
|
*/
|
|
export function formatSlotLabel(
|
|
amsId: number,
|
|
trayId: number,
|
|
isHt: boolean,
|
|
isExternal: boolean
|
|
): string {
|
|
if (isExternal) return 'Ext';
|
|
// Convert AMS ID to letter (A, B, C, D)
|
|
// AMS-HT uses IDs starting at 128
|
|
const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
|
|
if (isHt) return `HT-${letter}`;
|
|
return `${letter}${trayId + 1}`;
|
|
}
|
|
|
|
/**
|
|
* Calculate global tray ID for MQTT command.
|
|
* Used in the ams_mapping array sent to the printer.
|
|
* @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
|
|
* @param trayId - Tray/slot ID within the AMS unit
|
|
* @param isExternal - Whether this is the external spool holder
|
|
* @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)
|
|
*/
|
|
export function getGlobalTrayId(
|
|
amsId: number,
|
|
trayId: number,
|
|
isExternal: boolean
|
|
): number {
|
|
if (isExternal) return 254 + trayId;
|
|
// AMS-HT units have IDs starting at 128 with a single tray — use ID directly
|
|
if (amsId >= 128) return amsId;
|
|
return amsId * 4 + trayId;
|
|
}
|
|
|
|
/**
|
|
* Get fill bar color based on spool fill level.
|
|
* Matches PrintersPage thresholds and Bambu Lab brand green.
|
|
*/
|
|
export function getFillBarColor(fillLevel: number): string {
|
|
if (fillLevel > 50) return '#00ae42'; // Green - good
|
|
if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
|
|
return '#ef4444'; // Red - critical (< 15%)
|
|
}
|
|
|
|
/**
|
|
* Calculate fill level from Spoolman weight data.
|
|
* Used as the first source in the Spoolman → Inventory → AMS fill chain.
|
|
*/
|
|
export function getSpoolmanFillLevel(
|
|
linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined
|
|
): number | null {
|
|
if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
|
|
|| linkedSpool.filament_weight <= 0) return null;
|
|
return Math.min(100, Math.round(
|
|
(linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
|
|
));
|
|
}
|
|
|
|
function toFixedHex(value: number, width: number): string {
|
|
const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
|
|
}
|
|
|
|
// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
|
|
function hashSerialToHex32(serial: string): string {
|
|
const input = (serial || '').trim().toUpperCase();
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < input.length; i++) {
|
|
hash ^= input.charCodeAt(i);
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
|
|
}
|
|
|
|
/**
|
|
* Generate a stable fallback spool tag for slots without RFID identifiers.
|
|
* Returns a 16-char hex string derived from the printer serial + slot position.
|
|
*/
|
|
export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
|
|
return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
|
|
}
|
|
|
|
/**
|
|
* Get minimum datetime for scheduling (now + 1 minute).
|
|
* Returns ISO string format for datetime-local input.
|
|
*/
|
|
export function getMinDateTime(): string {
|
|
const now = new Date();
|
|
now.setMinutes(now.getMinutes() + 1);
|
|
return now.toISOString().slice(0, 16);
|
|
}
|
|
|
|
/**
|
|
* Check if a scheduled time is a placeholder far-future date.
|
|
* Placeholder dates (more than 6 months out) are treated as ASAP.
|
|
*/
|
|
export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
|
|
if (!scheduledTime) return false;
|
|
const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
|
|
return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
|
|
}
|
|
|
|
/**
|
|
* Banding tie-break for `preferLowestSortKey`, mirroring backend
|
|
* `PrintScheduler._slot_priority` so regular AMS < AMS-HT < external on ties
|
|
* regardless of the raw `ams_id`. In particular, `ams_id = -1` (VT / external
|
|
* in `buildLoadedFilaments`) MUST NOT sort to a negative number or it would
|
|
* beat AMS slot 0 — backend clamps to 10_000.
|
|
*/
|
|
function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
|
|
if (amsId == null || amsId < 0) return 10_000;
|
|
if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
|
|
return amsId * 4 + (trayId ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Two-tier sort key for the "Prefer Lowest Remaining Filament" preference (#1766).
|
|
*
|
|
* Mirrors backend `_prefer_lowest_sort_key` in `print_scheduler.py:1161` so the
|
|
* client-side sort that PrintModal pre-computes lines up with the dispatch-time
|
|
* sort. Inventory-bound spools sort before MQTT-only ones (tier 0 vs tier 1) so
|
|
* the user's tracked grams beat the printer's per-cent estimate; within each
|
|
* tier the lowest value wins, with the slot-position tie-break above so the
|
|
* order is deterministic across identical spools.
|
|
*
|
|
* `inventoryByTrayId` is the `globalTrayId -> grams_remaining` map derived from
|
|
* the user's spool assignments. Pass `undefined` to fall back to remain%-only
|
|
* sorting (preserves pre-#1766 behaviour for callers that don't yet wire it in).
|
|
*/
|
|
export function preferLowestSortKey(
|
|
f: { globalTrayId: number; amsId?: number; trayId?: number; remain?: number },
|
|
inventoryByTrayId: Map<number, number> | undefined,
|
|
): [number, number, number] {
|
|
const slot = slotPriority(f.amsId, f.trayId);
|
|
if (inventoryByTrayId && inventoryByTrayId.has(f.globalTrayId)) {
|
|
return [0, inventoryByTrayId.get(f.globalTrayId) ?? 0, slot];
|
|
}
|
|
const remain = f.remain ?? -1;
|
|
return [1, remain >= 0 ? remain : 101, slot];
|
|
}
|
|
|
|
/** Tuple compare for `preferLowestSortKey` outputs. */
|
|
export function compareSortKeys(
|
|
a: [number, number, number],
|
|
b: [number, number, number],
|
|
): number {
|
|
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
}
|
|
|
|
/**
|
|
* Effective "Prefer lowest remaining filament" preference for a given printer,
|
|
* gated on its AMS Filament Backup state (#1766).
|
|
*
|
|
* Without backup, the printer can't switch to a second spool when the picked
|
|
* one runs out — so even with the user setting on, sorting toward the lowest
|
|
* leaves the print at risk. Mirrors the backend gate in
|
|
* `print_scheduler.py::_compute_ams_mapping_for_printer`. `null`/`undefined`
|
|
* (unknown state, e.g. A1 family) preserves today's behaviour intentionally.
|
|
*/
|
|
export function effectivePreferLowest(
|
|
setting: boolean | undefined,
|
|
amsFilamentBackup: boolean | null | undefined,
|
|
): boolean {
|
|
if (!setting) return false;
|
|
return amsFilamentBackup !== false;
|
|
}
|
|
|
|
/**
|
|
* Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
|
|
* Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
|
|
*/
|
|
export function autoMatchFilament(
|
|
req: { type?: string; color?: string; nozzle_id?: number | null },
|
|
loadedFilaments: { globalTrayId: number; amsId?: number; trayId?: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
|
|
usedTrayIds: Set<number>,
|
|
preferLowest?: boolean,
|
|
inventoryByTrayId?: Map<number, number>,
|
|
): typeof loadedFilaments[number] | undefined {
|
|
let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
|
|
|
|
if (preferLowest) {
|
|
nozzleFilaments = [...nozzleFilaments].sort((a, b) =>
|
|
compareSortKeys(
|
|
preferLowestSortKey(a, inventoryByTrayId),
|
|
preferLowestSortKey(b, inventoryByTrayId),
|
|
),
|
|
);
|
|
}
|
|
|
|
const exactMatch = nozzleFilaments.find(
|
|
(f) =>
|
|
!usedTrayIds.has(f.globalTrayId) &&
|
|
filamentTypesCompatible(f.type, req.type) &&
|
|
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
|
);
|
|
const similarMatch = exactMatch
|
|
? undefined
|
|
: nozzleFilaments.find(
|
|
(f) =>
|
|
!usedTrayIds.has(f.globalTrayId) &&
|
|
filamentTypesCompatible(f.type, req.type) &&
|
|
colorsAreSimilar(f.color, req.color)
|
|
);
|
|
const typeOnlyMatch =
|
|
exactMatch || similarMatch
|
|
? undefined
|
|
: nozzleFilaments.find(
|
|
(f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type)
|
|
);
|
|
return exactMatch ?? similarMatch ?? typeOnlyMatch;
|
|
}
|
|
|
|
/**
|
|
* Filter loaded filaments to those valid for a given nozzle requirement.
|
|
* For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.
|
|
*/
|
|
export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
|
|
loadedFilaments: T[],
|
|
nozzleId: number | undefined | null,
|
|
): T[] {
|
|
return loadedFilaments.filter(
|
|
(f) => nozzleId == null || f.extruderId === nozzleId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
|
|
*
|
|
* Permissive zero-string check: any non-zero non-empty value returns true. The
|
|
* function exists to suppress assign/unassign actions on RFID-managed slots
|
|
* whose state is owned by the printer firmware — manual changes there would be
|
|
* overwritten on the next RFID re-read (eye → pen icon in BambuStudio).
|
|
*/
|
|
export function isBambuLabSpool(tray: {
|
|
tray_uuid?: string | null;
|
|
tag_uid?: string | null;
|
|
} | null | undefined): boolean {
|
|
if (!tray) return false;
|
|
if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') return true;
|
|
if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
|
|
return false;
|
|
}
|
|
|
|
export interface AmsTrayLike {
|
|
id: number;
|
|
tray_type: string | null | undefined;
|
|
tray_sub_brands: string | null | undefined;
|
|
tray_color: string | null | undefined;
|
|
tray_info_idx: string | null | undefined;
|
|
}
|
|
|
|
export interface AmsUnitLike {
|
|
id: number;
|
|
tray: AmsTrayLike[];
|
|
}
|
|
|
|
/**
|
|
* One row in the AMS Backup modal: a group of slots that back each other up
|
|
* (length >= 2), or a single non-empty slot with no peer (length === 1).
|
|
*/
|
|
export interface BackupGroup {
|
|
/** Stable key — same across renders for the same material+extruder. */
|
|
key: string;
|
|
/** Bambu preset ID (tray_info_idx) when matched on preset; null otherwise. */
|
|
presetId: string | null;
|
|
/** 0 = right / single, 1 = left. Scoping field for dual-nozzle. */
|
|
extruder: number;
|
|
/** Display name from the first slot's tray_sub_brands (or tray_type). */
|
|
displayName: string;
|
|
/** Tray colour from the first slot, for the swatch in the modal. */
|
|
trayColor: string | null;
|
|
/** Member slots, in (ams_id, slot_idx) order. */
|
|
members: Array<{ amsId: number; slotIdx: number; globalTrayId: number }>;
|
|
}
|
|
|
|
/**
|
|
* Canonicalise a hex colour for identity comparison. Mirrors the backend
|
|
* `_normalize_color_for_id`. Strips the leading `#`, uppercases, and drops
|
|
* the alpha channel when 8 chars long so `1A1A1AFF` matches `1A1A1A`.
|
|
*/
|
|
function normalizeColorForId(raw: string | null | undefined): string {
|
|
let s = (raw || '').trim().replace(/^#/, '').toUpperCase();
|
|
if (s.length === 8) s = s.slice(0, 6);
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Compute backup pairs for the AMS Backup modal (#1762).
|
|
*
|
|
* Strict identity rule (mirrors backend `_material_identity_internal` /
|
|
* `_material_identity_spoolman`): slots pair ONLY when they share the same
|
|
* Bambu preset ID (`tray_info_idx`, e.g. "GFA00") AND the same colour. The
|
|
* preset identifies the filament profile (PETG HF, PLA Basic, etc.); the
|
|
* colour pins the variant — three PETG HF spools in different colours
|
|
* absolutely don't back each other up. User-tagged spools without a preset
|
|
* never pair — Bambu's firmware backup logic relies on the preset, and
|
|
* pairing on cosmetic name/colour match alone would let two visually-
|
|
* identical but materially-different spools be treated as backups.
|
|
*
|
|
* Empty slots are skipped entirely. Every non-empty slot is returned — slots
|
|
* without a peer come back as 1-member entries so the modal can list them as
|
|
* "Slots without a backup peer".
|
|
*
|
|
* On dual-extruder printers (H2D / H2C / X2D), pairs are scoped per extruder
|
|
* side — the firmware can't cross extruders even with the global backup bit
|
|
* set.
|
|
*/
|
|
export function computeBackupGroups(
|
|
amsUnits: AmsUnitLike[] | undefined,
|
|
amsExtruderMap: Record<string, number> | undefined,
|
|
isDualNozzle: boolean,
|
|
): BackupGroup[] {
|
|
if (!amsUnits || amsUnits.length === 0) return [];
|
|
|
|
// Defensive dedup: ``status.ams`` is expected to be unique by `ams.id`, but
|
|
// observed in the wild to occasionally contain duplicate entries (e.g. on
|
|
// VP-aggregated switch printers or during MQTT partial-update merges). A
|
|
// duplicate would surface as "AMS-A slot 1" rendered twice with different
|
|
// materials, which is impossible physically and visually broken. First
|
|
// occurrence per `ams.id` wins.
|
|
const seenIds = new Set<number>();
|
|
const uniqueAms: AmsUnitLike[] = [];
|
|
for (const ams of amsUnits) {
|
|
if (seenIds.has(ams.id)) continue;
|
|
seenIds.add(ams.id);
|
|
uniqueAms.push(ams);
|
|
}
|
|
|
|
const byKey = new Map<string, BackupGroup>();
|
|
|
|
for (const ams of uniqueAms) {
|
|
const extruder = isDualNozzle ? Number(amsExtruderMap?.[String(ams.id)] ?? 0) : 0;
|
|
ams.tray.forEach((tray, slotIdx) => {
|
|
if (!tray?.tray_type) return; // empty slot
|
|
const preset = (tray.tray_info_idx || '').trim();
|
|
const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
|
|
const member = { amsId: ams.id, slotIdx, globalTrayId };
|
|
|
|
let key: string;
|
|
let presetId: string | null;
|
|
if (preset) {
|
|
// Same Bambu profile is necessary but NOT sufficient — different colours
|
|
// of the same PETG HF profile can't back each other up. Bake the colour
|
|
// into the identity key, normalised to strip alpha and case.
|
|
const color = normalizeColorForId(tray.tray_color);
|
|
key = `preset:${preset}|color:${color}#${extruder}`;
|
|
presetId = preset;
|
|
} else {
|
|
// No preset → never group with anything else. Unique-per-slot key.
|
|
key = `unmatched:${ams.id}:${slotIdx}#${extruder}`;
|
|
presetId = null;
|
|
}
|
|
|
|
const existing = byKey.get(key);
|
|
if (existing) {
|
|
existing.members.push(member);
|
|
} else {
|
|
byKey.set(key, {
|
|
key,
|
|
presetId,
|
|
extruder,
|
|
displayName: tray.tray_sub_brands || tray.tray_type || '',
|
|
trayColor: tray.tray_color ?? null,
|
|
members: [member],
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Stable sort: extruder first (so the modal can section per side on
|
|
// dual-nozzle), then pairs before lone slots, then by name, then by first
|
|
// member's global tray id for deterministic rendering.
|
|
return Array.from(byKey.values()).sort((a, b) => {
|
|
if (a.extruder !== b.extruder) return a.extruder - b.extruder;
|
|
const aLone = a.members.length === 1 ? 1 : 0;
|
|
const bLone = b.members.length === 1 ? 1 : 0;
|
|
if (aLone !== bLone) return aLone - bLone;
|
|
if (a.displayName !== b.displayName) return a.displayName.localeCompare(b.displayName);
|
|
return a.members[0].globalTrayId - b.members[0].globalTrayId;
|
|
});
|
|
}
|