utils.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
  2. import type { ColorPreset, FilamentOption } from './types';
  3. import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
  4. // Fallback filament presets when cloud is not available
  5. const FALLBACK_PRESETS: FilamentOption[] = [
  6. { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
  7. { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
  8. { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
  9. { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
  10. { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
  11. { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
  12. { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
  13. { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
  14. { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
  15. { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
  16. { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
  17. { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
  18. { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
  19. { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
  20. { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
  21. { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
  22. { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
  23. ];
  24. // Parse a slicer preset name to extract brand, material, and variant
  25. export function parsePresetName(name: string): { brand: string; material: string; variant: string } {
  26. // Remove @printer suffix (e.g., "@Bambu Lab H2D 0.4 nozzle")
  27. let cleanName = name.replace(/@.*$/, '').trim();
  28. // Remove (Custom) tag
  29. cleanName = cleanName.replace(/\(Custom\)/i, '').trim();
  30. // Remove leading # or * markers
  31. cleanName = cleanName.replace(/^[#*]+\s*/, '').trim();
  32. // Materials list - order matters (longer/more specific first)
  33. const materials = [
  34. 'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',
  35. 'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',
  36. 'PCTG', 'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PEEK', 'PEI',
  37. ];
  38. // Find material in the name
  39. let material = '';
  40. let materialIdx = -1;
  41. for (const m of materials) {
  42. const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());
  43. if (idx !== -1) {
  44. material = m;
  45. materialIdx = idx;
  46. break;
  47. }
  48. }
  49. // Brand is everything before the material
  50. let brand = '';
  51. if (materialIdx > 0) {
  52. brand = cleanName.substring(0, materialIdx).trim();
  53. brand = brand.replace(/[-_\s]+$/, '');
  54. }
  55. // Everything after material is potential variant
  56. let afterMaterial = '';
  57. if (materialIdx !== -1 && material) {
  58. afterMaterial = cleanName.substring(materialIdx + material.length).trim();
  59. afterMaterial = afterMaterial.replace(/^[-_\s]+/, '');
  60. }
  61. // Check for known variant - could be before OR after material
  62. let variant = '';
  63. // First check after material (most common)
  64. for (const v of KNOWN_VARIANTS) {
  65. if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {
  66. variant = v;
  67. break;
  68. }
  69. }
  70. // If no variant found after material, check if brand contains a known variant
  71. if (!variant && brand) {
  72. for (const v of KNOWN_VARIANTS) {
  73. const variantPattern = new RegExp(`\\s+${v}$`, 'i');
  74. if (variantPattern.test(brand)) {
  75. variant = v;
  76. brand = brand.replace(variantPattern, '').trim();
  77. break;
  78. }
  79. }
  80. }
  81. return { brand, material, variant };
  82. }
  83. // Extract unique brands from cloud presets and local presets
  84. export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
  85. const brandSet = new Set<string>(DEFAULT_BRANDS);
  86. for (const preset of presets) {
  87. const { brand } = parsePresetName(preset.name);
  88. if (brand && brand.length > 1) {
  89. brandSet.add(brand);
  90. }
  91. }
  92. // Also extract brands from local presets
  93. if (localPresets) {
  94. for (const preset of localPresets) {
  95. if (preset.filament_vendor && preset.filament_vendor.length > 1) {
  96. brandSet.add(preset.filament_vendor);
  97. } else {
  98. const { brand } = parsePresetName(preset.name);
  99. if (brand && brand.length > 1) {
  100. brandSet.add(brand);
  101. }
  102. }
  103. }
  104. }
  105. return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
  106. }
  107. // Build filament options from local presets (OrcaSlicer / BambuStudio imports).
  108. // Each preset gets its own entry — no base-name collapse — so the spool form
  109. // shows all per-printer/per-nozzle variants the user has imported. The spool
  110. // itself is printer-agnostic, so the variant the user picks just becomes the
  111. // stored slicer_filament code (consumed by normalize_slicer_filament during
  112. // slicing — kept as preset.filament_type when available so the existing
  113. // "GFL05"-style normalisation still resolves).
  114. function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
  115. const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
  116. if (filamentPresets.length === 0) return [];
  117. const options: FilamentOption[] = filamentPresets.map(preset => {
  118. // Use the unique preset.id (stringified) as the code so each local preset
  119. // has its own identity. Earlier this was preset.filament_type (e.g. "PLA")
  120. // which collapsed every PLA local preset onto the same code — picking any
  121. // of them saved slicer_filament="PLA", a material name the backend cannot
  122. // resolve back to a specific preset row. The backend handler at
  123. // inventory.py expects numeric IDs for local-preset slicer_filament values.
  124. // allCodes still carries the legacy filament_type so findPresetOption
  125. // resolves existing saved spools that have the old material-name code.
  126. const code = String(preset.id);
  127. const legacyCode = preset.filament_type || code;
  128. const allCodes = Array.from(new Set([code, legacyCode]));
  129. return {
  130. code,
  131. name: preset.name,
  132. displayName: preset.name,
  133. isCustom: false,
  134. allCodes,
  135. };
  136. });
  137. return options.sort((a, b) => a.displayName.localeCompare(b.displayName));
  138. }
  139. // Build filament options by merging cloud presets, local profiles, and built-in
  140. // filaments — matching the behavior of ConfigureAmsSlotModal and the wiki's
  141. // "Where Presets Come From" section. Earlier versions were precedence-based
  142. // (cloud-only when cloud had any presets), which silently hid Local Profiles
  143. // from users logged into Bambu Cloud — see #1248.
  144. export function buildFilamentOptions(
  145. cloudPresets: SlicerSetting[],
  146. configuredPrinterModels: Set<string>,
  147. localPresets?: LocalPreset[],
  148. builtinFilaments?: BuiltinFilament[],
  149. ): FilamentOption[] {
  150. const customPresets: FilamentOption[] = [];
  151. const defaultPresets: FilamentOption[] = [];
  152. const cloudCodes = new Set<string>();
  153. // 1. Cloud presets — each setting_id gets its own entry. The spool form is
  154. // printer-agnostic so we deliberately do NOT collapse "@P1S" / "@X1C"
  155. // variants into a single row; the user picks the variant they want and
  156. // its setting_id is what gets persisted.
  157. for (const preset of cloudPresets) {
  158. if (preset.is_custom) {
  159. const presetNameUpper = preset.name.toUpperCase();
  160. const matchesPrinter = configuredPrinterModels.size === 0 ||
  161. Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
  162. !presetNameUpper.includes('@');
  163. if (matchesPrinter) {
  164. customPresets.push({
  165. code: preset.setting_id,
  166. name: preset.name,
  167. displayName: `${preset.name} (Custom)`,
  168. isCustom: true,
  169. allCodes: [preset.setting_id],
  170. });
  171. cloudCodes.add(preset.setting_id);
  172. }
  173. } else {
  174. defaultPresets.push({
  175. code: preset.setting_id,
  176. name: preset.name,
  177. displayName: preset.name,
  178. isCustom: false,
  179. allCodes: [preset.setting_id],
  180. });
  181. cloudCodes.add(preset.setting_id);
  182. }
  183. }
  184. // 2. Local profiles (OrcaSlicer / BambuStudio imports)
  185. const localOptions = localPresets && localPresets.length > 0
  186. ? buildLocalFilamentOptions(localPresets)
  187. : [];
  188. // 3. Built-in filaments — only those not already represented by a cloud preset.
  189. // Cloud setting_ids look like "GFSA00", built-in filament_ids look like "GFA00";
  190. // map between the two so we don't render the same filament twice.
  191. const builtinOptions: FilamentOption[] = [];
  192. if (builtinFilaments && builtinFilaments.length > 0) {
  193. for (const bf of builtinFilaments) {
  194. const settingId = bf.filament_id.startsWith('GF')
  195. ? 'GFS' + bf.filament_id.slice(2)
  196. : bf.filament_id;
  197. if (cloudCodes.has(bf.filament_id) || cloudCodes.has(settingId)) continue;
  198. builtinOptions.push({
  199. code: bf.filament_id,
  200. name: bf.name,
  201. displayName: bf.name,
  202. isCustom: false,
  203. allCodes: [bf.filament_id, settingId],
  204. });
  205. }
  206. }
  207. const merged = [
  208. ...customPresets,
  209. ...defaultPresets,
  210. ...localOptions,
  211. ...builtinOptions,
  212. ];
  213. // 4. Hardcoded fallback only when literally every source is empty.
  214. if (merged.length === 0) return FALLBACK_PRESETS;
  215. return merged.sort((a, b) => a.displayName.localeCompare(b.displayName));
  216. }
  217. // Find selected preset option
  218. export function findPresetOption(
  219. slicerFilament: string,
  220. filamentOptions: FilamentOption[],
  221. ): FilamentOption | undefined {
  222. if (!slicerFilament) return undefined;
  223. // First try exact match on primary code
  224. let option = filamentOptions.find(o => o.code === slicerFilament);
  225. if (!option) {
  226. // Try matching against any code in allCodes
  227. option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
  228. }
  229. if (!option) {
  230. // Try case-insensitive match
  231. const slicerLower = slicerFilament.toLowerCase();
  232. option = filamentOptions.find(o =>
  233. o.code.toLowerCase() === slicerLower ||
  234. o.allCodes.some(c => c.toLowerCase() === slicerLower),
  235. );
  236. }
  237. return option;
  238. }
  239. // Recent colors management
  240. export function loadRecentColors(): ColorPreset[] {
  241. try {
  242. const stored = localStorage.getItem(RECENT_COLORS_KEY);
  243. if (stored) {
  244. return JSON.parse(stored) as ColorPreset[];
  245. }
  246. } catch {
  247. // Ignore errors
  248. }
  249. return [];
  250. }
  251. export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
  252. const filtered = currentRecent.filter(
  253. c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
  254. );
  255. const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
  256. try {
  257. localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
  258. } catch {
  259. // Ignore errors
  260. }
  261. return updated;
  262. }
  263. // Check if a calibration matches based on brand, material, and variant
  264. export function isMatchingCalibration(
  265. cal: { name?: string; filament_id?: string },
  266. formData: { material: string; brand: string; subtype: string },
  267. ): boolean {
  268. if (!formData.material) return false;
  269. const profileName = cal.name || '';
  270. // Remove flow type prefixes
  271. const cleanName = profileName
  272. .replace(/^High Flow[_\s]+/i, '')
  273. .replace(/^Standard[_\s]+/i, '')
  274. .replace(/^HF[_\s]+/i, '')
  275. .replace(/^S[_\s]+/i, '')
  276. .trim();
  277. const parsed = parsePresetName(cleanName);
  278. // Match material (required)
  279. const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
  280. if (!materialMatch) return false;
  281. // Match brand if specified in form
  282. if (formData.brand) {
  283. const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
  284. formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
  285. if (!brandMatch) return false;
  286. }
  287. // Match variant/subtype if specified in form
  288. if (formData.subtype) {
  289. const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
  290. formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
  291. cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
  292. if (!variantMatch) return false;
  293. }
  294. return true;
  295. }