slicerPrinterMatch.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. // Printer-compatibility matching for the SliceModal's process / filament
  2. // dropdowns (#1325).
  3. //
  4. // Compatibility is resolved in this order, stopping on the first non-unknown
  5. // answer:
  6. //
  7. // 1. Imported (local-tier) presets carry the slicer's own
  8. // `compatible_printers` list — an exact list of printer-preset names.
  9. // 2. The `@<printer>` naming convention, in both shapes the slicer
  10. // writes: `@BBL <model>` on shipped cloud / standard presets, and
  11. // `@Bambu Lab <model> <size> nozzle` on presets a user saved for a
  12. // specific printer (#2628). The token → printer-fragment table is
  13. // derived from the backend's canonical PRINTER_MODEL_MAP (fetched via
  14. // /slicer/printer-models), not duplicated here.
  15. //
  16. // The result drives grouping, not hard hiding: a preset no rule covers
  17. // stays in the main list, and only a preset that resolves to a *different*
  18. // printer is pushed into an "Other printers" group.
  19. export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
  20. // Lookup tables consumed by `presetCompatibility`. `bambuModelByShortCode`
  21. // is the @BBL token → printer-preset fragment map derived from the backend's
  22. // PRINTER_MODEL_MAP — e.g. `X1C` → `X1 Carbon`. An empty map means the @BBL
  23. // fallback still works when token and printer-name fragment match directly
  24. // (raw-token comparison), and gracefully degrades otherwise.
  25. export interface PrinterCompatibilityIndex {
  26. bambuModelByShortCode: Record<string, string>;
  27. }
  28. /** An empty index — used when the model map hasn't loaded yet. */
  29. export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
  30. bambuModelByShortCode: {},
  31. };
  32. // Bambu cloud started shipping terse model codes in `@BBL <code>` suffixes
  33. // mid-2026 — the most visible one is "A1 Mini" → "A1M" (#1649, reported by
  34. // @technopaw). User-authored profiles still use the long display name, so
  35. // both shapes have to match the same printer. The table is uppercase-normalised
  36. // for case-insensitive lookups; add a row when a future rename is spotted via
  37. // `/api/v1/cloud/settings`. Keep narrow on purpose — wide-net aliasing
  38. // (e.g. "X1" ⇄ "X1C") would silently group truly distinct printers.
  39. const PRINTER_MODEL_SUFFIX_ALIASES: Record<string, readonly string[]> = {
  40. 'A1 MINI': ['A1M'],
  41. };
  42. /**
  43. * True when ``presetSuffix`` (the token extracted from a "@BBL <code>" or
  44. * preset-name suffix) refers to the same printer as ``printerModel``
  45. * (the display name selected in the picker). Case-insensitive; consults
  46. * the alias table for short codes Bambu introduced after the long forms
  47. * shipped (#1649).
  48. */
  49. export function matchesPrinterModelSuffix(presetSuffix: string, printerModel: string): boolean {
  50. const p = presetSuffix.toUpperCase();
  51. const m = printerModel.toUpperCase();
  52. if (p === m) return true;
  53. const aliasesOfM = PRINTER_MODEL_SUFFIX_ALIASES[m];
  54. if (aliasesOfM && aliasesOfM.includes(p)) return true;
  55. const aliasesOfP = PRINTER_MODEL_SUFFIX_ALIASES[p];
  56. if (aliasesOfP && aliasesOfP.includes(m)) return true;
  57. return false;
  58. }
  59. /**
  60. * Invert the backend's PRINTER_MODEL_MAP into the shape the @BBL fallback
  61. * needs: short code → printer-preset fragment (the part of "Bambu Lab X1
  62. * Carbon" the user sees in a printer preset name, minus the "Bambu Lab "
  63. * brand prefix).
  64. *
  65. * Backend ships e.g. `{"Bambu Lab X1 Carbon": "X1C", "Bambu Lab A1 mini":
  66. * "A1 Mini", "Bambu Lab A1 Mini": "A1 Mini"}` — multiple long forms can map
  67. * to the same short. We pick the first long-form encountered for each short
  68. * code; case normalisation happens at match time so "A1 mini" vs "A1 Mini"
  69. * never matters.
  70. */
  71. function buildShortCodeMap(
  72. printerModels: Record<string, string>,
  73. ): Record<string, string> {
  74. const out: Record<string, string> = {};
  75. for (const [longName, shortCode] of Object.entries(printerModels)) {
  76. if (shortCode in out) continue;
  77. out[shortCode] = longName.replace(/^Bambu Lab\s+/, '');
  78. }
  79. return out;
  80. }
  81. /**
  82. * Build the compatibility index from the backend printer-model registry.
  83. */
  84. export function buildCompatibilityIndex(
  85. printerModels: Record<string, string> = {},
  86. ): PrinterCompatibilityIndex {
  87. return {
  88. bambuModelByShortCode: buildShortCodeMap(printerModels),
  89. };
  90. }
  91. function normalizeModelFragment(s: string): string {
  92. return s.replace(/\s+/g, '').toLowerCase();
  93. }
  94. // Bambu Studio's naming convention for bundled presets: the 0.4 nozzle is
  95. // the default and its variants drop the nozzle suffix; 0.2 / 0.6 / 0.8
  96. // carry an explicit "<size> nozzle" segment. So a process with no suffix
  97. // is implicitly a 0.4 process — required to compare correctly against a
  98. // 0.4 printer preset, which DOES carry the suffix.
  99. const DEFAULT_NOZZLE = '0.4';
  100. // Strip a trailing "<size> nozzle" segment, returning the nozzle string
  101. // (e.g. "0.6") or null when absent. Used by both BBL-token and printer-
  102. // preset extractors so the suffix is parsed identically on both sides.
  103. function takeNozzleSuffix(s: string): { stripped: string; nozzle: string | null } {
  104. const m = s.match(/^(.*?)\s+([\d.]+)\s*nozzle\s*$/i);
  105. if (!m) return { stripped: s.trim(), nozzle: null };
  106. return { stripped: m[1].trim(), nozzle: m[2] };
  107. }
  108. // Pull the model token and nozzle out of a "@BBL <token> [<size> nozzle]"
  109. // suffix. The token may contain a space (e.g. "A1 mini"), so we strip a
  110. // trailing nozzle segment rather than splitting on the first whitespace.
  111. function extractBblToken(presetName: string): { token: string; nozzle: string | null } | null {
  112. const marker = '@BBL ';
  113. const idx = presetName.indexOf(marker);
  114. if (idx < 0) return null;
  115. const rest = presetName.slice(idx + marker.length).trim();
  116. const { stripped, nozzle } = takeNozzleSuffix(rest);
  117. return stripped ? { token: stripped, nozzle } : null;
  118. }
  119. // Pull the model fragment and nozzle out of a "Bambu Lab <model> [<size>
  120. // nozzle]" printer preset name. Returns null for non-Bambu printer
  121. // presets — there is no reliable name-based match against those.
  122. function extractPrinterPresetModel(printerPresetName: string): { model: string; nozzle: string | null } | null {
  123. const m = printerPresetName.match(/^Bambu Lab\s+(.+)$/i);
  124. if (!m) return null;
  125. const { stripped, nozzle } = takeNozzleSuffix(m[1]);
  126. return stripped ? { model: stripped, nozzle } : null;
  127. }
  128. // Trailing parenthetical the slicer appends to user-saved presets —
  129. // "… @Bambu Lab H2D 0.4 nozzle (Custom)". Dropped before the nozzle suffix
  130. // is parsed, or the tag would resolve to a nonsense model token and the
  131. // preset would be branded a mismatch against its OWN printer.
  132. function stripTrailingParenthetical(s: string): string {
  133. return s.replace(/\s*\([^)]*\)\s*$/, '').trim();
  134. }
  135. // Nozzle sizes Bambu ships run 0.2 – 0.8. The range guard keeps a tag that
  136. // merely looks numeric ("PLA @2026") from being read as a nozzle and branded
  137. // incompatible with every printer.
  138. const MIN_NOZZLE_MM = 0.1;
  139. const MAX_NOZZLE_MM = 2.0;
  140. // Compare two nozzle strings numerically, so "0.20" and "0.2" are the same
  141. // size. Unparseable values never match — a size we can't read is not evidence.
  142. function sameNozzle(a: string, b: string): boolean {
  143. const x = Number.parseFloat(a);
  144. const y = Number.parseFloat(b);
  145. if (Number.isNaN(x) || Number.isNaN(y)) return false;
  146. return x === y;
  147. }
  148. // Pull the model token and nozzle out of a preset name's printer tag.
  149. // Three shapes exist in the wild (#2628):
  150. //
  151. // "0.20mm Standard @BBL X1C" — short code, the form
  152. // Bambu ships its own cloud / standard presets under.
  153. // "SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle" — the full printer-preset
  154. // name, the form the slicer writes when a user saves their own preset
  155. // for a printer. Handling only the short form left these classified
  156. // 'unknown', so an H2D-scoped filament was offered (and auto-picked)
  157. // for an A1 slice, which the CLI then rejected.
  158. // "Overture PLA Matte @0.2" — nozzle only, no model.
  159. // Returned with a null token: the size can rule a printer OUT, but
  160. // says nothing about which models the profile belongs to.
  161. //
  162. // The first two shapes are also parsed in ConfigureAmsSlotModal (#1623).
  163. function extractPrinterTag(presetName: string): { token: string | null; nozzle: string | null } | null {
  164. const cleaned = stripTrailingParenthetical(presetName);
  165. const bbl = extractBblToken(cleaned);
  166. if (bbl) return bbl;
  167. // The printer tag is a suffix by convention, so read from the LAST '@' —
  168. // a stray earlier one ("My @work PLA @Bambu Lab H2D 0.4 nozzle") must not
  169. // swallow it. Anything that doesn't parse as a Bambu printer preset name
  170. // falls through to 'unknown', never to a guessed mismatch.
  171. const at = cleaned.lastIndexOf('@');
  172. if (at < 0) return null;
  173. const suffix = cleaned.slice(at + 1).trim();
  174. const longForm = extractPrinterPresetModel(suffix);
  175. if (longForm) return { token: longForm.model, nozzle: longForm.nozzle };
  176. const nozzleOnly = suffix.match(/^([\d.]+)\s*(?:mm)?\s*(?:nozzle)?$/i);
  177. if (nozzleOnly) {
  178. const size = Number.parseFloat(nozzleOnly[1]);
  179. if (!Number.isNaN(size) && size >= MIN_NOZZLE_MM && size <= MAX_NOZZLE_MM) {
  180. return { token: null, nozzle: nozzleOnly[1] };
  181. }
  182. }
  183. return null;
  184. }
  185. /**
  186. * Name-based fallback for presets carrying a printer tag — BambuStudio's own
  187. * `@BBL <model>` (#1325 follow-up), the full `@Bambu Lab <model> <size>
  188. * nozzle` form user-saved presets get, or a bare `@<size>` (#2628).
  189. * Used only after `compatible_printers` has returned `'unknown'`.
  190. *
  191. * Compares BOTH model AND nozzle. The nozzle filter is required because
  192. * Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 /
  193. * 0.8) — a 0.6-nozzle process is unusable on a 0.4-nozzle printer.
  194. * 0.4 is Bambu's default and its variants drop the nozzle suffix, so a
  195. * preset with no suffix counts as 0.4.
  196. */
  197. function classifyByBambuName(
  198. presetName: string,
  199. selectedPrinterName: string,
  200. bambuModelByShortCode: Record<string, string>,
  201. ): PrinterCompatibility {
  202. const parsed = extractPrinterTag(presetName);
  203. if (!parsed) return 'unknown';
  204. const selectedParts = extractPrinterPresetModel(selectedPrinterName);
  205. if (!selectedParts) return 'unknown';
  206. if (parsed.token === null) {
  207. // Nozzle-only tag ("Overture PLA Matte @0.2"). The size can rule a
  208. // printer OUT, but a matching size proves nothing about the model, so
  209. // the best this can ever return is 'unknown' — never 'match'.
  210. if (
  211. selectedParts.nozzle !== null
  212. && parsed.nozzle !== null
  213. && !sameNozzle(parsed.nozzle, selectedParts.nozzle)
  214. ) {
  215. return 'mismatch';
  216. }
  217. return 'unknown';
  218. }
  219. // If the token isn't in the table (a brand-new Bambu model whose short
  220. // code the backend registry hasn't added yet, or the model map hasn't
  221. // loaded yet), fall back to comparing the raw token. That keeps the
  222. // matcher working when token and printer-name fragment happen to be
  223. // identical — e.g. "Q1" preset against "Bambu Lab Q1 0.4 nozzle" —
  224. // without us having to ship a code update. When they differ in form
  225. // (X1C vs "X1 Carbon"), the registry is what makes the match work.
  226. const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token;
  227. // The raw inferred model and the printer-preset fragment may differ only by
  228. // the Bambu short-code rename (e.g. preset token "A1M" vs printer "A1 Mini").
  229. // ``matchesPrinterModelSuffix`` consults the alias table before declaring a
  230. // mismatch — see #1649.
  231. if (
  232. normalizeModelFragment(selectedParts.model) !== normalizeModelFragment(inferredModel)
  233. && !matchesPrinterModelSuffix(parsed.token, selectedParts.model)
  234. ) {
  235. return 'mismatch';
  236. }
  237. // Nozzle compare — only when we have a usable size from the printer
  238. // side. A Bambu printer preset always carries one, so this branch is
  239. // taken in practice; the null path is defensive degrade for hand-typed
  240. // or non-Bambu printer names that happened to match the model.
  241. if (selectedParts.nozzle !== null) {
  242. const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE;
  243. if (!sameNozzle(presetNozzle, selectedParts.nozzle)) return 'mismatch';
  244. }
  245. return 'match';
  246. }
  247. /**
  248. * Classify a process / filament preset against the selected printer.
  249. *
  250. * - 'match' — the preset is compatible with the selected printer.
  251. * - 'mismatch' — the preset resolves to a *different* printer.
  252. * - 'unknown' — compatibility can't be determined (no `compatible_printers`,
  253. * no recognizable `@BBL` tag, or no printer is selected);
  254. * the caller must not hide it.
  255. */
  256. export function presetCompatibility(
  257. preset: { name: string; compatible_printers?: string[] | null },
  258. _slot: 'process' | 'filament',
  259. selectedPrinterName: string | null,
  260. index: PrinterCompatibilityIndex,
  261. ): PrinterCompatibility {
  262. if (!selectedPrinterName) return 'unknown';
  263. // (1) Imported presets carry the slicer's own compatible_printers list —
  264. // authoritative when set.
  265. const compat = preset.compatible_printers;
  266. if (compat && compat.length > 0) {
  267. return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
  268. }
  269. // (2) BambuStudio's `@BBL <model>` name convention — covers cloud /
  270. // standard presets that don't carry compatible_printers.
  271. return classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
  272. }