useFilamentMapping.ts 16 KB

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