useFilamentMapping.ts 18 KB

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