useFilamentMapping.ts 22 KB

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