slicerPrinterMatch.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  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. // Only a definite 'mismatch' is acted on: the dropdown holds those back
  17. // behind a "Show all" link. A preset no rule covers classifies as 'unknown'
  18. // and always stays in the list — absence of evidence is not evidence of
  19. // incompatibility, and hiding an untagged preset would make a user's own
  20. // imported profiles disappear. That asymmetry is why every parse failure
  21. // below returns 'unknown' rather than guessing a mismatch.
  22. export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
  23. // Lookup tables consumed by `presetCompatibility`. `bambuModelByShortCode`
  24. // is the @BBL token → printer-preset fragment map derived from the backend's
  25. // PRINTER_MODEL_MAP — e.g. `X1C` → `X1 Carbon`. An empty map means the @BBL
  26. // fallback still works when token and printer-name fragment match directly
  27. // (raw-token comparison), and gracefully degrades otherwise.
  28. export interface PrinterCompatibilityIndex {
  29. bambuModelByShortCode: Record<string, string>;
  30. // Printer preset name → the preset it was saved from (#3250). A printer
  31. // preset saved under a name of its own ("Bambu Lab H2D 0.4 nozzle - Skew")
  32. // is in no process's compatible_printers and its name may not parse as a
  33. // model; its parent is what the slicer checks against.
  34. printerParents?: Record<string, string>;
  35. }
  36. /** An empty index — used when the model map hasn't loaded yet. */
  37. export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
  38. bambuModelByShortCode: {},
  39. };
  40. // Bambu cloud started shipping terse model codes in `@BBL <code>` suffixes
  41. // mid-2026 — the most visible one is "A1 Mini" → "A1M" (#1649, reported by
  42. // @technopaw). User-authored profiles still use the long display name, so
  43. // both shapes have to match the same printer. The table is uppercase-normalised
  44. // for case-insensitive lookups; add a row when a future rename is spotted via
  45. // `/api/v1/cloud/settings`. Keep narrow on purpose — wide-net aliasing
  46. // (e.g. "X1" ⇄ "X1C") would silently group truly distinct printers.
  47. const PRINTER_MODEL_SUFFIX_ALIASES: Record<string, readonly string[]> = {
  48. 'A1 MINI': ['A1M'],
  49. // Same shape, spotted while tracing #2982: the bundle names every H2D Pro
  50. // process and filament preset "@BBL H2DP" while the printer preset — and
  51. // PRINTER_MODEL_MAP with it — spells the model "H2D Pro". Without the alias
  52. // the H2D Pro classified all 198 bundled processes as belonging to another
  53. // printer.
  54. 'H2D PRO': ['H2DP'],
  55. };
  56. /**
  57. * True when ``presetSuffix`` (the token extracted from a "@BBL <code>" or
  58. * preset-name suffix) refers to the same printer as ``printerModel``
  59. * (the display name selected in the picker). Case-insensitive; consults
  60. * the alias table for short codes Bambu introduced after the long forms
  61. * shipped (#1649).
  62. */
  63. export function matchesPrinterModelSuffix(presetSuffix: string, printerModel: string): boolean {
  64. const p = presetSuffix.toUpperCase();
  65. const m = printerModel.toUpperCase();
  66. if (p === m) return true;
  67. const aliasesOfM = PRINTER_MODEL_SUFFIX_ALIASES[m];
  68. if (aliasesOfM && aliasesOfM.includes(p)) return true;
  69. const aliasesOfP = PRINTER_MODEL_SUFFIX_ALIASES[p];
  70. if (aliasesOfP && aliasesOfP.includes(m)) return true;
  71. return false;
  72. }
  73. /**
  74. * Invert the backend's PRINTER_MODEL_MAP into the shape the @BBL fallback
  75. * needs: short code → printer-preset fragment (the part of "Bambu Lab X1
  76. * Carbon" the user sees in a printer preset name, minus the "Bambu Lab "
  77. * brand prefix).
  78. *
  79. * Backend ships e.g. `{"Bambu Lab X1 Carbon": "X1C", "Bambu Lab A1 mini":
  80. * "A1 Mini", "Bambu Lab A1 Mini": "A1 Mini"}` — multiple long forms can map
  81. * to the same short. We pick the first long-form encountered for each short
  82. * code; case normalisation happens at match time so "A1 mini" vs "A1 Mini"
  83. * never matters.
  84. */
  85. function buildShortCodeMap(
  86. printerModels: Record<string, string>,
  87. ): Record<string, string> {
  88. const out: Record<string, string> = {};
  89. for (const [longName, shortCode] of Object.entries(printerModels)) {
  90. if (shortCode in out) continue;
  91. out[shortCode] = longName.replace(/^Bambu Lab\s+/, '');
  92. }
  93. return out;
  94. }
  95. /**
  96. * Build the compatibility index from the backend printer-model registry.
  97. */
  98. export function buildCompatibilityIndex(
  99. printerModels: Record<string, string> = {},
  100. printerParents: Record<string, string> = {},
  101. ): PrinterCompatibilityIndex {
  102. return {
  103. bambuModelByShortCode: buildShortCodeMap(printerModels),
  104. printerParents,
  105. };
  106. }
  107. function normalizeModelFragment(s: string): string {
  108. return s.replace(/\s+/g, '').toLowerCase();
  109. }
  110. /**
  111. * Drop BambuStudio's ``"# "`` user-clone prefix.
  112. *
  113. * Editing a system preset saves a copy named ``"# Bambu Lab X1 Carbon 0.4
  114. * nozzle"``, and `.bbscfg` bundle exports use the same convention. Two places
  115. * need it off:
  116. *
  117. * 1. ``extractPrinterPresetModel`` — the prefix fails its "Bambu Lab …"
  118. * test, so a cloned *printer* made every preset classify as 'unknown'
  119. * and the dropdown filter silently did nothing.
  120. * 2. The ``compatible_printers`` comparison, where a prefix on one side
  121. * alone reads as a mismatch against the very printer the preset was
  122. * cloned from — and a mismatch now hides the preset.
  123. *
  124. * The ``@`` tag extractors need no such handling: they scan for "@BBL " or
  125. * the last "@", both of which skip a leading prefix already.
  126. *
  127. * The backend normalises the same prefix in ``_canonical_printer_model``.
  128. */
  129. function stripUserClonePrefix(name: string): string {
  130. return name.replace(/^#\s*/, '').trim();
  131. }
  132. // Bambu Studio's naming convention for bundled presets: the 0.4 nozzle is
  133. // the default and its variants drop the nozzle suffix; 0.2 / 0.6 / 0.8
  134. // carry an explicit "<size> nozzle" segment. So a process with no suffix
  135. // is implicitly a 0.4 process — required to compare correctly against a
  136. // 0.4 printer preset, which DOES carry the suffix.
  137. const DEFAULT_NOZZLE = '0.4';
  138. // Strip a trailing "<size> nozzle" segment, returning the nozzle string
  139. // (e.g. "0.6") or null when absent. Used by both BBL-token and printer-
  140. // preset extractors so the suffix is parsed identically on both sides.
  141. function takeNozzleSuffix(s: string): { stripped: string; nozzle: string | null } {
  142. const m = s.match(/^(.*?)\s+([\d.]+)\s*nozzle\s*$/i);
  143. if (!m) return { stripped: s.trim(), nozzle: null };
  144. return { stripped: m[1].trim(), nozzle: m[2] };
  145. }
  146. // Pull the model token and nozzle out of a "@BBL <token> [<size> nozzle]"
  147. // suffix. The token may contain a space (e.g. "A1 mini"), so we strip a
  148. // trailing nozzle segment rather than splitting on the first whitespace.
  149. function extractBblToken(presetName: string): { token: string; nozzle: string | null } | null {
  150. const marker = '@BBL ';
  151. const idx = presetName.indexOf(marker);
  152. if (idx < 0) return null;
  153. const rest = presetName.slice(idx + marker.length).trim();
  154. const { stripped, nozzle } = takeNozzleSuffix(rest);
  155. return stripped ? { token: stripped, nozzle } : null;
  156. }
  157. // Pull the model fragment and nozzle out of a "Bambu Lab <model> [<size>
  158. // nozzle]" printer preset name. Returns null for non-Bambu printer
  159. // presets — there is no reliable name-based match against those.
  160. function extractPrinterPresetModel(printerPresetName: string): { model: string; nozzle: string | null } | null {
  161. const m = stripUserClonePrefix(printerPresetName).match(/^Bambu Lab\s+(.+)$/i);
  162. if (!m) return null;
  163. const { stripped, nozzle } = takeNozzleSuffix(m[1]);
  164. return stripped ? { model: stripped, nozzle } : null;
  165. }
  166. // Trailing parenthetical the slicer appends to user-saved presets —
  167. // "… @Bambu Lab H2D 0.4 nozzle (Custom)". Dropped before the nozzle suffix
  168. // is parsed, or the tag would resolve to a nonsense model token and the
  169. // preset would be branded a mismatch against its OWN printer.
  170. function stripTrailingParenthetical(s: string): string {
  171. return s.replace(/\s*\([^)]*\)\s*$/, '').trim();
  172. }
  173. // Nozzle sizes Bambu ships run 0.2 – 0.8. The range guard keeps a tag that
  174. // merely looks numeric ("PLA @2026") from being read as a nozzle and branded
  175. // incompatible with every printer.
  176. const MIN_NOZZLE_MM = 0.1;
  177. const MAX_NOZZLE_MM = 2.0;
  178. // Compare two nozzle strings numerically, so "0.20" and "0.2" are the same
  179. // size. Unparseable values never match — a size we can't read is not evidence.
  180. function sameNozzle(a: string, b: string): boolean {
  181. const x = Number.parseFloat(a);
  182. const y = Number.parseFloat(b);
  183. if (Number.isNaN(x) || Number.isNaN(y)) return false;
  184. return x === y;
  185. }
  186. // Pull the model token and nozzle out of a preset name's printer tag.
  187. // Three shapes exist in the wild (#2628):
  188. //
  189. // "0.20mm Standard @BBL X1C" — short code, the form
  190. // Bambu ships its own cloud / standard presets under.
  191. // "SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle" — the full printer-preset
  192. // name, the form the slicer writes when a user saves their own preset
  193. // for a printer. Handling only the short form left these classified
  194. // 'unknown', so an H2D-scoped filament was offered (and auto-picked)
  195. // for an A1 slice, which the CLI then rejected.
  196. // "Overture PLA Matte @0.2" — nozzle only, no model.
  197. // Returned with a null token: the size can rule a printer OUT, but
  198. // says nothing about which models the profile belongs to.
  199. //
  200. // The first two shapes are also parsed in ConfigureAmsSlotModal (#1623).
  201. function extractPrinterTag(presetName: string): { token: string | null; nozzle: string | null } | null {
  202. const cleaned = stripTrailingParenthetical(presetName);
  203. const bbl = extractBblToken(cleaned);
  204. if (bbl) return bbl;
  205. // The printer tag is a suffix by convention, so read from the LAST '@' —
  206. // a stray earlier one ("My @work PLA @Bambu Lab H2D 0.4 nozzle") must not
  207. // swallow it. Anything that doesn't parse as a Bambu printer preset name
  208. // falls through to 'unknown', never to a guessed mismatch.
  209. const at = cleaned.lastIndexOf('@');
  210. if (at < 0) return null;
  211. const suffix = cleaned.slice(at + 1).trim();
  212. const longForm = extractPrinterPresetModel(suffix);
  213. if (longForm) return { token: longForm.model, nozzle: longForm.nozzle };
  214. const nozzleOnly = suffix.match(/^([\d.]+)\s*(?:mm)?\s*(?:nozzle)?$/i);
  215. if (nozzleOnly) {
  216. const size = Number.parseFloat(nozzleOnly[1]);
  217. if (!Number.isNaN(size) && size >= MIN_NOZZLE_MM && size <= MAX_NOZZLE_MM) {
  218. return { token: null, nozzle: nozzleOnly[1] };
  219. }
  220. }
  221. return null;
  222. }
  223. /**
  224. * Name-based fallback for presets carrying a printer tag — BambuStudio's own
  225. * `@BBL <model>` (#1325 follow-up), the full `@Bambu Lab <model> <size>
  226. * nozzle` form user-saved presets get, or a bare `@<size>` (#2628).
  227. * Used only after `compatible_printers` has returned `'unknown'`.
  228. *
  229. * Compares BOTH model AND nozzle. The nozzle filter is required because
  230. * Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 /
  231. * 0.8) — a 0.6-nozzle process is unusable on a 0.4-nozzle printer.
  232. * 0.4 is Bambu's default and its variants drop the nozzle suffix, so a
  233. * preset with no suffix counts as 0.4.
  234. */
  235. function classifyByBambuName(
  236. presetName: string,
  237. selectedPrinterName: string,
  238. bambuModelByShortCode: Record<string, string>,
  239. ): PrinterCompatibility {
  240. const parsed = extractPrinterTag(presetName);
  241. if (!parsed) return 'unknown';
  242. const selectedParts = extractPrinterPresetModel(selectedPrinterName);
  243. if (!selectedParts) return 'unknown';
  244. if (parsed.token === null) {
  245. // Nozzle-only tag ("Overture PLA Matte @0.2"). The size can rule a
  246. // printer OUT, but a matching size proves nothing about the model, so
  247. // the best this can ever return is 'unknown' — never 'match'.
  248. if (
  249. selectedParts.nozzle !== null
  250. && parsed.nozzle !== null
  251. && !sameNozzle(parsed.nozzle, selectedParts.nozzle)
  252. ) {
  253. return 'mismatch';
  254. }
  255. return 'unknown';
  256. }
  257. // If the token isn't in the table (a brand-new Bambu model whose short
  258. // code the backend registry hasn't added yet, or the model map hasn't
  259. // loaded yet), fall back to comparing the raw token. That keeps the
  260. // matcher working when token and printer-name fragment happen to be
  261. // identical — e.g. "Q1" preset against "Bambu Lab Q1 0.4 nozzle" —
  262. // without us having to ship a code update. When they differ in form
  263. // (X1C vs "X1 Carbon"), the registry is what makes the match work.
  264. const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token;
  265. // The raw inferred model and the printer-preset fragment may differ only by
  266. // the Bambu short-code rename (e.g. preset token "A1M" vs printer "A1 Mini").
  267. // ``matchesPrinterModelSuffix`` consults the alias table before declaring a
  268. // mismatch — see #1649.
  269. if (
  270. normalizeModelFragment(selectedParts.model) !== normalizeModelFragment(inferredModel)
  271. && !matchesPrinterModelSuffix(parsed.token, selectedParts.model)
  272. ) {
  273. return 'mismatch';
  274. }
  275. // Nozzle compare — only when we have a usable size from the printer
  276. // side. A Bambu printer preset always carries one, so this branch is
  277. // taken in practice; the null path is defensive degrade for hand-typed
  278. // or non-Bambu printer names that happened to match the model.
  279. if (selectedParts.nozzle !== null) {
  280. const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE;
  281. if (!sameNozzle(presetNozzle, selectedParts.nozzle)) return 'mismatch';
  282. }
  283. return 'match';
  284. }
  285. /**
  286. * Classify a process / filament preset against the selected printer.
  287. *
  288. * - 'match' — the preset is compatible with the selected printer.
  289. * - 'mismatch' — the preset resolves to a *different* printer.
  290. * - 'unknown' — compatibility can't be determined (no `compatible_printers`,
  291. * no recognizable `@BBL` tag, or no printer is selected);
  292. * the caller must not hide it.
  293. */
  294. export function presetCompatibility(
  295. preset: { name: string; compatible_printers?: string[] | null },
  296. _slot: 'process' | 'filament',
  297. selectedPrinterName: string | null,
  298. index: PrinterCompatibilityIndex,
  299. ): PrinterCompatibility {
  300. if (!selectedPrinterName) return 'unknown';
  301. // The preset the selected printer was saved from, when it has another name
  302. // (#3250). The slicer checks processes against that parent, so either name
  303. // counts as the selected printer.
  304. const parents = index.printerParents;
  305. const parent = parents && Object.hasOwn(parents, selectedPrinterName) ? parents[selectedPrinterName] : null;
  306. const parentName = typeof parent === 'string' && parent && parent !== selectedPrinterName ? parent : null;
  307. // (1) Imported presets carry the slicer's own compatible_printers list —
  308. // authoritative when set.
  309. const compat = preset.compatible_printers;
  310. if (compat && compat.length > 0) {
  311. // Compared with the clone prefix off both sides: a preset cloned from a
  312. // system printer lists the *unprefixed* name, and comparing that raw
  313. // against a selected "# Bambu Lab …" reads as a mismatch — which now
  314. // hides the preset rather than merely demoting it.
  315. const names = [selectedPrinterName, parentName].filter((n): n is string => !!n).map(stripUserClonePrefix);
  316. return compat.some((name) => names.includes(stripUserClonePrefix(name))) ? 'match' : 'mismatch';
  317. }
  318. // (2) BambuStudio's `@BBL <model>` name convention — covers cloud /
  319. // standard presets that don't carry compatible_printers. A name of the
  320. // user's own may not parse as a model at all, so the parent decides
  321. // whenever it can.
  322. const own = classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
  323. if (own === 'match' || !parentName) return own;
  324. const inherited = classifyByBambuName(preset.name, parentName, index.bambuModelByShortCode);
  325. return inherited === 'unknown' ? own : inherited;
  326. }
  327. // model token compiles to a flexible-whitespace word-boundary regex.
  328. function _tokenToRegex(token: string): RegExp {
  329. const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
  330. return new RegExp(`\\b${escaped}\\b`, 'i');
  331. }
  332. // Extract printer model from a preset name → normalized short code
  333. // (e.g. "X1C", "H2D"). Two strategies in order:
  334. //
  335. // (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
  336. // - "@BBL X1C 0.4 nozzle" → "X1C" (short-code form,
  337. // Bambu Cloud system presets)
  338. // - "@Bambu Lab X1 Carbon 0.4 nozzle" → "X1C" (long-form, used by
  339. // user-renamed Bambu Cloud presets and most Orca Cloud profiles —
  340. // reverse-looked-up via the backend printer-model registry)
  341. //
  342. // (2) Body scan — many user-authored / Orca Cloud presets put the printer
  343. // model at the START of the name with no @ suffix at all (the literal
  344. // shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
  345. // name for any known model token (every long-name fragment + every short
  346. // code from the registry) and return the first match. Long-first sort
  347. // keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
  348. // shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
  349. // false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
  350. // "X1Box" doesn't match "X1").
  351. //
  352. // Returns null when neither strategy resolves; the caller keeps such
  353. // presets visible (can't filter what we can't classify).
  354. //
  355. // ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
  356. // keys are "Bambu Lab <long>", values are short codes.
  357. export function extractPresetModel(
  358. name: string,
  359. printerModelsLongToShort: Record<string, string>,
  360. ): string | null {
  361. const atIdx = name.indexOf('@');
  362. if (atIdx >= 0) {
  363. const suffix = name.slice(atIdx + 1).trim();
  364. const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
  365. if (bblMatch) return bblMatch[1].trim();
  366. const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
  367. if (longMatch) {
  368. const longFragment = longMatch[1].trim();
  369. const fullKey = `Bambu Lab ${longFragment}`;
  370. if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
  371. const lower = fullKey.toLowerCase();
  372. for (const [k, v] of Object.entries(printerModelsLongToShort)) {
  373. if (k.toLowerCase() === lower) return v;
  374. }
  375. return longFragment;
  376. }
  377. }
  378. // Body scan — accumulate {token, short} pairs and try long-first.
  379. const tokens: Array<{ token: string; short: string }> = [];
  380. const seen = new Set<string>();
  381. for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
  382. const fragment = longName.replace(/^Bambu Lab\s+/, '');
  383. const key = fragment.toLowerCase();
  384. if (!seen.has(key)) {
  385. tokens.push({ token: fragment, short });
  386. seen.add(key);
  387. }
  388. const shortKey = short.toLowerCase();
  389. if (!seen.has(shortKey)) {
  390. tokens.push({ token: short, short });
  391. seen.add(shortKey);
  392. }
  393. }
  394. tokens.sort((a, b) => b.token.length - a.token.length);
  395. for (const { token, short } of tokens) {
  396. if (_tokenToRegex(token).test(name)) return short;
  397. }
  398. return null;
  399. }