Browse Source

fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325)

  The process dropdown still mixed @BBL P2S presets into an X1C list:
  slicerPrinterMatch matched cloud/standard presets by parsing the
  @BBL <model> name suffix against a hardcoded model-code allow-list
  that was missing P2S, H2C and X2D — so those presets resolved to
  "unknown" and stayed in the main list instead of "Other printers".

  Drop both hardcoded model tables. Compatibility now comes from the
  user's uploaded Slicer Bundles: a bundle is scoped to one printer and
  lists the presets it ships, so a preset matches a printer exactly when
  some bundle for that printer contains it. New models are covered the
  moment their bundle is uploaded.
maziggy 6 days ago
parent
commit
e0247fc6a6

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 2 - 2
backend/app/api/routes/slicer_presets.py

@@ -179,7 +179,7 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
             # Precise compatibility link — the slicer's own compatible_printers
             # list, captured at import time. Lets the SliceModal filter the
             # process / filament dropdowns by the selected printer without
-            # falling back to its name-suffix heuristic.
+            # falling back to the uploaded-bundle index.
             preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
         slots[slot].append(preset)
     return slots
@@ -188,7 +188,7 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
     """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
     names. Return the parsed list, or ``None`` on missing / malformed data so
-    the SliceModal falls back to its name-suffix heuristic for that preset."""
+    the SliceModal falls back to the uploaded-bundle index for that preset."""
     if not raw:
         return None
     try:

+ 3 - 2
backend/app/schemas/slicer_presets.py

@@ -37,8 +37,9 @@ class UnifiedPreset(BaseModel):
     local tier (stored at import time); left ``None`` for cloud (no per-preset
     detail is fetched — rate limits) and standard (the sidecar's bundled
     listing doesn't expose it). The SliceModal uses it to filter the
-    process / filament dropdowns by the selected printer (#1325), falling back
-    to a name-suffix heuristic when it is ``None``.
+    process / filament dropdowns by the selected printer (#1325); when it is
+    ``None`` the modal falls back to the user's uploaded Slicer Bundles, which
+    map each printer to the presets it ships.
     """
 
     id: str

+ 83 - 59
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -1,96 +1,120 @@
 import { describe, it, expect } from 'vitest';
 import {
-  printerPresetCode,
-  presetModelCodes,
+  buildCompatibilityIndex,
   presetCompatibility,
+  EMPTY_COMPATIBILITY_INDEX,
+  type CompatibilityBundle,
 } from '../../utils/slicerPrinterMatch';
 
-describe('printerPresetCode', () => {
-  it('maps Bambu stock printer names to model codes', () => {
-    expect(printerPresetCode('Bambu Lab X1 Carbon 0.4 nozzle')).toBe('X1C');
-    expect(printerPresetCode('Bambu Lab X1E 0.4 nozzle')).toBe('X1E');
-    expect(printerPresetCode('Bambu Lab X1 0.4 nozzle')).toBe('X1');
-    expect(printerPresetCode('Bambu Lab P1S 0.4 nozzle')).toBe('P1S');
-    expect(printerPresetCode('Bambu Lab P1P 0.4 nozzle')).toBe('P1P');
-    expect(printerPresetCode('Bambu Lab A1 mini 0.4 nozzle')).toBe('A1M');
-    expect(printerPresetCode('Bambu Lab A1 0.4 nozzle')).toBe('A1');
-    expect(printerPresetCode('Bambu Lab H2D 0.4 nozzle')).toBe('H2D');
-  });
+const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle';
+const P2S = 'Bambu Lab P2S 0.4 nozzle';
 
-  it('prefers the more specific name when prefixes overlap', () => {
-    // "X1 Carbon" must not be read as bare "X1"; "A1 mini" not as "A1".
-    expect(printerPresetCode('X1 Carbon')).toBe('X1C');
-    expect(printerPresetCode('A1 mini')).toBe('A1M');
-  });
+// Two uploaded bundles, one per printer — the ground truth all matching
+// is derived from. Note P2S: a model the old hard-coded list never knew
+// about, now covered purely because its bundle was uploaded (#1325).
+const BUNDLES: CompatibilityBundle[] = [
+  {
+    printer_preset_name: X1C,
+    process: ['0.20mm Standard @BBL X1C', '0.20mm Strength @BBL X1C'],
+    filament: ['Bambu PLA Basic @BBL X1C'],
+  },
+  {
+    printer_preset_name: P2S,
+    process: ['0.20mm Standard @BBL P2S', '0.16mm Standard @BBL P2S'],
+    filament: ['Bambu PLA Basic @BBL P2S'],
+  },
+];
 
-  it('returns null for an unrecognised / custom printer', () => {
-    expect(printerPresetCode('My Custom Voron 0.4')).toBeNull();
-    expect(printerPresetCode('')).toBeNull();
+describe('buildCompatibilityIndex', () => {
+  it('maps each preset name to the printers whose bundles ship it', () => {
+    const index = buildCompatibilityIndex(BUNDLES);
+    expect([...(index.process.get('0.20mm Standard @BBL X1C') ?? [])]).toEqual([X1C]);
+    expect([...(index.process.get('0.16mm Standard @BBL P2S') ?? [])]).toEqual([P2S]);
+    expect([...(index.filament.get('Bambu PLA Basic @BBL P2S') ?? [])]).toEqual([P2S]);
   });
-});
 
-describe('presetModelCodes', () => {
-  it('parses a single model from the @BBL suffix', () => {
-    expect([...presetModelCodes('0.20mm Standard @BBL X1C')]).toEqual(['X1C']);
-    expect([...presetModelCodes('Bambu PLA Basic @BBL A1M')]).toEqual(['A1M']);
+  it('unions printers when several bundles ship the same preset name', () => {
+    const shared = '0.20mm Standard';
+    const index = buildCompatibilityIndex([
+      { printer_preset_name: X1C, process: [shared], filament: [] },
+      { printer_preset_name: P2S, process: [shared], filament: [] },
+    ]);
+    expect(index.process.get(shared)).toEqual(new Set([X1C, P2S]));
   });
 
-  it('parses multiple models from one suffix', () => {
-    const codes = presetModelCodes('0.20mm Standard @BBL X1C X1');
-    expect(codes.has('X1C')).toBe(true);
-    expect(codes.has('X1')).toBe(true);
-    expect(codes.size).toBe(2);
+  it("strips BambuStudio's '# ' user-clone prefix so names compare equal", () => {
+    const index = buildCompatibilityIndex([
+      { printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] },
+    ]);
+    expect(index.process.has('0.20mm Custom')).toBe(true);
   });
 
-  it('returns an empty set for generic / untagged presets', () => {
-    expect(presetModelCodes('Generic PLA @base').size).toBe(0);
-    expect(presetModelCodes('My Custom Process').size).toBe(0);
+  it('skips bundles with no printer name', () => {
+    const index = buildCompatibilityIndex([
+      { printer_preset_name: '', process: ['Orphan Process'], filament: [] },
+    ]);
+    expect(index.process.size).toBe(0);
   });
 });
 
 describe('presetCompatibility', () => {
-  it('uses compatible_printers exactly when present (local-tier override)', () => {
-    const preset = {
-      name: 'My Process',
-      compatible_printers: ['Bambu Lab X1 Carbon 0.4 nozzle'],
-    };
+  const index = buildCompatibilityIndex(BUNDLES);
+
+  it('uses compatible_printers exactly when present (imported / local tier)', () => {
+    const preset = { name: 'My Process', compatible_printers: [X1C] };
+    expect(presetCompatibility(preset, 'process', X1C, EMPTY_COMPATIBILITY_INDEX)).toBe('match');
+    expect(presetCompatibility(preset, 'process', P2S, EMPTY_COMPATIBILITY_INDEX)).toBe('mismatch');
+  });
+
+  it('is unknown when compatible_printers is set but no printer is selected', () => {
     expect(
-      presetCompatibility(preset, 'Bambu Lab X1 Carbon 0.4 nozzle', 'X1C'),
-    ).toBe('match');
-    expect(presetCompatibility(preset, 'Bambu Lab A1 0.4 nozzle', 'A1')).toBe(
-      'mismatch',
-    );
+      presetCompatibility({ name: 'P', compatible_printers: [X1C] }, 'process', null, index),
+    ).toBe('unknown');
   });
 
-  it('falls back to the name heuristic when compatible_printers is absent', () => {
-    const preset = { name: '0.20mm Standard @BBL X1C' };
-    expect(presetCompatibility(preset, 'Bambu Lab X1 Carbon 0.4 nozzle', 'X1C')).toBe(
+  it('matches a preset shipped by the selected printer\'s bundle', () => {
+    expect(presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', X1C, index)).toBe(
       'match',
     );
-    expect(presetCompatibility(preset, 'Bambu Lab A1 0.4 nozzle', 'A1')).toBe(
-      'mismatch',
-    );
+    expect(
+      presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S, index),
+    ).toBe('match');
   });
 
-  it('is unknown when the preset carries no resolvable model', () => {
-    expect(presetCompatibility({ name: 'Generic PLA @base' }, 'Bambu Lab X1 Carbon', 'X1C')).toBe(
-      'unknown',
+  it('flags a preset whose bundle is for a different printer (the #1325 bug)', () => {
+    // X1C selected, but this process only ships in the P2S bundle.
+    expect(presetCompatibility({ name: '0.16mm Standard @BBL P2S' }, 'process', X1C, index)).toBe(
+      'mismatch',
     );
   });
 
-  it('is unknown when the selected printer is unrecognised', () => {
+  it('is unknown when no uploaded bundle covers the preset', () => {
     expect(
-      presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'My Custom Printer', null),
+      presetCompatibility({ name: '0.20mm Standard @BBL A1' }, 'process', X1C, index),
     ).toBe('unknown');
   });
 
-  it('is unknown when compatible_printers is set but no printer is selected', () => {
+  it('is unknown when no bundles are imported at all', () => {
     expect(
       presetCompatibility(
-        { name: 'P', compatible_printers: ['Bambu Lab X1 Carbon 0.4 nozzle'] },
-        null,
-        null,
+        { name: '0.20mm Standard @BBL X1C' },
+        'process',
+        X1C,
+        EMPTY_COMPATIBILITY_INDEX,
       ),
     ).toBe('unknown');
   });
+
+  it('is unknown when no printer is selected', () => {
+    expect(
+      presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', null, index),
+    ).toBe('unknown');
+  });
+
+  it("matches across the '# ' user-clone prefix", () => {
+    const index2 = buildCompatibilityIndex([
+      { printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] },
+    ]);
+    expect(presetCompatibility({ name: '0.20mm Custom' }, 'process', X1C, index2)).toBe('match');
+  });
 });

+ 1 - 1
frontend/src/api/client.ts

@@ -1325,7 +1325,7 @@ export interface UnifiedPreset {
   // compatible with. Populated for the local tier (the slicer's own
   // `compatible_printers`); null for cloud / standard. The SliceModal filters
   // the process / filament dropdowns by the selected printer using this when
-  // present, falling back to a name-suffix heuristic otherwise (#1325).
+  // present, and otherwise by the user's uploaded Slicer Bundles (#1325).
   compatible_printers?: string[] | null;
 }
 export interface UnifiedPresetsBySlot {

+ 38 - 24
frontend/src/components/SliceModal.tsx

@@ -20,7 +20,12 @@ import { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
 import type { PlateFilament } from '../types/plates';
 import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
-import { presetCompatibility, printerPresetCode } from '../utils/slicerPrinterMatch';
+import {
+  presetCompatibility,
+  buildCompatibilityIndex,
+  EMPTY_COMPATIBILITY_INDEX,
+  type PrinterCompatibilityIndex,
+} from '../utils/slicerPrinterMatch';
 
 export type SliceSource =
   | { kind: 'libraryFile'; id: number; filename: string }
@@ -87,20 +92,20 @@ function findPresetByName(
 function pickProcessDefault(
   by: UnifiedPresetsResponse,
   printerName: string | null,
-  printerCode: string | null,
+  compatIndex: PrinterCompatibilityIndex,
   preferredName?: string | null,
 ): PresetRef | null {
   const preferred = findPresetByName(by, 'process', preferredName);
   if (preferred) {
     const p = findPreset(by, preferred, 'process');
-    if (p && presetCompatibility(p, printerName, printerCode) !== 'mismatch') {
+    if (p && presetCompatibility(p, 'process', printerName, compatIndex) !== 'mismatch') {
       return preferred;
     }
   }
   for (const wanted of ['match', 'unknown'] as const) {
     for (const tier of SLICE_MODAL_TIER_ORDER) {
       for (const p of by[tier].process) {
-        if (presetCompatibility(p, printerName, printerCode) === wanted) {
+        if (presetCompatibility(p, 'process', printerName, compatIndex) === wanted) {
           return { source: p.source, id: p.id };
         }
       }
@@ -119,7 +124,7 @@ function pickFilamentForSlot(
   by: UnifiedPresetsResponse,
   required: { type: string; color: string },
   printerName: string | null,
-  printerCode: string | null,
+  compatIndex: PrinterCompatibilityIndex,
 ): PresetRef | null {
   // Score every filament preset against the plate slot's required (type,
   // colour) and pick the highest. Mirrors the AMS slot-mapping match in the
@@ -144,7 +149,7 @@ function pickFilamentForSlot(
       // Demote printer-incompatible filaments (#1325): a penalty rather than a
       // hard skip so the pick still degrades gracefully if every filament
       // mismatches the selected printer.
-      if (presetCompatibility(p, printerName, printerCode) === 'mismatch') {
+      if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') {
         score -= 100;
       }
       if (best == null || score > best.score) {
@@ -429,9 +434,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     if (!presetsQuery.data || !printerPreset) return null;
     return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
   }, [presetsQuery.data, printerPreset]);
-  const selectedPrinterCode = useMemo<string | null>(
-    () => (selectedPrinterName ? printerPresetCode(selectedPrinterName) : null),
-    [selectedPrinterName],
+  // Compatibility ground truth, derived from the user's uploaded Slicer
+  // Bundles (#1325) — empty until at least one bundle is imported, in which
+  // case no process / filament gets filtered (nothing to filter against).
+  const compatIndex = useMemo<PrinterCompatibilityIndex>(
+    () => buildCompatibilityIndex(bundlesQuery.data ?? []),
+    [bundlesQuery.data],
   );
 
   // Printer / process preset names the source 3MF was prepared with. The
@@ -463,13 +471,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     setProcessPreset((current) => {
       if (current) {
         const p = findPreset(data, current, 'process');
-        if (p && presetCompatibility(p, selectedPrinterName, selectedPrinterCode) !== 'mismatch') {
+        if (p && presetCompatibility(p, 'process', selectedPrinterName, compatIndex) !== 'mismatch') {
           return current;
         }
       }
-      return pickProcessDefault(data, selectedPrinterName, selectedPrinterCode, embeddedProcess);
+      return pickProcessDefault(data, selectedPrinterName, compatIndex, embeddedProcess);
     });
-  }, [presetsQuery.data, selectedPrinterName, selectedPrinterCode, embeddedProcess]);
+  }, [presetsQuery.data, selectedPrinterName, compatIndex, embeddedProcess]);
 
   // Filament pre-pick: re-runs when the active filament-slot count changes
   // (plate selection, single-plate metadata arriving) or the selected printer
@@ -485,7 +493,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
         const cur = current[i] ?? null;
         if (cur) {
           const p = findPreset(data, cur, 'filament');
-          if (p && presetCompatibility(p, selectedPrinterName, selectedPrinterCode) !== 'mismatch') {
+          if (p && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch') {
             return cur;
           }
         }
@@ -493,11 +501,11 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
           data,
           { type: slot.type, color: slot.color },
           selectedPrinterName,
-          selectedPrinterCode,
+          compatIndex,
         );
       });
     });
-  }, [presetsQuery.data, filamentSlots, selectedPrinterName, selectedPrinterCode]);
+  }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
 
   // Bundle-mode auto-pick: when the user picks a bundle (or the slot count
   // changes after the picker is open), default the process to the bundle's
@@ -719,7 +727,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                     onChange={setProcessPreset}
                     disabled={isEnqueuing}
                     selectedPrinterName={selectedPrinterName}
-                    selectedPrinterCode={selectedPrinterCode}
+                    compatIndex={compatIndex}
                   />
                 </>
               )}
@@ -837,7 +845,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                       disabled={isEnqueuing || !isUsed}
                       swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
                       selectedPrinterName={selectedPrinterName}
-                      selectedPrinterCode={selectedPrinterCode}
+                      compatIndex={compatIndex}
                     />
                   );
                 })
@@ -984,10 +992,11 @@ interface PresetDropdownProps {
   // configuring against the source 3MF's per-slot colour.
   swatchColor?: string;
   // Selected printer context (#1325). When provided for a process / filament
-  // slot, presets that resolve to a different printer move into a trailing
-  // "Other printers" group instead of the main tier list.
+  // slot, presets that resolve to a different printer (per the uploaded
+  // Slicer Bundles in compatIndex) move into a trailing "Other printers"
+  // group instead of the main tier list.
   selectedPrinterName?: string | null;
-  selectedPrinterCode?: string | null;
+  compatIndex?: PrinterCompatibilityIndex;
 }
 
 function PresetDropdown({
@@ -999,7 +1008,7 @@ function PresetDropdown({
   disabled,
   swatchColor,
   selectedPrinterName,
-  selectedPrinterCode,
+  compatIndex,
 }: PresetDropdownProps) {
   const { t } = useTranslation();
 
@@ -1026,8 +1035,13 @@ function PresetDropdown({
       const compatible: UnifiedPreset[] = [];
       for (const p of entries) {
         if (
-          presetCompatibility(p, selectedPrinterName ?? null, selectedPrinterCode ?? null) ===
-          'mismatch'
+          presetCompatibility(
+            p,
+            // filterByPrinter is true here, so slot is never 'printer'.
+            slot as 'process' | 'filament',
+            selectedPrinterName ?? null,
+            compatIndex ?? EMPTY_COMPATIBILITY_INDEX,
+          ) === 'mismatch'
         ) {
           other.push(p);
         } else {
@@ -1039,7 +1053,7 @@ function PresetDropdown({
       }
     }
     return { sections: compatSections, otherEntries: other };
-  }, [data, slot, t, selectedPrinterName, selectedPrinterCode]);
+  }, [data, slot, t, selectedPrinterName, compatIndex]);
 
   const totalEntries =
     sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;

+ 72 - 73
frontend/src/utils/slicerPrinterMatch.ts

@@ -1,101 +1,100 @@
 // Printer-compatibility matching for the SliceModal's process / filament
-// dropdowns (#1325). Bambuddy can't filter every preset tier the same way:
+// dropdowns (#1325).
 //
-//   - local presets carry the slicer's own `compatible_printers` list (a
-//     precise list of printer-preset names) — used directly when present.
-//   - cloud / standard presets carry no compatibility data, so we fall back
-//     to the "@BBL <model>" suffix Bambu Studio / OrcaSlicer embed in preset
-//     names (e.g. "0.20mm Standard @BBL X1C").
+// Compatibility is read from ground truth, never guessed from preset names:
 //
-// Either way the result drives grouping, not hard hiding: a preset whose
-// compatibility can't be determined stays in the main list, and only a
-// preset that resolves to a *different* printer is pushed into an
-// "Other printers" group.
+//   - imported (local-tier) presets carry the slicer's own
+//     `compatible_printers` list — an exact list of printer-preset names.
+//   - every other preset is matched through the user's uploaded Slicer
+//     Bundles (.bbscfg). A bundle is scoped to one printer and lists the
+//     process / filament presets shipped with it, so "process P works with
+//     printer X" holds exactly when some uploaded bundle for printer X
+//     contains P. No model codes, no name parsing — a newly released Bambu
+//     model is covered the moment its bundle is uploaded.
+//
+// The result drives grouping, not hard hiding: a preset no bundle covers
+// stays in the main list, and only a preset that resolves to a *different*
+// printer is pushed into an "Other printers" group.
 
-// Canonical Bambu Lab model codes exactly as they appear in preset-name
-// suffixes. Order is irrelevant here — this is a membership set.
-const KNOWN_MODEL_CODES = new Set([
-  'X1C',
-  'X1E',
-  'X1',
-  'P1S',
-  'P1P',
-  'A1M',
-  'A1',
-  'H2D',
-  'H2S',
-]);
+export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
 
-// Ordered printer-preset-name patterns → model code. First match wins, so
-// more specific names ("X1 Carbon", "A1 mini") must precede their prefixes
-// ("X1", "A1").
-const PRINTER_NAME_PATTERNS: { re: RegExp; code: string }[] = [
-  { re: /x1\s*-?\s*carbon/i, code: 'X1C' },
-  { re: /\bx1c\b/i, code: 'X1C' },
-  { re: /\bx1e\b/i, code: 'X1E' },
-  { re: /\bx1\b/i, code: 'X1' },
-  { re: /\bp1s\b/i, code: 'P1S' },
-  { re: /\bp1p\b/i, code: 'P1P' },
-  { re: /a1\s*mini/i, code: 'A1M' },
-  { re: /\ba1m\b/i, code: 'A1M' },
-  { re: /\ba1\b/i, code: 'A1' },
-  { re: /\bh2d\b/i, code: 'H2D' },
-  { re: /\bh2s\b/i, code: 'H2S' },
-];
+// Minimal shape of a Slicer Bundle needed for matching (see SlicerBundle in
+// api/client.ts). `printer_preset_name` scopes the bundle to one printer;
+// `process` / `filament` are the preset names that bundle ships.
+export interface CompatibilityBundle {
+  printer_preset_name: string;
+  process: string[];
+  filament: string[];
+}
 
-/**
- * Derive a Bambu model code from a printer-preset name
- * (e.g. "Bambu Lab X1 Carbon 0.4 nozzle" → "X1C"). Returns null when the
- * name matches no known model — a custom / third-party printer, for which
- * we can't filter and so show every preset.
- */
-export function printerPresetCode(name: string): string | null {
-  for (const { re, code } of PRINTER_NAME_PATTERNS) {
-    if (re.test(name)) return code;
-  }
-  return null;
+// A preset-name → set-of-compatible-printer-names index, one map per slot,
+// built from every uploaded bundle. Empty when no bundles are imported.
+export interface PrinterCompatibilityIndex {
+  process: Map<string, Set<string>>;
+  filament: Map<string, Set<string>>;
+}
+
+/** An empty index — used when no bundles are imported / available yet. */
+export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
+  process: new Map(),
+  filament: new Map(),
+};
+
+// Bundle preset names occasionally carry BambuStudio's "# " user-clone
+// prefix; strip it so a bundle entry and a tier-listed preset compare equal.
+function normalizePresetName(name: string): string {
+  return name.replace(/^#\s*/, '').trim();
 }
 
 /**
- * Parse the "@BBL <model>..." suffix of a process / filament preset name into
- * the set of model codes it targets. A preset can list several
- * ("... @BBL X1C X1"); a generic preset ("Generic PLA @base") yields an
- * empty set, meaning "applies everywhere / can't tell".
+ * Build the compatibility index from the user's uploaded Slicer Bundles.
+ * Each bundle contributes its printer to every process / filament name it
+ * ships; a name shipped by several bundles accumulates every printer.
  */
-export function presetModelCodes(name: string): Set<string> {
-  const at = name.lastIndexOf('@');
-  if (at < 0) return new Set();
-  const tokens = name
-    .slice(at + 1)
-    .toUpperCase()
-    .split(/[\s,]+/)
-    .filter(Boolean);
-  return new Set(tokens.filter((tok) => KNOWN_MODEL_CODES.has(tok)));
+export function buildCompatibilityIndex(
+  bundles: readonly CompatibilityBundle[],
+): PrinterCompatibilityIndex {
+  const process = new Map<string, Set<string>>();
+  const filament = new Map<string, Set<string>>();
+  const add = (map: Map<string, Set<string>>, name: string, printer: string) => {
+    const key = normalizePresetName(name);
+    if (!key) return;
+    const set = map.get(key) ?? new Set<string>();
+    set.add(printer);
+    map.set(key, set);
+  };
+  for (const bundle of bundles) {
+    const printer = bundle.printer_preset_name?.trim();
+    if (!printer) continue;
+    for (const name of bundle.process) add(process, name, printer);
+    for (const name of bundle.filament) add(filament, name, printer);
+  }
+  return { process, filament };
 }
 
-export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
-
 /**
  * Classify a process / filament preset against the selected printer.
  *
  * - 'match'    — the preset is compatible with the selected printer.
  * - 'mismatch' — the preset resolves to a *different* printer.
- * - 'unknown'  — compatibility can't be determined (no data, generic preset,
- *                or an unrecognised printer); the caller should not hide it.
+ * - 'unknown'  — compatibility can't be determined (no `compatible_printers`
+ *                and no uploaded bundle covers the preset, or no printer is
+ *                selected); the caller must not hide it.
  */
 export function presetCompatibility(
   preset: { name: string; compatible_printers?: string[] | null },
+  slot: 'process' | 'filament',
   selectedPrinterName: string | null,
-  selectedPrinterCode: string | null,
+  index: PrinterCompatibilityIndex,
 ): PrinterCompatibility {
-  // Precise link first: the slicer's own compatible_printers list (local tier).
+  // Imported presets carry the slicer's own compatible_printers list.
   const compat = preset.compatible_printers;
   if (compat && compat.length > 0) {
     if (!selectedPrinterName) return 'unknown';
     return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
   }
-  // Heuristic fallback: the "@BBL <model>" suffix in the preset name.
-  const codes = presetModelCodes(preset.name);
-  if (codes.size === 0 || !selectedPrinterCode) return 'unknown';
-  return codes.has(selectedPrinterCode) ? 'match' : 'mismatch';
+  // Otherwise consult the uploaded bundles.
+  const printers = index[slot].get(normalizePresetName(preset.name));
+  if (!printers || printers.size === 0 || !selectedPrinterName) return 'unknown';
+  return printers.has(selectedPrinterName) ? 'match' : 'mismatch';
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B2ysIPjO.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DQCawz3_.js"></script>
+    <script type="module" crossorigin src="/assets/index-B2ysIPjO.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CBuiHfeb.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff