utils.ts 23 KB

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