utils.ts 20 KB

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