useFilamentMapping.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import { useMemo } from 'react';
  2. import { getColorName } from '../utils/colors';
  3. import type { RackGroupInfo } from '../components/PrintModal/types';
  4. import {
  5. normalizeColor,
  6. normalizeColorForCompare,
  7. colorsAreSimilar,
  8. filamentTypesCompatible,
  9. findNearestSimilar,
  10. formatSlotLabel,
  11. getGlobalTrayId,
  12. preferLowestSortKey,
  13. compareSortKeys,
  14. } from '../utils/amsHelpers';
  15. import type { PrinterStatus, SlotSpoolIdentity } from '../api/client';
  16. /** Global-tray-id → the identity of the spool Bambuddy has bound to that slot. */
  17. export type SlotSpoolIdentities = Map<number, SlotSpoolIdentity>;
  18. /**
  19. * Name a slot after the spool bound to it, the way the printer card does.
  20. *
  21. * Telemetry can only ever say "PLA" plus a colour hex for anything that isn't
  22. * a Bambu spool: the tray record has no brand field, `tray_sub_brands` stays
  23. * empty, and the hex gets resolved against Bambu's own colour catalogue. So a
  24. * Devil Design PLA Basic Orange the operator assigned in Bambuddy read back as
  25. * "PLA (Sunflower Yellow)" here while the printer card named it correctly.
  26. *
  27. * Returns null when the slot has no binding or the binding says nothing
  28. * useful, and the caller keeps the telemetry description it had before.
  29. */
  30. function spoolDisplayName(identity: SlotSpoolIdentity | undefined): string | null {
  31. if (!identity) return null;
  32. // Same order the hover card prints: brand, then material, then subtype.
  33. const parts = [identity.brand, identity.material, identity.subtype].filter(Boolean);
  34. return parts.length > 0 ? parts.join(' ') : null;
  35. }
  36. /**
  37. * Build loaded filaments list from printer status (non-hook version).
  38. * Extracts filaments from all AMS units (regular and HT) and external spool.
  39. *
  40. * `slotSpools` is optional and display-only: it renames slots after their
  41. * bound spool without touching `type`, `color` or `trayInfoIdx`, so matching
  42. * and the dispatcher keep drawing on the printer's own telemetry.
  43. */
  44. export function buildLoadedFilaments(
  45. printerStatus: PrinterStatus | undefined,
  46. slotSpools?: SlotSpoolIdentities,
  47. ): LoadedFilament[] {
  48. const filaments: LoadedFilament[] = [];
  49. const amsExtruderMap = printerStatus?.ams_extruder_map;
  50. // Dual-nozzle detection. The backend always emits a 2-entry nozzles array
  51. // (default-stub second entry for single-nozzle printers), so length is not
  52. // a reliable signal. Real second-nozzle hardware sets `nozzle_diameter` from
  53. // the MQTT `right_nozzle_diameter` field (bambu_mqtt.py:2619-2621); without
  54. // that field, nozzles[1] stays at its empty default. Belt-and-braces: a
  55. // populated ams_extruder_map (dual-nozzle with AMS) and >1 vt_tray (only
  56. // dual-nozzle hardware exposes multiple external feeds) each independently
  57. // imply dual-nozzle — keep them as fallbacks for any firmware rev that
  58. // surfaces one signal but not the other. (#1257)
  59. const hasDualNozzle =
  60. Boolean(printerStatus?.nozzles?.[1]?.nozzle_diameter)
  61. || (amsExtruderMap && Object.keys(amsExtruderMap).length > 0)
  62. || (printerStatus?.vt_tray?.length ?? 0) > 1;
  63. // Add filaments from all AMS units (regular and HT)
  64. printerStatus?.ams?.forEach((amsUnit) => {
  65. const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
  66. amsUnit.tray.forEach((tray) => {
  67. if (tray.tray_type) {
  68. const color = normalizeColor(tray.tray_color);
  69. const globalTrayId = getGlobalTrayId(amsUnit.id, tray.id, false);
  70. const identity = slotSpools?.get(globalTrayId);
  71. filaments.push({
  72. type: tray.tray_type,
  73. color,
  74. colorName: identity?.color_name?.trim() || getColorName(color, tray.tray_sub_brands),
  75. amsId: amsUnit.id,
  76. trayId: tray.id,
  77. isHt,
  78. isExternal: false,
  79. label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
  80. globalTrayId,
  81. trayInfoIdx: tray.tray_info_idx || '',
  82. traySubBrands: tray.tray_sub_brands || '',
  83. spoolName: spoolDisplayName(identity) ?? undefined,
  84. extruderId: amsExtruderMap?.[String(amsUnit.id)],
  85. remain: tray.remain ?? -1,
  86. });
  87. }
  88. });
  89. });
  90. // Add external spool(s) if loaded
  91. for (const extTray of printerStatus?.vt_tray ?? []) {
  92. if (extTray.tray_type) {
  93. const color = normalizeColor(extTray.tray_color);
  94. const trayId = extTray.id ?? 254;
  95. const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
  96. // The external holder's global tray id IS the tray id (254 / 255), which
  97. // is how the backend keys it too — see `_ams_key_to_global`.
  98. const identity = slotSpools?.get(trayId);
  99. filaments.push({
  100. type: extTray.tray_type,
  101. color,
  102. colorName: identity?.color_name?.trim() || getColorName(color, extTray.tray_sub_brands),
  103. amsId: -1,
  104. trayId: trayId - 254,
  105. isHt: false,
  106. isExternal: true,
  107. label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
  108. globalTrayId: trayId,
  109. trayInfoIdx: extTray.tray_info_idx || '',
  110. traySubBrands: extTray.tray_sub_brands || '',
  111. spoolName: spoolDisplayName(identity) ?? undefined,
  112. extruderId: hasDualNozzle ? (255 - trayId) : undefined,
  113. remain: extTray.remain ?? -1,
  114. });
  115. }
  116. }
  117. return filaments;
  118. }
  119. /**
  120. * Compute AMS mapping for a printer given filament requirements and printer status.
  121. * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
  122. *
  123. * Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
  124. *
  125. * The tray_info_idx is a filament type identifier stored in the 3MF file when the user
  126. * slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
  127. * tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
  128. * have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
  129. * matching among those trays.
  130. *
  131. * @param filamentReqs - Required filaments from the 3MF file
  132. * @param printerStatus - Current printer status with AMS information
  133. * @returns AMS mapping array or undefined if no mapping needed
  134. */
  135. export function computeAmsMapping(
  136. filamentReqs: { filaments: FilamentRequirement[] } | undefined,
  137. printerStatus: PrinterStatus | undefined,
  138. preferLowest?: boolean,
  139. inventoryByTrayId?: Map<number, number>,
  140. ): number[] | undefined {
  141. const loadedFilaments = buildLoadedFilaments(printerStatus);
  142. if (loadedFilaments.length === 0) return undefined;
  143. // FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
  144. // doesn't apply when it's installed (#1162).
  145. const ftsActive = printerStatus?.fila_switch?.installed === true;
  146. // No manual overrides on this path — it maps a printer the user is not looking
  147. // at (per-printer fan-out), so there is no panel to override a slot in.
  148. return buildAmsMapping(
  149. buildFilamentComparison(filamentReqs, loadedFilaments, {}, preferLowest, inventoryByTrayId, ftsActive),
  150. );
  151. }
  152. /**
  153. * Represents a loaded filament in the printer's AMS/HT/External spool holder.
  154. */
  155. export interface LoadedFilament {
  156. type: string;
  157. color: string;
  158. colorName: string;
  159. amsId: number;
  160. trayId: number;
  161. isHt: boolean;
  162. isExternal: boolean;
  163. label: string;
  164. globalTrayId: number;
  165. /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
  166. trayInfoIdx?: string;
  167. /** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
  168. traySubBrands?: string;
  169. /** "Devil Design PLA Basic" — the spool Bambuddy has bound to this slot,
  170. * when there is one. Display only; prefer it over `traySubBrands`, which
  171. * the printer leaves empty for everything that isn't a Bambu spool. */
  172. spoolName?: string;
  173. /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
  174. extruderId?: number;
  175. /** Remaining filament percentage (0-100), -1 = unknown */
  176. remain: number;
  177. }
  178. /**
  179. * Represents a required filament from the 3MF file.
  180. */
  181. export interface FilamentRequirement {
  182. slot_id: number;
  183. type: string;
  184. color: string;
  185. used_grams: number;
  186. /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
  187. tray_info_idx?: string;
  188. /** Target nozzle for dual-nozzle printers (0=right, 1=left) */
  189. nozzle_id?: number;
  190. /** Filament group this slot prints in, on a nozzle-rack machine (#1784).
  191. * The group is the slicer's logical nozzle, so it is what a rack position
  192. * gets chosen for. Absent on every other model. */
  193. group_id?: number;
  194. /** What that group needs of a hotend, for filtering the rack positions it
  195. * can be sent to. */
  196. group?: RackGroupInfo;
  197. }
  198. /**
  199. * Status of filament comparison between required and loaded.
  200. */
  201. export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
  202. /**
  203. * Result of comparing a required filament with loaded filaments.
  204. */
  205. export interface FilamentComparison extends FilamentRequirement {
  206. loaded: LoadedFilament | undefined;
  207. hasFilament: boolean;
  208. typeMatch: boolean;
  209. colorMatch: boolean;
  210. status: FilamentStatus;
  211. isManual: boolean;
  212. }
  213. export interface FilamentRequirementsResponse {
  214. filaments: FilamentRequirement[];
  215. }
  216. interface UseFilamentMappingResult {
  217. /** List of all filaments loaded in the printer */
  218. loadedFilaments: LoadedFilament[];
  219. /** Comparison results for each required filament */
  220. filamentComparison: FilamentComparison[];
  221. /** AMS mapping array for the print command */
  222. amsMapping: number[] | undefined;
  223. /** Whether any required filament type is not loaded */
  224. hasTypeMismatch: boolean;
  225. /** Whether any required filament has a color mismatch */
  226. hasColorMismatch: boolean;
  227. }
  228. /**
  229. * Hook to build loaded filaments list from printer status.
  230. * Extracts filaments from all AMS units (regular and HT) and external spool.
  231. */
  232. export function useLoadedFilaments(
  233. printerStatus: PrinterStatus | undefined,
  234. slotSpools?: SlotSpoolIdentities,
  235. ): LoadedFilament[] {
  236. return useMemo(() => {
  237. return buildLoadedFilaments(printerStatus, slotSpools);
  238. }, [printerStatus, slotSpools]);
  239. }
  240. /**
  241. * Does the tray we picked actually carry the colour the slice asked for?
  242. *
  243. * Shared by the manual and auto branches below so the two can never disagree
  244. * about the same tray again (#2687). Exact hex first, then the perceptual
  245. * tolerance, so a spool the printer reports one shade off still reads as a
  246. * match.
  247. *
  248. * A requirement with no colour at all is not a mismatch — the 3MF simply
  249. * didn't ask for one (`filament_requirements.py` defaults it to `""`), so any
  250. * loaded colour satisfies it. Loaded trays always have a colour: buildLoaded-
  251. * Filaments falls back to grey when MQTT reports none.
  252. */
  253. function coloursMatch(loadedColor: string | undefined, requiredColor: string | undefined): boolean {
  254. const required = normalizeColorForCompare(requiredColor);
  255. if (!required) return true;
  256. return (
  257. normalizeColorForCompare(loadedColor) === required || colorsAreSimilar(loadedColor, requiredColor)
  258. );
  259. }
  260. /**
  261. * Compare required filaments with loaded filaments (non-hook version).
  262. *
  263. * Tray assignment is stateful across the list — a tray matched to one slot is
  264. * not offered to the next — so this must be run over exactly the slots of one
  265. * print, never a union of several plates: two plates that share a colour on
  266. * different slots would otherwise compete for the same tray and one of them
  267. * would fall through to a worse match, or to none (#2551 follow-up).
  268. */
  269. export function buildFilamentComparison(
  270. filamentReqs: FilamentRequirementsResponse | undefined,
  271. loadedFilaments: LoadedFilament[],
  272. manualMappings: Record<number, number>,
  273. preferLowest?: boolean,
  274. inventoryByTrayId?: Map<number, number>,
  275. ftsActive = false,
  276. ): FilamentComparison[] {
  277. if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
  278. // Track which trays have been assigned to avoid duplicates
  279. // First, mark all manually assigned trays as used
  280. const usedTrayIds = new Set<number>(Object.values(manualMappings));
  281. return filamentReqs.filaments.map((req) => {
  282. const slotId = req.slot_id || 0;
  283. // Check if there's a manual override for this slot
  284. if (slotId > 0 && manualMappings[slotId] !== undefined) {
  285. const manualTrayId = manualMappings[slotId];
  286. const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
  287. if (manualLoaded) {
  288. const typeMatch = filamentTypesCompatible(manualLoaded.type, req.type);
  289. const colorMatch = coloursMatch(manualLoaded.color, req.color);
  290. let status: FilamentStatus;
  291. if (typeMatch && colorMatch) {
  292. status = 'match';
  293. } else if (typeMatch) {
  294. status = 'type_only';
  295. } else {
  296. status = 'mismatch';
  297. }
  298. return {
  299. ...req,
  300. loaded: manualLoaded,
  301. hasFilament: true,
  302. typeMatch,
  303. colorMatch,
  304. status,
  305. isManual: true,
  306. };
  307. }
  308. }
  309. // Auto-match: Find a loaded filament
  310. // Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
  311. // IMPORTANT: Exclude trays that are already assigned (manually or auto)
  312. const reqTrayInfoIdx = req.tray_info_idx || '';
  313. // Get available trays (not already used)
  314. let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
  315. // Nozzle-aware filtering: restrict to trays on the correct nozzle.
  316. // This is a hard filter — cross-nozzle assignment causes print failures.
  317. // Skip when an FTS is installed: it can route any slot to either extruder.
  318. if (req.nozzle_id != null && !ftsActive) {
  319. available = available.filter((f) => f.extruderId === req.nozzle_id);
  320. }
  321. // Sort lowest-first when the preference is on. Inventory-tracked spools
  322. // sort before MQTT-only ones; see preferLowestSortKey for the rationale.
  323. if (preferLowest) {
  324. available = [...available].sort((a, b) =>
  325. compareSortKeys(
  326. preferLowestSortKey(a, inventoryByTrayId),
  327. preferLowestSortKey(b, inventoryByTrayId),
  328. ),
  329. );
  330. }
  331. let idxMatch: LoadedFilament | undefined;
  332. let exactMatch: LoadedFilament | undefined;
  333. let similarMatch: LoadedFilament | undefined;
  334. let typeOnlyMatch: LoadedFilament | undefined;
  335. // Check if tray_info_idx is unique among available trays
  336. if (reqTrayInfoIdx) {
  337. const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
  338. if (idxMatches.length === 1) {
  339. // Unique tray_info_idx - use it as definitive match
  340. idxMatch = idxMatches[0];
  341. } else if (idxMatches.length > 1) {
  342. // Multiple trays with same tray_info_idx - use color matching among them
  343. if (preferLowest) {
  344. idxMatches.sort((a, b) =>
  345. compareSortKeys(
  346. preferLowestSortKey(a, inventoryByTrayId),
  347. preferLowestSortKey(b, inventoryByTrayId),
  348. ),
  349. );
  350. }
  351. exactMatch = idxMatches.find(
  352. (f) =>
  353. filamentTypesCompatible(f.type, req.type) &&
  354. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  355. );
  356. if (!exactMatch) {
  357. similarMatch = findNearestSimilar(
  358. idxMatches.filter((f) => filamentTypesCompatible(f.type, req.type)),
  359. req.color,
  360. (f) => f.color,
  361. );
  362. }
  363. if (!exactMatch && !similarMatch) {
  364. typeOnlyMatch = idxMatches.find(
  365. (f) => filamentTypesCompatible(f.type, req.type)
  366. );
  367. }
  368. }
  369. }
  370. // If no idx match, do standard type/color matching on all available trays
  371. if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
  372. exactMatch = available.find(
  373. (f) =>
  374. filamentTypesCompatible(f.type, req.type) &&
  375. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  376. );
  377. if (!exactMatch) {
  378. similarMatch = findNearestSimilar(
  379. available.filter((f) => filamentTypesCompatible(f.type, req.type)),
  380. req.color,
  381. (f) => f.color,
  382. );
  383. }
  384. if (!exactMatch && !similarMatch) {
  385. typeOnlyMatch = available.find(
  386. (f) => filamentTypesCompatible(f.type, req.type)
  387. );
  388. }
  389. }
  390. const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
  391. // Mark this tray as used so it won't be assigned to another slot
  392. if (loaded) {
  393. usedTrayIds.add(loaded.globalTrayId);
  394. }
  395. const hasFilament = !!loaded;
  396. const typeMatch = hasFilament;
  397. // #2687: judge the colour on the tray we actually picked, never on which
  398. // branch found it. tray_info_idx identifies the filament *variant* — GFA00
  399. // is PLA Basic, GFA01 PLA Matte, GFA17 PLA Translucent — not an individual
  400. // spool, so one Matte spool idx-matches every Matte requirement whatever
  401. // colour it is. The old rule ("same spool = same color") therefore reported
  402. // red-required-on-green-loaded as a match, while manually picking that same
  403. // tray reported the mismatch honestly. Variant still decides *selection*
  404. // (#2650: Basic is not Matte) — it just no longer decides the verdict.
  405. const colorMatch = hasFilament && coloursMatch(loaded.color, req.color);
  406. // No tray of the required type at all is a type mismatch; otherwise the
  407. // colour decides between a full match and type-only.
  408. let status: FilamentStatus;
  409. if (!hasFilament) {
  410. status = 'mismatch';
  411. } else if (colorMatch) {
  412. status = 'match';
  413. } else {
  414. status = 'type_only';
  415. }
  416. return {
  417. ...req,
  418. loaded,
  419. hasFilament,
  420. typeMatch,
  421. colorMatch,
  422. status,
  423. isManual: false,
  424. };
  425. });
  426. }
  427. /**
  428. * Build the AMS mapping array the print command carries (non-hook version).
  429. * Position = slot_id - 1 (0-indexed), value = global tray ID, or -1 for a slot
  430. * with no matching tray. Indexed by the 3MF's own slot ids, which are global to
  431. * the file, so a plate that only prints slot 3 still emits `[-1, -1, tray]`.
  432. */
  433. export function buildAmsMapping(filamentComparison: FilamentComparison[]): number[] | undefined {
  434. if (filamentComparison.length === 0) return undefined;
  435. const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
  436. if (maxSlotId <= 0) return undefined;
  437. const mapping = new Array(maxSlotId).fill(-1);
  438. filamentComparison.forEach((f) => {
  439. if (f.slot_id && f.slot_id > 0) {
  440. mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
  441. }
  442. });
  443. return mapping;
  444. }
  445. /**
  446. * Hook to compare required filaments with loaded filaments and build AMS mapping.
  447. * Handles both auto-matching and manual overrides.
  448. *
  449. * @param filamentReqs - Required filaments from the 3MF file
  450. * @param printerStatus - Current printer status with AMS information
  451. * @param manualMappings - Manual slot overrides (slot_id -> globalTrayId)
  452. */
  453. export function useFilamentMapping(
  454. filamentReqs: FilamentRequirementsResponse | undefined,
  455. printerStatus: PrinterStatus | undefined,
  456. manualMappings: Record<number, number>,
  457. preferLowest?: boolean,
  458. inventoryByTrayId?: Map<number, number>,
  459. slotSpools?: SlotSpoolIdentities,
  460. ): UseFilamentMappingResult {
  461. const loadedFilaments = useLoadedFilaments(printerStatus, slotSpools);
  462. // FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
  463. // doesn't apply when it's installed (#1162).
  464. const ftsActive = printerStatus?.fila_switch?.installed === true;
  465. const filamentComparison = useMemo(
  466. () =>
  467. buildFilamentComparison(
  468. filamentReqs,
  469. loadedFilaments,
  470. manualMappings,
  471. preferLowest,
  472. inventoryByTrayId,
  473. ftsActive,
  474. ),
  475. [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId],
  476. );
  477. // Don't emit a mapping until the printer's trays are known. With no loaded
  478. // filaments (e.g. printerStatus still loading), buildFilamentComparison marks
  479. // every required slot unmatched and buildAmsMapping would serialize an
  480. // all-[-1] array — which the backend used to treat as an explicit
  481. // external-spool selection, silently printing to an empty feed (#2589).
  482. // Return undefined instead so the scheduler resolves the mapping from live
  483. // status at dispatch. Mirrors the guard in computeAmsMapping.
  484. const amsMapping = useMemo(
  485. () => (loadedFilaments.length === 0 ? undefined : buildAmsMapping(filamentComparison)),
  486. [filamentComparison, loadedFilaments.length],
  487. );
  488. const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
  489. const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
  490. return {
  491. loadedFilaments,
  492. filamentComparison,
  493. amsMapping,
  494. hasTypeMismatch,
  495. hasColorMismatch,
  496. };
  497. }