slicerPrinterMatch.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  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. Uploaded Slicer Bundles (.bbscfg). A bundle is scoped to one printer
  10. // and lists the process / filament presets shipped with it, so a preset
  11. // a bundle covers is compatible with exactly that bundle's printer. A
  12. // newly released Bambu model is covered the moment its bundle is
  13. // uploaded — no code change required.
  14. // 3. BambuStudio's own `@BBL <model>` naming convention on shipped cloud
  15. // / standard presets. This used to be the only signal, was removed in
  16. // the first cut of #1325 in favour of (2) — which works for the author
  17. // and anyone who uploaded their bundles, but silently no-ops for users
  18. // who hadn't (the reporter's case). Restored as a fallback below the
  19. // bundle path so the table is only consulted when bundles can't decide.
  20. // The token → printer-fragment table is derived from the backend's
  21. // canonical PRINTER_MODEL_MAP (fetched via /slicer/printer-models),
  22. // not duplicated here.
  23. //
  24. // The result drives grouping, not hard hiding: a preset no rule covers
  25. // stays in the main list, and only a preset that resolves to a *different*
  26. // printer is pushed into an "Other printers" group.
  27. export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
  28. // Minimal shape of a Slicer Bundle needed for matching (see SlicerBundle in
  29. // api/client.ts). `printer_preset_name` scopes the bundle to one printer;
  30. // `process` / `filament` are the preset names that bundle ships.
  31. export interface CompatibilityBundle {
  32. printer_preset_name: string;
  33. process: string[];
  34. filament: string[];
  35. }
  36. // Lookup tables consumed by `presetCompatibility`. `process` / `filament` are
  37. // preset-name → set-of-compatible-printer-names built from uploaded bundles.
  38. // `bambuModelByShortCode` is the @BBL token → printer-preset fragment map
  39. // derived from the backend's PRINTER_MODEL_MAP — e.g. `X1C` → `X1 Carbon`.
  40. // All three are empty by default; an empty `bambuModelByShortCode` means the
  41. // @BBL fallback still works when token and printer-name fragment match
  42. // directly (raw-token comparison), and gracefully degrades otherwise.
  43. export interface PrinterCompatibilityIndex {
  44. process: Map<string, Set<string>>;
  45. filament: Map<string, Set<string>>;
  46. bambuModelByShortCode: Record<string, string>;
  47. }
  48. /** An empty index — used when no bundles / models are loaded yet. */
  49. export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
  50. process: new Map(),
  51. filament: new Map(),
  52. bambuModelByShortCode: {},
  53. };
  54. // Bundle preset names occasionally carry BambuStudio's "# " user-clone
  55. // prefix; strip it so a bundle entry and a tier-listed preset compare equal.
  56. function normalizePresetName(name: string): string {
  57. return name.replace(/^#\s*/, '').trim();
  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 user's uploaded Slicer Bundles and
  83. * the backend printer-model registry. Each bundle contributes its printer
  84. * to every process / filament name it ships; a name shipped by several
  85. * bundles accumulates every printer.
  86. */
  87. export function buildCompatibilityIndex(
  88. bundles: readonly CompatibilityBundle[],
  89. printerModels: Record<string, string> = {},
  90. ): PrinterCompatibilityIndex {
  91. const process = new Map<string, Set<string>>();
  92. const filament = new Map<string, Set<string>>();
  93. const add = (map: Map<string, Set<string>>, name: string, printer: string) => {
  94. const key = normalizePresetName(name);
  95. if (!key) return;
  96. const set = map.get(key) ?? new Set<string>();
  97. set.add(printer);
  98. map.set(key, set);
  99. };
  100. for (const bundle of bundles) {
  101. const printer = bundle.printer_preset_name?.trim();
  102. if (!printer) continue;
  103. for (const name of bundle.process) add(process, name, printer);
  104. for (const name of bundle.filament) add(filament, name, printer);
  105. }
  106. return {
  107. process,
  108. filament,
  109. bambuModelByShortCode: buildShortCodeMap(printerModels),
  110. };
  111. }
  112. function normalizeModelFragment(s: string): string {
  113. return s.replace(/\s+/g, '').toLowerCase();
  114. }
  115. // Bambu Studio's naming convention for bundled presets: the 0.4 nozzle is
  116. // the default and its variants drop the nozzle suffix; 0.2 / 0.6 / 0.8
  117. // carry an explicit "<size> nozzle" segment. So a process with no suffix
  118. // is implicitly a 0.4 process — required to compare correctly against a
  119. // 0.4 printer preset, which DOES carry the suffix.
  120. const DEFAULT_NOZZLE = '0.4';
  121. // Strip a trailing "<size> nozzle" segment, returning the nozzle string
  122. // (e.g. "0.6") or null when absent. Used by both BBL-token and printer-
  123. // preset extractors so the suffix is parsed identically on both sides.
  124. function takeNozzleSuffix(s: string): { stripped: string; nozzle: string | null } {
  125. const m = s.match(/^(.*?)\s+([\d.]+)\s*nozzle\s*$/i);
  126. if (!m) return { stripped: s.trim(), nozzle: null };
  127. return { stripped: m[1].trim(), nozzle: m[2] };
  128. }
  129. // Pull the model token and nozzle out of a "@BBL <token> [<size> nozzle]"
  130. // suffix. The token may contain a space (e.g. "A1 mini"), so we strip a
  131. // trailing nozzle segment rather than splitting on the first whitespace.
  132. function extractBblToken(presetName: string): { token: string; nozzle: string | null } | null {
  133. const marker = '@BBL ';
  134. const idx = presetName.indexOf(marker);
  135. if (idx < 0) return null;
  136. const rest = presetName.slice(idx + marker.length).trim();
  137. const { stripped, nozzle } = takeNozzleSuffix(rest);
  138. return stripped ? { token: stripped, nozzle } : null;
  139. }
  140. // Pull the model fragment and nozzle out of a "Bambu Lab <model> [<size>
  141. // nozzle]" printer preset name. Returns null for non-Bambu printer
  142. // presets — there is no reliable name-based match against those.
  143. function extractPrinterPresetModel(printerPresetName: string): { model: string; nozzle: string | null } | null {
  144. const m = printerPresetName.match(/^Bambu Lab\s+(.+)$/i);
  145. if (!m) return null;
  146. const { stripped, nozzle } = takeNozzleSuffix(m[1]);
  147. return stripped ? { model: stripped, nozzle } : null;
  148. }
  149. /**
  150. * Name-based fallback for presets BambuStudio ships with a `@BBL <model>`
  151. * tag (#1325 follow-up). Used only after `compatible_printers` and the
  152. * uploaded-bundle index have already returned `'unknown'`.
  153. *
  154. * Compares BOTH model AND nozzle. The nozzle filter is required because
  155. * Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 /
  156. * 0.8) — a 0.6-nozzle process is unusable on a 0.4-nozzle printer.
  157. * 0.4 is Bambu's default and its variants drop the nozzle suffix, so a
  158. * preset with no suffix counts as 0.4.
  159. */
  160. function classifyByBambuName(
  161. presetName: string,
  162. selectedPrinterName: string,
  163. bambuModelByShortCode: Record<string, string>,
  164. ): PrinterCompatibility {
  165. const parsed = extractBblToken(presetName);
  166. if (!parsed) return 'unknown';
  167. // If the token isn't in the table (a brand-new Bambu model whose short
  168. // code the backend registry hasn't added yet, or the model map hasn't
  169. // loaded yet), fall back to comparing the raw token. That keeps the
  170. // matcher working when token and printer-name fragment happen to be
  171. // identical — e.g. "Q1" preset against "Bambu Lab Q1 0.4 nozzle" —
  172. // without us having to ship a code update. When they differ in form
  173. // (X1C vs "X1 Carbon"), the registry is what makes the match work.
  174. const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token;
  175. const selectedParts = extractPrinterPresetModel(selectedPrinterName);
  176. if (!selectedParts) return 'unknown';
  177. if (normalizeModelFragment(selectedParts.model) !== normalizeModelFragment(inferredModel)) {
  178. return 'mismatch';
  179. }
  180. // Nozzle compare — only when we have a usable size from the printer
  181. // side. A Bambu printer preset always carries one, so this branch is
  182. // taken in practice; the null path is defensive degrade for hand-typed
  183. // or non-Bambu printer names that happened to match the model.
  184. if (selectedParts.nozzle !== null) {
  185. const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE;
  186. if (presetNozzle !== selectedParts.nozzle) return 'mismatch';
  187. }
  188. return 'match';
  189. }
  190. /**
  191. * Classify a process / filament preset against the selected printer.
  192. *
  193. * - 'match' — the preset is compatible with the selected printer.
  194. * - 'mismatch' — the preset resolves to a *different* printer.
  195. * - 'unknown' — compatibility can't be determined (no `compatible_printers`,
  196. * no uploaded bundle, no recognizable `@BBL` tag, or no
  197. * printer is selected); the caller must not hide it.
  198. */
  199. export function presetCompatibility(
  200. preset: { name: string; compatible_printers?: string[] | null },
  201. slot: 'process' | 'filament',
  202. selectedPrinterName: string | null,
  203. index: PrinterCompatibilityIndex,
  204. ): PrinterCompatibility {
  205. if (!selectedPrinterName) return 'unknown';
  206. // (1) Imported presets carry the slicer's own compatible_printers list —
  207. // authoritative when set.
  208. const compat = preset.compatible_printers;
  209. if (compat && compat.length > 0) {
  210. return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
  211. }
  212. // (2) Consult the uploaded Slicer Bundles.
  213. const printers = index[slot].get(normalizePresetName(preset.name));
  214. if (printers && printers.size > 0) {
  215. return printers.has(selectedPrinterName) ? 'match' : 'mismatch';
  216. }
  217. // (3) BambuStudio's `@BBL <model>` name convention — covers cloud /
  218. // standard presets for users who haven't uploaded bundles for every
  219. // printer their cloud catalogue includes.
  220. return classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
  221. }