utils.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import type { SlicerSetting, LocalPreset } 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. 'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PCTG', '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 imports)
  108. function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
  109. const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
  110. if (filamentPresets.length === 0) return [];
  111. const presetsMap = new Map<string, FilamentOption>();
  112. for (const preset of filamentPresets) {
  113. const baseName = preset.name.replace(/@.*$/, '').trim();
  114. const existing = presetsMap.get(baseName);
  115. if (existing) {
  116. existing.allCodes.push(String(preset.id));
  117. } else {
  118. // Use filament_type as the code if available (e.g. "GFL00"), otherwise use the id
  119. const code = preset.filament_type || String(preset.id);
  120. presetsMap.set(baseName, {
  121. code,
  122. name: baseName,
  123. displayName: baseName,
  124. isCustom: false,
  125. allCodes: [code],
  126. });
  127. }
  128. }
  129. return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
  130. }
  131. // Build filament options: cloud presets → local presets → hardcoded fallback
  132. export function buildFilamentOptions(
  133. cloudPresets: SlicerSetting[],
  134. configuredPrinterModels: Set<string>,
  135. localPresets?: LocalPreset[],
  136. ): FilamentOption[] {
  137. // 1. Cloud presets (highest priority)
  138. if (cloudPresets.length > 0) {
  139. const customPresets: FilamentOption[] = [];
  140. const defaultPresetsMap = new Map<string, FilamentOption>();
  141. for (const preset of cloudPresets) {
  142. if (preset.is_custom) {
  143. // Custom presets: include if matches configured printers or no printer filter
  144. const presetNameUpper = preset.name.toUpperCase();
  145. const matchesPrinter = configuredPrinterModels.size === 0 ||
  146. Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
  147. !presetNameUpper.includes('@');
  148. if (matchesPrinter) {
  149. customPresets.push({
  150. code: preset.setting_id,
  151. name: preset.name,
  152. displayName: `${preset.name} (Custom)`,
  153. isCustom: true,
  154. allCodes: [preset.setting_id],
  155. });
  156. }
  157. } else {
  158. // Default presets: deduplicate by base name
  159. const baseName = preset.name.replace(/@.*$/, '').trim();
  160. const existing = defaultPresetsMap.get(baseName);
  161. if (existing) {
  162. existing.allCodes.push(preset.setting_id);
  163. } else {
  164. defaultPresetsMap.set(baseName, {
  165. code: preset.setting_id,
  166. name: baseName,
  167. displayName: baseName,
  168. isCustom: false,
  169. allCodes: [preset.setting_id],
  170. });
  171. }
  172. }
  173. }
  174. return [
  175. ...customPresets,
  176. ...Array.from(defaultPresetsMap.values()),
  177. ].sort((a, b) => a.displayName.localeCompare(b.displayName));
  178. }
  179. // 2. Local presets (OrcaSlicer imports)
  180. if (localPresets && localPresets.length > 0) {
  181. const localOptions = buildLocalFilamentOptions(localPresets);
  182. if (localOptions.length > 0) return localOptions;
  183. }
  184. // 3. Hardcoded fallback
  185. return FALLBACK_PRESETS;
  186. }
  187. // Find selected preset option
  188. export function findPresetOption(
  189. slicerFilament: string,
  190. filamentOptions: FilamentOption[],
  191. ): FilamentOption | undefined {
  192. if (!slicerFilament) return undefined;
  193. // First try exact match on primary code
  194. let option = filamentOptions.find(o => o.code === slicerFilament);
  195. if (!option) {
  196. // Try matching against any code in allCodes
  197. option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
  198. }
  199. if (!option) {
  200. // Try case-insensitive match
  201. const slicerLower = slicerFilament.toLowerCase();
  202. option = filamentOptions.find(o =>
  203. o.code.toLowerCase() === slicerLower ||
  204. o.allCodes.some(c => c.toLowerCase() === slicerLower),
  205. );
  206. }
  207. return option;
  208. }
  209. // Recent colors management
  210. export function loadRecentColors(): ColorPreset[] {
  211. try {
  212. const stored = localStorage.getItem(RECENT_COLORS_KEY);
  213. if (stored) {
  214. return JSON.parse(stored) as ColorPreset[];
  215. }
  216. } catch {
  217. // Ignore errors
  218. }
  219. return [];
  220. }
  221. export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
  222. const filtered = currentRecent.filter(
  223. c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
  224. );
  225. const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
  226. try {
  227. localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
  228. } catch {
  229. // Ignore errors
  230. }
  231. return updated;
  232. }
  233. // Check if a calibration matches based on brand, material, and variant
  234. export function isMatchingCalibration(
  235. cal: { name?: string; filament_id?: string },
  236. formData: { material: string; brand: string; subtype: string },
  237. ): boolean {
  238. if (!formData.material) return false;
  239. const profileName = cal.name || '';
  240. // Remove flow type prefixes
  241. const cleanName = profileName
  242. .replace(/^High Flow[_\s]+/i, '')
  243. .replace(/^Standard[_\s]+/i, '')
  244. .replace(/^HF[_\s]+/i, '')
  245. .replace(/^S[_\s]+/i, '')
  246. .trim();
  247. const parsed = parsePresetName(cleanName);
  248. // Match material (required)
  249. const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
  250. if (!materialMatch) return false;
  251. // Match brand if specified in form
  252. if (formData.brand) {
  253. const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
  254. formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
  255. if (!brandMatch) return false;
  256. }
  257. // Match variant/subtype if specified in form
  258. if (formData.subtype) {
  259. const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
  260. formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
  261. cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
  262. if (!variantMatch) return false;
  263. }
  264. return true;
  265. }