utils.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. import { api } from '../../api/client';
  2. import type { SlicerSetting, LocalPreset, BuiltinFilament, SpoolKProfileInput } from '../../api/client';
  3. import { installedNozzleDiameters } from '../../utils/amsHelpers';
  4. import { filamentIdToSettingId, settingIdToFilamentId } from '../../utils/filamentIds';
  5. import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
  6. import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS, STANDARD_NOZZLE_DIAMETERS } from './constants';
  7. /**
  8. * Fetch a printer's K-profiles across every nozzle it actually has installed
  9. * (#2618) and flatten them into CalibrationProfile rows for the PA-Profil
  10. * picker. `getKProfiles` filters strictly by nozzle diameter and defaults to
  11. * "0.4", so a single call hides non-0.4 profiles (e.g. a 0.6mm PAHT-CF K
  12. * value) — the picker then shows only one of two nozzle-specific profiles.
  13. * We query each installed diameter and merge. Falls back to "0.4" when the
  14. * printer hasn't reported nozzle hardware, preserving prior behaviour.
  15. * Per-diameter failures are swallowed (a printer that doesn't support the
  16. * endpoint just yields no rows), matching the callers' previous try/catch.
  17. */
  18. export async function fetchPrinterCalibrations(
  19. printerId: number,
  20. status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
  21. ): Promise<CalibrationProfile[]> {
  22. // Every standard size, plus anything unusual the printer reports as fitted --
  23. // not just the fitted ones. A K profile is stored on the printer per nozzle
  24. // diameter and survives a nozzle swap, so fetching only what is screwed in
  25. // right now made a 0.6 profile invisible until a 0.6 was fitted, and left the
  26. // user unable to prepare a spool for a nozzle they are about to change to.
  27. // A diameter the printer has nothing for answers with an empty list rather
  28. // than timing out, so the extra sizes cost a round trip each, not a stall --
  29. // provided they are sent one at a time, see below.
  30. const installed = installedNozzleDiameters(status);
  31. const toFetch = Array.from(new Set([...STANDARD_NOZZLE_DIAMETERS, ...installed]));
  32. // One diameter at a time, NOT in parallel. H2-series firmware answers only
  33. // the first one or two of a concurrent burst of `extrusion_cali_get` and
  34. // silently drops the rest, so each dropped request costs a 5s timeout before
  35. // it is retried: measured on an H2C and an H2D, four parallel requests took
  36. // 11s and 23s respectively, against ~1s when sent one after another. (An X1C
  37. // answers all four concurrently, which is why this went unnoticed while only
  38. // dual-diameter printers ever sent more than one.) Per-diameter failures are
  39. // still swallowed: a printer that doesn't support the endpoint yields no rows
  40. // rather than losing the diameters that did answer.
  41. const responses = [];
  42. for (const diameter of toFetch) {
  43. responses.push(await api.getKProfiles(printerId, diameter).catch(() => null));
  44. }
  45. const calibrations: CalibrationProfile[] = [];
  46. for (const res of responses) {
  47. if (!res) continue;
  48. for (const p of res.profiles) {
  49. calibrations.push({
  50. cali_idx: p.slot_id,
  51. filament_id: p.filament_id,
  52. setting_id: p.setting_id || '',
  53. name: p.name,
  54. k_value: parseFloat(p.k_value) || 0,
  55. n_coef: parseFloat(p.n_coef) || 0,
  56. extruder_id: p.extruder_id,
  57. nozzle_diameter: p.nozzle_diameter,
  58. nozzle_id: p.nozzle_id,
  59. });
  60. }
  61. }
  62. return calibrations;
  63. }
  64. // Fallback filament presets when cloud is not available
  65. const FALLBACK_PRESETS: FilamentOption[] = [
  66. { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'], source: 'builtin' },
  67. { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'], source: 'builtin' },
  68. { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'], source: 'builtin' },
  69. { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'], source: 'builtin' },
  70. { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'], source: 'builtin' },
  71. { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'], source: 'builtin' },
  72. { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'], source: 'builtin' },
  73. { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'], source: 'builtin' },
  74. { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'], source: 'builtin' },
  75. { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'], source: 'builtin' },
  76. { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'], source: 'builtin' },
  77. { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'], source: 'builtin' },
  78. { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'], source: 'builtin' },
  79. { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'], source: 'builtin' },
  80. { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'], source: 'builtin' },
  81. { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'], source: 'builtin' },
  82. { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'], source: 'builtin' },
  83. ];
  84. // Parse a slicer preset name to extract brand, material, and variant
  85. export function parsePresetName(name: string): { brand: string; material: string; variant: string } {
  86. // Remove @printer suffix (e.g., "@Bambu Lab H2D 0.4 nozzle")
  87. let cleanName = name.replace(/@.*$/, '').trim();
  88. // Remove (Custom) tag
  89. cleanName = cleanName.replace(/\(Custom\)/i, '').trim();
  90. // Remove leading # or * markers
  91. cleanName = cleanName.replace(/^[#*]+\s*/, '').trim();
  92. // Materials list - order matters (longer/more specific first)
  93. const materials = [
  94. 'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',
  95. 'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',
  96. 'PCTG', 'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PEEK', 'PEI',
  97. ];
  98. // Find material in the name
  99. let material = '';
  100. let materialIdx = -1;
  101. for (const m of materials) {
  102. const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());
  103. if (idx !== -1) {
  104. material = m;
  105. materialIdx = idx;
  106. break;
  107. }
  108. }
  109. // Brand is everything before the material
  110. let brand = '';
  111. if (materialIdx > 0) {
  112. brand = cleanName.substring(0, materialIdx).trim();
  113. brand = brand.replace(/[-_\s]+$/, '');
  114. }
  115. // Everything after material is potential variant
  116. let afterMaterial = '';
  117. if (materialIdx !== -1 && material) {
  118. afterMaterial = cleanName.substring(materialIdx + material.length).trim();
  119. afterMaterial = afterMaterial.replace(/^[-_\s]+/, '');
  120. }
  121. // Check for known variant - could be before OR after material
  122. let variant = '';
  123. // First check after material (most common)
  124. for (const v of KNOWN_VARIANTS) {
  125. if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {
  126. variant = v;
  127. break;
  128. }
  129. }
  130. // If no variant found after material, check if brand contains a known variant
  131. if (!variant && brand) {
  132. for (const v of KNOWN_VARIANTS) {
  133. const variantPattern = new RegExp(`\\s+${v}$`, 'i');
  134. if (variantPattern.test(brand)) {
  135. variant = v;
  136. brand = brand.replace(variantPattern, '').trim();
  137. break;
  138. }
  139. }
  140. }
  141. return { brand, material, variant };
  142. }
  143. // Extract unique brands from cloud presets and local presets
  144. export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
  145. const brandSet = new Set<string>(DEFAULT_BRANDS);
  146. for (const preset of presets) {
  147. const { brand } = parsePresetName(preset.name);
  148. if (brand && brand.length > 1) {
  149. brandSet.add(brand);
  150. }
  151. }
  152. // Also extract brands from local presets
  153. if (localPresets) {
  154. for (const preset of localPresets) {
  155. if (preset.filament_vendor && preset.filament_vendor.length > 1) {
  156. brandSet.add(preset.filament_vendor);
  157. } else {
  158. const { brand } = parsePresetName(preset.name);
  159. if (brand && brand.length > 1) {
  160. brandSet.add(brand);
  161. }
  162. }
  163. }
  164. }
  165. return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
  166. }
  167. // Build filament options from local presets (OrcaSlicer / BambuStudio imports).
  168. // Each preset gets its own entry — no base-name collapse — so the spool form
  169. // shows all per-printer/per-nozzle variants the user has imported. The spool
  170. // itself is printer-agnostic, so the variant the user picks just becomes the
  171. // stored slicer_filament code (consumed by normalize_slicer_filament during
  172. // slicing — kept as preset.filament_type when available so the existing
  173. // "GFL05"-style normalisation still resolves).
  174. function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
  175. const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
  176. if (filamentPresets.length === 0) return [];
  177. const options: FilamentOption[] = filamentPresets.map(preset => {
  178. // Use the unique preset.id (stringified) as the code so each local preset
  179. // has its own identity. Earlier this was preset.filament_type (e.g. "PLA")
  180. // which collapsed every PLA local preset onto the same code — picking any
  181. // of them saved slicer_filament="PLA", a material name the backend cannot
  182. // resolve back to a specific preset row. The backend handler at
  183. // inventory.py expects numeric IDs for local-preset slicer_filament values.
  184. // allCodes still carries the legacy filament_type so findPresetOption
  185. // resolves existing saved spools that have the old material-name code.
  186. const code = String(preset.id);
  187. const legacyCode = preset.filament_type || code;
  188. const allCodes = Array.from(new Set([code, legacyCode]));
  189. return {
  190. code,
  191. name: preset.name,
  192. displayName: preset.name,
  193. isCustom: false,
  194. allCodes,
  195. source: 'local',
  196. };
  197. });
  198. return options.sort((a, b) => a.displayName.localeCompare(b.displayName));
  199. }
  200. // Build filament options by merging cloud presets, local profiles, and built-in
  201. // filaments — matching the behavior of ConfigureAmsSlotModal and the wiki's
  202. // "Where Presets Come From" section. Earlier versions were precedence-based
  203. // (cloud-only when cloud had any presets), which silently hid Local Profiles
  204. // from users logged into Bambu Cloud — see #1248.
  205. export function buildFilamentOptions(
  206. cloudPresets: SlicerSetting[],
  207. configuredPrinterModels: Set<string>,
  208. localPresets?: LocalPreset[],
  209. builtinFilaments?: BuiltinFilament[],
  210. // Which of the cloud presets came from Orca Cloud rather than Bambu Cloud.
  211. // The two are merged into one list by the callers (OrcaProfileMeta is
  212. // structurally identical to SlicerSetting), so the distinction has to be
  213. // carried in rather than derived -- and it is worth carrying, because the
  214. // origin badge is the only thing telling a user which cloud a preset is in.
  215. orcaSettingIds?: Set<string>,
  216. ): FilamentOption[] {
  217. const customPresets: FilamentOption[] = [];
  218. const defaultPresets: FilamentOption[] = [];
  219. const cloudCodes = new Set<string>();
  220. // 1. Cloud presets — each setting_id gets its own entry. The spool form is
  221. // printer-agnostic so we deliberately do NOT collapse "@P1S" / "@X1C"
  222. // variants into a single row; the user picks the variant they want and
  223. // its setting_id is what gets persisted.
  224. for (const preset of cloudPresets) {
  225. if (preset.is_custom) {
  226. const presetNameUpper = preset.name.toUpperCase();
  227. const matchesPrinter = configuredPrinterModels.size === 0 ||
  228. Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
  229. !presetNameUpper.includes('@');
  230. if (matchesPrinter) {
  231. customPresets.push({
  232. code: preset.setting_id,
  233. name: preset.name,
  234. displayName: `${preset.name} (Custom)`,
  235. isCustom: true,
  236. allCodes: [preset.setting_id],
  237. source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
  238. });
  239. cloudCodes.add(preset.setting_id);
  240. }
  241. } else {
  242. defaultPresets.push({
  243. code: preset.setting_id,
  244. name: preset.name,
  245. displayName: preset.name,
  246. isCustom: false,
  247. allCodes: [preset.setting_id],
  248. source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
  249. });
  250. cloudCodes.add(preset.setting_id);
  251. }
  252. }
  253. // 2. Local profiles (OrcaSlicer / BambuStudio imports)
  254. const localOptions = localPresets && localPresets.length > 0
  255. ? buildLocalFilamentOptions(localPresets)
  256. : [];
  257. // 3. Built-in filaments — only those not already represented by a cloud preset.
  258. // Cloud setting_ids look like "GFSA00", built-in filament_ids look like "GFA00";
  259. // map between the two so we don't render the same filament twice.
  260. const builtinOptions: FilamentOption[] = [];
  261. if (builtinFilaments && builtinFilaments.length > 0) {
  262. for (const bf of builtinFilaments) {
  263. const settingId = filamentIdToSettingId(bf.filament_id);
  264. if (cloudCodes.has(bf.filament_id) || cloudCodes.has(settingId)) continue;
  265. builtinOptions.push({
  266. code: bf.filament_id,
  267. name: bf.name,
  268. displayName: bf.name,
  269. isCustom: false,
  270. allCodes: [bf.filament_id, settingId],
  271. source: 'builtin',
  272. });
  273. }
  274. }
  275. const merged = [
  276. ...customPresets,
  277. ...defaultPresets,
  278. ...localOptions,
  279. ...builtinOptions,
  280. ];
  281. // 4. Hardcoded fallback only when literally every source is empty.
  282. if (merged.length === 0) return FALLBACK_PRESETS;
  283. return merged.sort((a, b) => a.displayName.localeCompare(b.displayName));
  284. }
  285. // Find selected preset option
  286. export function findPresetOption(
  287. slicerFilament: string,
  288. filamentOptions: FilamentOption[],
  289. ): FilamentOption | undefined {
  290. if (!slicerFilament) return undefined;
  291. // First try exact match on primary code
  292. let option = filamentOptions.find(o => o.code === slicerFilament);
  293. if (!option) {
  294. // Try matching against any code in allCodes
  295. option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
  296. }
  297. if (!option) {
  298. // Try case-insensitive match
  299. const slicerLower = slicerFilament.toLowerCase();
  300. option = filamentOptions.find(o =>
  301. o.code.toLowerCase() === slicerLower ||
  302. o.allCodes.some(c => c.toLowerCase() === slicerLower),
  303. );
  304. }
  305. return option;
  306. }
  307. // Keep the value a spool already carries selectable in its own dropdown (#1905).
  308. // A brand or material entered as a custom value isn't part of the color catalog
  309. // or any slicer preset, so without this the edit form offered no way back to it
  310. // once the user opened the dropdown.
  311. export function withCurrentValue(options: string[], current: string): string[] {
  312. const trimmed = current.trim();
  313. if (!trimmed || options.some(o => o.toLowerCase() === trimmed.toLowerCase())) return options;
  314. return [...options, trimmed].sort((a, b) => a.localeCompare(b));
  315. }
  316. // Brands/materials the catalog and slicer presets pair with the other field's
  317. // current value (#1905). Used to rank the dropdown, never to filter it — the
  318. // pairs are incomplete (Elegoo ships ASA even though the catalog only knows its
  319. // PLA), and hiding the rest made valid entries look impossible.
  320. export function pairedOptions(
  321. options: string[],
  322. counterpart: string,
  323. pairMap: Map<string, Set<string>>,
  324. ): string[] {
  325. if (!counterpart) return [];
  326. const keys = pairMap.get(counterpart.toLowerCase());
  327. if (!keys || keys.size === 0) return [];
  328. return options.filter(o => keys.has(o.toLowerCase()));
  329. }
  330. // Recent colors management
  331. export function loadRecentColors(): ColorPreset[] {
  332. try {
  333. const stored = localStorage.getItem(RECENT_COLORS_KEY);
  334. if (stored) {
  335. return JSON.parse(stored) as ColorPreset[];
  336. }
  337. } catch {
  338. // Ignore errors
  339. }
  340. return [];
  341. }
  342. export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
  343. const filtered = currentRecent.filter(
  344. c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
  345. );
  346. const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
  347. try {
  348. localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
  349. } catch {
  350. // Ignore errors
  351. }
  352. return updated;
  353. }
  354. // Normalise a Bambu filament identifier to its bare filament_id form (#1688).
  355. // Spools store ``slicer_filament`` as a setting_id like "GFSG98_09" (the "_NN"
  356. // suffix is the variant, the "S" infix marks it as a setting_id); printer
  357. // K-profiles store ``filament_id`` as "GFG98" (bare). Both shapes need
  358. // normalising before comparison. The rule and its exceptions (SUNLU, a few
  359. // P1/X1 presets, #3387) live in ``utils/filamentIds``.
  360. //
  361. // Non-Bambu IDs (numeric local-preset IDs, Orca UUIDs) are returned unchanged
  362. // uppercase — they won't match any K-profile's filament_id and the caller
  363. // falls through to name-based matching.
  364. export function toFilamentId(id: string | null | undefined): string {
  365. return settingIdToFilamentId((id || '').toUpperCase());
  366. }
  367. // "GFx99" identifiers (GFL99, GFG99, GFB99, ...) are Bambu's *generic* filament
  368. // IDs — one per material, shared across every physical filament the user hasn't
  369. // given a specific preset. They still identify a material unambiguously, so an
  370. // exact generic id match is only ambiguous about *brand*, never about material.
  371. export function isGenericFilamentId(id: string | null | undefined): boolean {
  372. return !!id && /^GF[A-Z]99$/i.test(id);
  373. }
  374. // The material each generic Bambu filament ID stands for. Used to sanity-check
  375. // a generic id-match against the material the caller already knows (#2710): a
  376. // PETG spool must never claim GFL99 (generic PLA) profiles just because both
  377. // sides happen to have stored the same generic id.
  378. const GENERIC_FILAMENT_MATERIALS: Record<string, string> = {
  379. GFB99: 'ABS',
  380. GFC99: 'PC',
  381. GFG99: 'PETG',
  382. GFL99: 'PLA',
  383. GFN99: 'PA',
  384. GFP99: 'PE',
  385. GFR99: 'EVA',
  386. GFS99: 'PVA',
  387. GFU99: 'TPU',
  388. };
  389. // Material a generic filament ID stands for ("GFL99" → "PLA"), or '' when the
  390. // ID isn't a known generic one.
  391. export function materialForGenericFilamentId(id: string | null | undefined): string {
  392. if (!id) return '';
  393. return GENERIC_FILAMENT_MATERIALS[id.toUpperCase()] || '';
  394. }
  395. // Bambu labels nylon "PA"; users routinely type "Nylon". Compare materials
  396. // through this so the two spellings agree.
  397. function normaliseMaterial(material: string): string {
  398. const upper = material.trim().toUpperCase();
  399. return upper === 'NYLON' ? 'PA' : upper;
  400. }
  401. // True when a generic filament ID may stand in for the given material — i.e.
  402. // the ID is generic and describes that same material.
  403. export function genericFilamentIdMatchesMaterial(id: string, material: string): boolean {
  404. const generic = materialForGenericFilamentId(id);
  405. return !!generic && !!material && normaliseMaterial(generic) === normaliseMaterial(material);
  406. }
  407. // Check if a calibration matches based on brand, material, and variant
  408. /**
  409. * Key one hotend's K-profile selection: printer, extruder, nozzle diameter.
  410. *
  411. * All three because that is what both K tables are keyed on -- a K value is
  412. * measured on one individual hotend, and cali_idx alone cannot identify one
  413. * (the printer numbers its calibration table PER NOZZLE, so index 16 exists on
  414. * both hotends of a dual-nozzle machine meaning different things).
  415. */
  416. export function hotendKey(printerId: number, extruder: number, diameter: string): string {
  417. return `${printerId}:${extruder}:${diameter}`;
  418. }
  419. /**
  420. * K profiles picked for a new spool, plus its product's profile default
  421. * (#1326) for every hotend nothing was picked for. For a page that saves a
  422. * new spool's K profiles without showing the default: the save replaces the
  423. * whole list, so the default the create applied would otherwise be lost. A
  424. * failed lookup returns the picks alone.
  425. */
  426. export async function withProductDefaultKProfiles(
  427. picked: SpoolKProfileInput[],
  428. product: { brand: string; material: string; subtype: string; color_name: string },
  429. ): Promise<SpoolKProfileInput[]> {
  430. const keyOf = (k: SpoolKProfileInput) =>
  431. hotendKey(k.printer_id, k.extruder ?? 0, (k.nozzle_diameter || '').trim() || '0.4');
  432. let resolved: Awaited<ReturnType<typeof api.resolveProfileDefault>>;
  433. try {
  434. resolved = await api.resolveProfileDefault({
  435. brand: product.brand.trim(),
  436. material: product.material.trim(),
  437. subtype: product.subtype.trim(),
  438. color_name: product.color_name.trim(),
  439. });
  440. } catch {
  441. return picked;
  442. }
  443. const taken = new Set(picked.map(keyOf));
  444. const merged = [...picked];
  445. for (const k of resolved?.k_profiles ?? []) {
  446. if (taken.has(keyOf(k))) continue;
  447. merged.push(k);
  448. taken.add(keyOf(k));
  449. }
  450. return merged;
  451. }
  452. /**
  453. * The Printers tab's selection, one entry per hotend, from stored K profiles:
  454. * a spool's, or a profile default's. The stored row carries everything the
  455. * picker needs to show the selection before the printer answers, so an
  456. * offline printer still renders what was chosen for it. Rows without a
  457. * calibration slot are skipped -- there is nothing on the printer to select.
  458. */
  459. export function selectionFromKProfiles(rows: SpoolKProfileInput[]): Map<string, CalibrationProfile> {
  460. const chosen = new Map<string, CalibrationProfile>();
  461. for (const p of rows) {
  462. if (p.cali_idx === null || p.cali_idx === undefined) continue;
  463. const diameter = (p.nozzle_diameter || '').trim() || '0.4';
  464. const extruder = p.extruder ?? 0;
  465. chosen.set(hotendKey(p.printer_id, extruder, diameter), {
  466. cali_idx: p.cali_idx,
  467. filament_id: '',
  468. setting_id: p.setting_id || '',
  469. name: p.name || '',
  470. k_value: p.k_value,
  471. n_coef: 0,
  472. extruder_id: extruder,
  473. nozzle_diameter: diameter,
  474. // Stored as the bare flow code; the picker re-derives its label
  475. // from the same field it reads off a live profile.
  476. nozzle_id: p.nozzle_type || '',
  477. });
  478. }
  479. return chosen;
  480. }
  481. /**
  482. * Key one per-printer-model preset override.
  483. *
  484. * Diameter "" is the model's own default and a bare decimal is a per-hotend
  485. * exception, matching the (printer_model, nozzle_diameter) pair the backend
  486. * stores. The separator is a NUL because a model name is free text from the
  487. * printer ("A1 mini") and must never be able to collide with a diameter.
  488. */
  489. export function presetKey(model: string, diameter: string): string {
  490. return `${model}\u0000${diameter}`;
  491. }
  492. /** Split a `presetKey` back into its model and diameter. */
  493. export function parsePresetKey(key: string): { model: string; diameter: string } {
  494. const [model = '', diameter = ''] = key.split('\u0000');
  495. return { model, diameter };
  496. }
  497. export function isMatchingCalibration(
  498. cal: { name?: string; filament_id?: string },
  499. formData: { material: string; brand: string; subtype: string; slicer_filament?: string },
  500. ): boolean {
  501. if (!formData.material) return false;
  502. // Preferred path: exact filament_id match after normalising both sides
  503. // (#1688). When the spool has a non-generic preset assigned and it agrees
  504. // with the K-profile's filament_id, this is unambiguous — no name parsing
  505. // needed. A spool storing "GFSG98_09" matches a K-profile with filament_id
  506. // "GFG98" without going anywhere near parsePresetName.
  507. const spoolFid = toFilamentId(formData.slicer_filament);
  508. const calFid = toFilamentId(cal.filament_id);
  509. if (spoolFid && calFid && spoolFid === calFid) {
  510. if (!isGenericFilamentId(calFid)) {
  511. return true;
  512. }
  513. // Both sides carry the same *generic* id (#2710). That still pins the
  514. // material, so the only thing left ambiguous is brand — a printer holds
  515. // one flat calibration table per generic id and users routinely name
  516. // those entries by colour ("Dark Brown", "Marble"), which no amount of
  517. // name parsing can tie back to a material. Accept the match when the
  518. // material agrees and the spool claims no brand of its own; a spool that
  519. // does name a brand keeps the stricter name-based path below so its
  520. // suggestions stay brand-specific.
  521. const brand = formData.brand.trim();
  522. const brandIsGeneric = !brand || brand.toUpperCase() === 'GENERIC';
  523. if (brandIsGeneric && genericFilamentIdMatchesMaterial(calFid, formData.material)) {
  524. return true;
  525. }
  526. }
  527. const profileName = cal.name || '';
  528. // Remove flow type prefixes
  529. const cleanName = profileName
  530. .replace(/^High Flow[_\s]+/i, '')
  531. .replace(/^Standard[_\s]+/i, '')
  532. .replace(/^HF[_\s]+/i, '')
  533. .replace(/^S[_\s]+/i, '')
  534. .trim();
  535. const parsed = parsePresetName(cleanName);
  536. // Match material (required)
  537. const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
  538. if (!materialMatch) return false;
  539. // Match brand if specified in form
  540. if (formData.brand) {
  541. const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
  542. formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
  543. if (!brandMatch) return false;
  544. }
  545. // Match variant/subtype if specified in form
  546. if (formData.subtype) {
  547. const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
  548. formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
  549. cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
  550. if (!variantMatch) return false;
  551. }
  552. return true;
  553. }