filamentPresets.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. // Tiered filament-preset list, shared by every picker that has to offer "all
  2. // the filaments this install knows about".
  3. //
  4. // Lookup order is fixed across the app: local imported > Orca Cloud > Bambu
  5. // Cloud > hardcoded built-in table. It mirrors ConfigureAmsSlotModal's picker
  6. // and SliceModal's tier groups, so a filament the user sees in one place is
  7. // named and ranked the same way in the others.
  8. //
  9. // The built-in table is the floor, not an equal source: it is a static list
  10. // compiled into the backend, so it is the only tier that can never be empty
  11. // and the only one that works with no cloud account and nothing imported.
  12. import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../api/client';
  13. import { parsePresetName } from '../components/spool-form/utils';
  14. import { cloudPresetFilamentId } from './filamentIds';
  15. export type FilamentPresetSource = 'local' | 'orca_cloud' | 'cloud' | 'builtin';
  16. export interface FilamentPresetOption {
  17. /** Opaque, source-prefixed handle: ``local_12`` / ``orca_<uuid>`` / a Bambu
  18. * cloud setting_id / ``builtin_GFA00``. Prefixes match the convention
  19. * ConfigureAmsSlotModal already uses so the two can share resolvers. */
  20. id: string;
  21. name: string;
  22. source: FilamentPresetSource;
  23. /** The Bambu filament id this preset resolves to, when it is derivable
  24. * without a network round trip. Empty for Bambu Cloud *user* presets, whose
  25. * real filament_id only exists in the cloud detail — see
  26. * resolveFilamentId. */
  27. filamentId: string;
  28. /** Material as the preset itself declares it, used to derive a generic
  29. * filament id for tiers that carry no Bambu id of their own. */
  30. filamentType: string;
  31. }
  32. export interface FilamentPresetSources {
  33. localPresets?: LocalPreset[];
  34. orcaProfiles?: OrcaProfileMeta[];
  35. cloudSettings?: SlicerSetting[];
  36. builtinFilaments?: BuiltinFilament[];
  37. }
  38. /** Generic Bambu filament ids by material. Local and Orca Cloud presets carry
  39. * no Bambu filament id, but the printer's calibration table is indexed by
  40. * one, so the closest generic is what a calibration for such a preset has to
  41. * be filed under. Same table and same fallback chain as the AMS slot
  42. * configure flow — the two must agree or a profile created here won't match
  43. * the slot configured there. */
  44. const GENERIC_FILAMENT_IDS: Record<string, string> = {
  45. 'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
  46. 'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
  47. 'ABS': 'GFB99', 'ASA': 'GFB98',
  48. 'PC': 'GFC99',
  49. 'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
  50. 'TPU': 'GFU99',
  51. 'PVA': 'GFS99', 'HIPS': 'GFS98',
  52. 'PE': 'GFP99', 'PP': 'GFP97',
  53. };
  54. /** Resolve a material string to a generic Bambu filament id, trying the exact
  55. * spelling before progressively stripping the suffixes slicer presets add
  56. * ("-CF", "+", " HF"). Returns '' when nothing matches, which callers must
  57. * treat as "not calibratable" rather than substituting a default — filing a
  58. * calibration under the wrong material is worse than refusing. */
  59. export function genericFilamentIdForMaterial(material: string | null | undefined): string {
  60. const m = (material || '').toUpperCase().trim();
  61. if (!m) return '';
  62. return GENERIC_FILAMENT_IDS[m]
  63. || GENERIC_FILAMENT_IDS[m.replace(/[-\s]?CF$/, '')]
  64. || GENERIC_FILAMENT_IDS[m.replace(/\+$/, '')]
  65. || GENERIC_FILAMENT_IDS[m.split(/[-\s]/)[0]]
  66. || '';
  67. }
  68. /** Strip the printer/nozzle suffix and the "# " custom-preset marker a preset
  69. * name may carry, e.g. "Elegoo PLA+ @BBL X1C 0.4 nozzle" → "Elegoo PLA+". */
  70. export function presetDisplayName(name: string): string {
  71. const withoutSuffix = name.replace(/@.+$/, '').trim();
  72. return withoutSuffix.startsWith('# ') ? withoutSuffix.slice(2).trim() : withoutSuffix;
  73. }
  74. const SOURCE_ORDER: Record<FilamentPresetSource, number> = {
  75. local: 0,
  76. orca_cloud: 1,
  77. cloud: 2,
  78. builtin: 3,
  79. };
  80. /**
  81. * Merge every filament source into one ranked list.
  82. *
  83. * Deduplication is deliberately asymmetric, because "the same name in two
  84. * tiers" means different things depending on which tiers:
  85. *
  86. * - *Within* a tier, by resolved filament id or display name. This is what
  87. * collapses the per-printer-model copies a cloud account carries —
  88. * "Bambu PLA Basic @BBL X1C", "@BBL P1S", "@BBL A1" are one name once the
  89. * suffix is stripped — and repeated imports of one filament for several
  90. * printers.
  91. *
  92. * - *Across* tiers, by id only. Two entries carrying the same id really are
  93. * one record reached by two routes; two entries merely sharing a name are
  94. * not. Imported presets and an Orca Cloud library overlap heavily by name
  95. * (they are usually the same profiles, synced), and suppressing one for the
  96. * other empties a tier the user curated on purpose. The heading says where
  97. * each came from, which is the point of having tiers at all.
  98. *
  99. * - *Into the built-in tier*, by name as well as by id. That tier is a static
  100. * table of the same Bambu catalogue every other source also ships, so
  101. * without a name check it echoes back everything above it. It exists to
  102. * guarantee the list is never empty, not to be a fourth copy.
  103. */
  104. export function buildFilamentPresetOptions(sources: FilamentPresetSources): FilamentPresetOption[] {
  105. const { localPresets, orcaProfiles, cloudSettings, builtinFilaments } = sources;
  106. const options: FilamentPresetOption[] = [];
  107. const nameKey = (name: string) => name.trim().toLowerCase();
  108. // Ids seen anywhere: a cloud setting_id, an Orca profile id, a resolved
  109. // filament id. Shared across tiers — an id collision is true identity.
  110. const claimedIds = new Set<string>();
  111. // Names seen, scoped to one tier, so two tiers can each list "Elegoo PLA+".
  112. const namesInTier = new Set<string>();
  113. // Every name any real source offered, consulted only by the built-in tier.
  114. const namesOffered = new Set<string>();
  115. const take = (source: FilamentPresetSource, name: string, ...ids: (string | undefined)[]): boolean => {
  116. const usableIds = ids.filter((k): k is string => !!k);
  117. if (usableIds.some(k => claimedIds.has(k))) return false;
  118. const scoped = `${source}|${nameKey(name)}`;
  119. if (namesInTier.has(scoped)) return false;
  120. usableIds.forEach(k => claimedIds.add(k));
  121. namesInTier.add(scoped);
  122. namesOffered.add(nameKey(name));
  123. return true;
  124. };
  125. // 1. Local imported presets. filament_id lives in the preset's setting JSON,
  126. // which the list endpoint doesn't return, so the generic material id is what
  127. // we can offer without a per-preset detail fetch.
  128. for (const lp of localPresets ?? []) {
  129. const name = presetDisplayName(lp.name);
  130. const material = lp.filament_type || parsePresetName(name).material;
  131. // No id is claimed here: the generic id an import maps to is shared by
  132. // every filament of that material, so claiming it would let the first
  133. // imported PLA swallow every other PLA in the list.
  134. if (!take('local', name)) continue;
  135. options.push({
  136. id: `local_${lp.id}`,
  137. name,
  138. source: 'local',
  139. filamentId: genericFilamentIdForMaterial(material),
  140. filamentType: material || '',
  141. });
  142. }
  143. // 2. Orca Cloud. setting_ids are UUIDs a Bambu printer can't resolve, so
  144. // these also fall back to the generic id for their material.
  145. for (const op of orcaProfiles ?? []) {
  146. const name = presetDisplayName(op.name);
  147. const material = parsePresetName(name).material;
  148. // Same reasoning as the local tier for the generic id. The Orca profile id
  149. // is claimed, so a Bambu Cloud row carrying that same id is recognised as
  150. // the same record — a shared *name* is not, since an Orca library and an
  151. // imported bundle are usually the same profiles reached two ways and both
  152. // are worth showing under their own heading.
  153. if (!take('orca_cloud', name, op.setting_id)) continue;
  154. options.push({
  155. id: `orca_${op.setting_id}`,
  156. name,
  157. source: 'orca_cloud',
  158. filamentId: genericFilamentIdForMaterial(material),
  159. filamentType: material,
  160. });
  161. }
  162. // 3. Bambu Cloud. Bambu's list carries each preset's filament id; without
  163. // it, official presets (GFS…) give theirs by their setting_id. User presets
  164. // (PFUS… / PFCN…) do not, and the rule would hand back the raw cloud id,
  165. // which the printer rejects. Leave those empty here and let
  166. // resolveFilamentId fetch the detail on selection. These options are picked
  167. // by Bambu's name, so a bare official id that could name two filaments is
  168. // the one Bambu's list says (see cloudPresetFilamentId, #3387).
  169. for (const cp of cloudSettings ?? []) {
  170. const name = presetDisplayName(cp.name);
  171. // Cloud setting_ids carry a variant suffix ("GFSA00_01"); claim the bare
  172. // filament id as well, or the built-in tier won't recognise the filament
  173. // as covered and will list it again under its own heading.
  174. const filamentId = cp.setting_id.startsWith('GFS') ? cloudPresetFilamentId(cp, { pickedFromList: true }) : '';
  175. if (!take('cloud', name, cp.setting_id, filamentId || undefined)) continue;
  176. options.push({
  177. id: cp.setting_id,
  178. name,
  179. source: 'cloud',
  180. filamentId,
  181. filamentType: parsePresetName(name).material,
  182. });
  183. }
  184. // 4. Hardcoded fallback. Always present, so the picker is never empty even
  185. // with no cloud account and nothing imported — but only for filaments none
  186. // of the tiers above already offered.
  187. for (const bf of builtinFilaments ?? []) {
  188. // Cloud setting_ids insert an "S" after "GF" ("GFA00" → "GFSA00"); check
  189. // both spellings so a filament a cloud tier already offered isn't listed
  190. // a second time under a slightly different id.
  191. const asSettingId = bf.filament_id.startsWith('GF') ? `GFS${bf.filament_id.slice(2)}` : bf.filament_id;
  192. // Unlike the tiers above, a name match is enough to skip: this table is a
  193. // static copy of the same catalogue, not a library of its own.
  194. if (namesOffered.has(nameKey(bf.name))) continue;
  195. if (!take('builtin', bf.name, bf.filament_id, asSettingId)) continue;
  196. options.push({
  197. id: `builtin_${bf.filament_id}`,
  198. name: bf.name,
  199. source: 'builtin',
  200. filamentId: bf.filament_id,
  201. filamentType: parsePresetName(bf.name).material,
  202. });
  203. }
  204. return options.sort((a, b) => {
  205. if (a.source !== b.source) return SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source];
  206. return a.name.localeCompare(b.name);
  207. });
  208. }
  209. /**
  210. * The Bambu filament id to file a calibration under for a chosen preset.
  211. *
  212. * Everything except a Bambu Cloud *user* preset is already resolved by
  213. * buildFilamentPresetOptions; those need the cloud detail, because the
  214. * PFUS/PFCN setting_id is not a filament id and the printer's calibration
  215. * table is indexed by filament id. ``fetchDetail`` is injected so the pure
  216. * cases stay testable without a network stub.
  217. */
  218. export async function resolveFilamentId(
  219. option: FilamentPresetOption,
  220. fetchDetail?: (settingId: string) => Promise<{ filament_id?: string | null }>,
  221. ): Promise<string> {
  222. if (option.filamentId) return option.filamentId;
  223. if (option.source !== 'cloud' || !fetchDetail) return '';
  224. try {
  225. const detail = await fetchDetail(option.id);
  226. // Never fall back to the preset's base_id: that collapses a custom preset
  227. // onto the generic it inherits from, and the printer then resolves the
  228. // calibration to "Generic …" instead of the user's filament (#1053).
  229. return detail.filament_id || '';
  230. } catch {
  231. return '';
  232. }
  233. }