useFilamentMapping.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. import { useMemo } from 'react';
  2. import { getColorName } from '../utils/colors';
  3. import {
  4. normalizeColor,
  5. normalizeColorForCompare,
  6. colorsAreSimilar,
  7. formatSlotLabel,
  8. getGlobalTrayId,
  9. } from '../utils/amsHelpers';
  10. import type { PrinterStatus } from '../api/client';
  11. /**
  12. * Build loaded filaments list from printer status (non-hook version).
  13. * Extracts filaments from all AMS units (regular and HT) and external spool.
  14. */
  15. export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
  16. const filaments: LoadedFilament[] = [];
  17. const amsExtruderMap = printerStatus?.ams_extruder_map;
  18. const hasDualNozzle = amsExtruderMap && Object.keys(amsExtruderMap).length > 0;
  19. // Add filaments from all AMS units (regular and HT)
  20. printerStatus?.ams?.forEach((amsUnit) => {
  21. const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
  22. amsUnit.tray.forEach((tray) => {
  23. if (tray.tray_type) {
  24. const color = normalizeColor(tray.tray_color);
  25. filaments.push({
  26. type: tray.tray_type,
  27. color,
  28. colorName: getColorName(color),
  29. amsId: amsUnit.id,
  30. trayId: tray.id,
  31. isHt,
  32. isExternal: false,
  33. label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
  34. globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
  35. trayInfoIdx: tray.tray_info_idx || '',
  36. traySubBrands: tray.tray_sub_brands || '',
  37. extruderId: amsExtruderMap?.[String(amsUnit.id)],
  38. });
  39. }
  40. });
  41. });
  42. // Add external spool(s) if loaded
  43. for (const extTray of printerStatus?.vt_tray ?? []) {
  44. if (extTray.tray_type) {
  45. const color = normalizeColor(extTray.tray_color);
  46. const trayId = extTray.id ?? 254;
  47. const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
  48. filaments.push({
  49. type: extTray.tray_type,
  50. color,
  51. colorName: getColorName(color),
  52. amsId: -1,
  53. trayId: trayId - 254,
  54. isHt: false,
  55. isExternal: true,
  56. label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
  57. globalTrayId: trayId,
  58. trayInfoIdx: extTray.tray_info_idx || '',
  59. traySubBrands: extTray.tray_sub_brands || '',
  60. extruderId: hasDualNozzle ? (255 - trayId) : undefined,
  61. });
  62. }
  63. }
  64. return filaments;
  65. }
  66. /**
  67. * Compute AMS mapping for a printer given filament requirements and printer status.
  68. * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
  69. *
  70. * Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
  71. *
  72. * The tray_info_idx is a filament type identifier stored in the 3MF file when the user
  73. * slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
  74. * tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
  75. * have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
  76. * matching among those trays.
  77. *
  78. * @param filamentReqs - Required filaments from the 3MF file
  79. * @param printerStatus - Current printer status with AMS information
  80. * @returns AMS mapping array or undefined if no mapping needed
  81. */
  82. export function computeAmsMapping(
  83. filamentReqs: { filaments: FilamentRequirement[] } | undefined,
  84. printerStatus: PrinterStatus | undefined
  85. ): number[] | undefined {
  86. if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
  87. const loadedFilaments = buildLoadedFilaments(printerStatus);
  88. if (loadedFilaments.length === 0) return undefined;
  89. // Track which trays have been assigned to avoid duplicates
  90. const usedTrayIds = new Set<number>();
  91. const comparisons = filamentReqs.filaments.map((req) => {
  92. const reqTrayInfoIdx = req.tray_info_idx || '';
  93. // Get available trays (not already used)
  94. let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
  95. // Nozzle-aware filtering: restrict to trays on the correct nozzle.
  96. // This is a hard filter — cross-nozzle assignment causes print failures
  97. // ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
  98. if (req.nozzle_id != null) {
  99. available = available.filter((f) => f.extruderId === req.nozzle_id);
  100. }
  101. let idxMatch: LoadedFilament | undefined;
  102. let exactMatch: LoadedFilament | undefined;
  103. let similarMatch: LoadedFilament | undefined;
  104. let typeOnlyMatch: LoadedFilament | undefined;
  105. // Check if tray_info_idx is unique among available trays
  106. if (reqTrayInfoIdx) {
  107. const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
  108. if (idxMatches.length === 1) {
  109. // Unique tray_info_idx - use it as definitive match
  110. idxMatch = idxMatches[0];
  111. } else if (idxMatches.length > 1) {
  112. // Multiple trays with same tray_info_idx - use color matching among them
  113. exactMatch = idxMatches.find(
  114. (f) =>
  115. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  116. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  117. );
  118. if (!exactMatch) {
  119. similarMatch = idxMatches.find(
  120. (f) =>
  121. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  122. colorsAreSimilar(f.color, req.color)
  123. );
  124. }
  125. if (!exactMatch && !similarMatch) {
  126. typeOnlyMatch = idxMatches.find(
  127. (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
  128. );
  129. }
  130. }
  131. }
  132. // If no idx match, do standard type/color matching on all available trays
  133. if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
  134. exactMatch = available.find(
  135. (f) =>
  136. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  137. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  138. );
  139. if (!exactMatch) {
  140. similarMatch = available.find(
  141. (f) =>
  142. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  143. colorsAreSimilar(f.color, req.color)
  144. );
  145. }
  146. if (!exactMatch && !similarMatch) {
  147. typeOnlyMatch = available.find(
  148. (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
  149. );
  150. }
  151. }
  152. const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
  153. // Mark this tray as used so it won't be assigned to another slot
  154. if (loaded) {
  155. usedTrayIds.add(loaded.globalTrayId);
  156. }
  157. return {
  158. slot_id: req.slot_id,
  159. globalTrayId: loaded?.globalTrayId ?? -1,
  160. };
  161. });
  162. // Find the max slot_id to determine array size
  163. const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
  164. if (maxSlotId <= 0) return undefined;
  165. // Create array with -1 for all positions
  166. const mapping = new Array(maxSlotId).fill(-1);
  167. // Fill in tray IDs at correct positions (slot_id - 1)
  168. comparisons.forEach((f) => {
  169. if (f.slot_id && f.slot_id > 0) {
  170. mapping[f.slot_id - 1] = f.globalTrayId;
  171. }
  172. });
  173. return mapping;
  174. }
  175. /**
  176. * Represents a loaded filament in the printer's AMS/HT/External spool holder.
  177. */
  178. export interface LoadedFilament {
  179. type: string;
  180. color: string;
  181. colorName: string;
  182. amsId: number;
  183. trayId: number;
  184. isHt: boolean;
  185. isExternal: boolean;
  186. label: string;
  187. globalTrayId: number;
  188. /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
  189. trayInfoIdx?: string;
  190. /** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
  191. traySubBrands?: string;
  192. /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
  193. extruderId?: number;
  194. }
  195. /**
  196. * Represents a required filament from the 3MF file.
  197. */
  198. export interface FilamentRequirement {
  199. slot_id: number;
  200. type: string;
  201. color: string;
  202. used_grams: number;
  203. /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
  204. tray_info_idx?: string;
  205. /** Target nozzle for dual-nozzle printers (0=right, 1=left) */
  206. nozzle_id?: number;
  207. }
  208. /**
  209. * Status of filament comparison between required and loaded.
  210. */
  211. export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
  212. /**
  213. * Result of comparing a required filament with loaded filaments.
  214. */
  215. export interface FilamentComparison extends FilamentRequirement {
  216. loaded: LoadedFilament | undefined;
  217. hasFilament: boolean;
  218. typeMatch: boolean;
  219. colorMatch: boolean;
  220. status: FilamentStatus;
  221. isManual: boolean;
  222. }
  223. interface FilamentRequirementsResponse {
  224. filaments: FilamentRequirement[];
  225. }
  226. interface UseFilamentMappingResult {
  227. /** List of all filaments loaded in the printer */
  228. loadedFilaments: LoadedFilament[];
  229. /** Comparison results for each required filament */
  230. filamentComparison: FilamentComparison[];
  231. /** AMS mapping array for the print command */
  232. amsMapping: number[] | undefined;
  233. /** Whether any required filament type is not loaded */
  234. hasTypeMismatch: boolean;
  235. /** Whether any required filament has a color mismatch */
  236. hasColorMismatch: boolean;
  237. }
  238. /**
  239. * Hook to build loaded filaments list from printer status.
  240. * Extracts filaments from all AMS units (regular and HT) and external spool.
  241. */
  242. export function useLoadedFilaments(
  243. printerStatus: PrinterStatus | undefined
  244. ): LoadedFilament[] {
  245. return useMemo(() => {
  246. return buildLoadedFilaments(printerStatus);
  247. }, [printerStatus]);
  248. }
  249. /**
  250. * Hook to compare required filaments with loaded filaments and build AMS mapping.
  251. * Handles both auto-matching and manual overrides.
  252. *
  253. * @param filamentReqs - Required filaments from the 3MF file
  254. * @param printerStatus - Current printer status with AMS information
  255. * @param manualMappings - Manual slot overrides (slot_id -> globalTrayId)
  256. */
  257. export function useFilamentMapping(
  258. filamentReqs: FilamentRequirementsResponse | undefined,
  259. printerStatus: PrinterStatus | undefined,
  260. manualMappings: Record<number, number>
  261. ): UseFilamentMappingResult {
  262. const loadedFilaments = useLoadedFilaments(printerStatus);
  263. const filamentComparison = useMemo(() => {
  264. if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
  265. // Track which trays have been assigned to avoid duplicates
  266. // First, mark all manually assigned trays as used
  267. const usedTrayIds = new Set<number>(Object.values(manualMappings));
  268. return filamentReqs.filaments.map((req) => {
  269. const slotId = req.slot_id || 0;
  270. // Check if there's a manual override for this slot
  271. if (slotId > 0 && manualMappings[slotId] !== undefined) {
  272. const manualTrayId = manualMappings[slotId];
  273. const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
  274. if (manualLoaded) {
  275. const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
  276. const colorMatch =
  277. normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
  278. colorsAreSimilar(manualLoaded.color, req.color);
  279. let status: FilamentStatus;
  280. if (typeMatch && colorMatch) {
  281. status = 'match';
  282. } else if (typeMatch) {
  283. status = 'type_only';
  284. } else {
  285. status = 'mismatch';
  286. }
  287. return {
  288. ...req,
  289. loaded: manualLoaded,
  290. hasFilament: true,
  291. typeMatch,
  292. colorMatch,
  293. status,
  294. isManual: true,
  295. };
  296. }
  297. }
  298. // Auto-match: Find a loaded filament
  299. // Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
  300. // IMPORTANT: Exclude trays that are already assigned (manually or auto)
  301. const reqTrayInfoIdx = req.tray_info_idx || '';
  302. // Get available trays (not already used)
  303. let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
  304. // Nozzle-aware filtering: restrict to trays on the correct nozzle.
  305. // This is a hard filter — cross-nozzle assignment causes print failures.
  306. if (req.nozzle_id != null) {
  307. available = available.filter((f) => f.extruderId === req.nozzle_id);
  308. }
  309. let idxMatch: LoadedFilament | undefined;
  310. let exactMatch: LoadedFilament | undefined;
  311. let similarMatch: LoadedFilament | undefined;
  312. let typeOnlyMatch: LoadedFilament | undefined;
  313. // Check if tray_info_idx is unique among available trays
  314. if (reqTrayInfoIdx) {
  315. const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
  316. if (idxMatches.length === 1) {
  317. // Unique tray_info_idx - use it as definitive match
  318. idxMatch = idxMatches[0];
  319. } else if (idxMatches.length > 1) {
  320. // Multiple trays with same tray_info_idx - use color matching among them
  321. exactMatch = idxMatches.find(
  322. (f) =>
  323. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  324. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  325. );
  326. if (!exactMatch) {
  327. similarMatch = idxMatches.find(
  328. (f) =>
  329. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  330. colorsAreSimilar(f.color, req.color)
  331. );
  332. }
  333. if (!exactMatch && !similarMatch) {
  334. typeOnlyMatch = idxMatches.find(
  335. (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
  336. );
  337. }
  338. }
  339. }
  340. // If no idx match, do standard type/color matching on all available trays
  341. if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
  342. exactMatch = available.find(
  343. (f) =>
  344. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  345. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  346. );
  347. if (!exactMatch) {
  348. similarMatch = available.find(
  349. (f) =>
  350. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  351. colorsAreSimilar(f.color, req.color)
  352. );
  353. }
  354. if (!exactMatch && !similarMatch) {
  355. typeOnlyMatch = available.find(
  356. (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
  357. );
  358. }
  359. }
  360. const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
  361. // Mark this tray as used so it won't be assigned to another slot
  362. if (loaded) {
  363. usedTrayIds.add(loaded.globalTrayId);
  364. }
  365. const hasFilament = !!loaded;
  366. const typeMatch = hasFilament;
  367. // idxMatch is always considered a color match (same spool = same color)
  368. const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
  369. // Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
  370. let status: FilamentStatus;
  371. if (idxMatch || exactMatch || similarMatch) {
  372. status = 'match';
  373. } else if (typeOnlyMatch) {
  374. status = 'type_only';
  375. } else {
  376. status = 'mismatch';
  377. }
  378. return {
  379. ...req,
  380. loaded,
  381. hasFilament,
  382. typeMatch,
  383. colorMatch,
  384. status,
  385. isManual: false,
  386. };
  387. });
  388. }, [filamentReqs, loadedFilaments, manualMappings]);
  389. // Build AMS mapping from matched filaments
  390. // Format: array matching 3MF filament slot structure
  391. // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
  392. const amsMapping = useMemo(() => {
  393. if (filamentComparison.length === 0) return undefined;
  394. // Find the max slot_id to determine array size
  395. const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
  396. if (maxSlotId <= 0) return undefined;
  397. // Create array with -1 for all positions
  398. const mapping = new Array(maxSlotId).fill(-1);
  399. // Fill in tray IDs at correct positions (slot_id - 1)
  400. filamentComparison.forEach((f) => {
  401. if (f.slot_id && f.slot_id > 0) {
  402. mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
  403. }
  404. });
  405. return mapping;
  406. }, [filamentComparison]);
  407. const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
  408. const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
  409. return {
  410. loadedFilaments,
  411. filamentComparison,
  412. amsMapping,
  413. hasTypeMismatch,
  414. hasColorMismatch,
  415. };
  416. }