Browse Source

fix(slice): @BBL name fallback for users without slicer bundles (#1325 follow-up)

  The first cut of #1325 swapped a stale hardcoded model table for
  bundle-based compatibility matching: a cloud / standard process or
  filament preset was classified by consulting the user's uploaded
  Slicer Bundles (.bbscfg). That works perfectly when bundles cover
  every printer in the user's cloud catalogue, and silently no-ops
  otherwise - every cloud preset resolves to 'unknown', nothing moves
  into "Other printers", and the dropdowns look identical to the
  pre-fix state. The reporter saw exactly this on a clean install
  with no bundles uploaded.

  Restored BambuStudio's `@BBL <token>` name convention as a third
  tier below the bundle path, but driven by the canonical backend
  PRINTER_MODEL_MAP - exposed via a new GET /slicer/printer-models
  route - rather than a manually-maintained frontend table. The
  matcher inverts the registry into short-code -> printer-fragment
  ("X1C" -> "X1 Carbon"), normalises whitespace + case so "A1 mini"
  and "A1 Mini" compare equal, and falls back to raw-token compare
  for models not yet in the registry (so a future "Q1" matches
  without a code change). Adding a new Bambu model still touches
  exactly the one backend file already listed in the Bambu Model
  Codes registry.

  Tests: 2 new in test_slicer_presets.py (route returns the full
  PRINTER_MODEL_MAP, route returns a copy not the live dict); 11
  new in slicerPrinterMatch.test.ts covering registry-driven X1C
  vs X1 Carbon, A1 vs A1 mini, H2D vs H2D Pro, P2S / H2C / H2S /
  X2D (which the original hardcoded list was missing), raw-token
  fallback, registry-not-loaded-yet degradation, and the
  precedence rules between compatible_printers / bundle / @BBL
  name. 38 slicer-presets + 36 slicerPrinterMatch + 34 SliceModal
  tests green; backend ruff clean; frontend build clean.
maziggy 4 days ago
parent
commit
3058c5789b

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


+ 18 - 0
backend/app/api/routes/slicer_presets.py

@@ -45,6 +45,7 @@ from backend.app.services.slicer_api import (
     SlicerApiUnavailableError,
     SlicerApiUnavailableError,
     SlicerInputError,
     SlicerInputError,
 )
 )
+from backend.app.utils.printer_models import PRINTER_MODEL_MAP
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -360,6 +361,23 @@ def _dedupe_by_name(
     return cloud, deduped_local, deduped_standard
     return cloud, deduped_local, deduped_standard
 
 
 
 
+@router.get("/printer-models")
+def list_printer_models() -> dict[str, str]:
+    """Canonical Bambu printer-model registry, surfaced for the SliceModal.
+
+    Returns the backend's ``PRINTER_MODEL_MAP`` unmodified: keys are the long
+    "Bambu Lab <model>" form that appears in 3MF metadata and in slicer
+    printer-preset names, values are the normalized short codes used in
+    BambuStudio's `@BBL <code>` cloud-preset filenames. The frontend uses this
+    mapping to classify cloud / standard presets against the selected printer
+    when no slicer bundle has been uploaded that covers the preset (#1325
+    follow-up) - avoiding a second, manually-maintained model table on the
+    frontend. No auth gate: this is a static reference dictionary, not
+    user data.
+    """
+    return dict(PRINTER_MODEL_MAP)
+
+
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
 async def list_unified_presets(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),

+ 27 - 0
backend/tests/unit/test_slicer_presets.py

@@ -670,3 +670,30 @@ class TestParseCompatiblePrinters:
             "X1C",
             "X1C",
             "A1",
             "A1",
         ]
         ]
+
+
+class TestListPrinterModels:
+    """``GET /slicer/printer-models`` exposes ``PRINTER_MODEL_MAP`` so the
+    frontend doesn't duplicate the Bambu model registry (#1325 follow-up)."""
+
+    def test_returns_canonical_printer_model_map(self):
+        from backend.app.utils.printer_models import PRINTER_MODEL_MAP
+
+        result = sp.list_printer_models()
+        # Same shape - mapping from "Bambu Lab <model>" to short code.
+        assert result == PRINTER_MODEL_MAP
+        # Spot-check a few entries: the SliceModal name-fallback (#1325)
+        # specifically depends on these resolving.
+        assert result["Bambu Lab X1 Carbon"] == "X1C"
+        assert result["Bambu Lab P2S"] == "P2S"
+        assert result["Bambu Lab A1 mini"] == "A1 Mini"
+        assert result["Bambu Lab H2D Pro"] == "H2D Pro"
+
+    def test_returns_a_copy_not_the_module_dict(self):
+        # A response handler must never hand out the live module-level dict —
+        # accidental mutation by middleware / serialisers would silently
+        # corrupt the registry for every subsequent request.
+        from backend.app.utils.printer_models import PRINTER_MODEL_MAP
+
+        result = sp.list_printer_models()
+        assert result is not PRINTER_MODEL_MAP

+ 223 - 20
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -9,6 +9,27 @@ import {
 const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle';
 const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle';
 const P2S = 'Bambu Lab P2S 0.4 nozzle';
 const P2S = 'Bambu Lab P2S 0.4 nozzle';
 
 
+// Mirror of backend/app/utils/printer_models.py PRINTER_MODEL_MAP, fetched
+// from /slicer/printer-models at runtime (#1325 follow-up). Listed in tests
+// to exercise the @BBL name fallback against the same registry the real
+// app sees.
+const PRINTER_MODELS: Record<string, string> = {
+  'Bambu Lab X1 Carbon': 'X1C',
+  'Bambu Lab X1': 'X1',
+  'Bambu Lab X1E': 'X1E',
+  'Bambu Lab P1S': 'P1S',
+  'Bambu Lab P1P': 'P1P',
+  'Bambu Lab P2S': 'P2S',
+  'Bambu Lab A1': 'A1',
+  'Bambu Lab A1 Mini': 'A1 Mini',
+  'Bambu Lab A1 mini': 'A1 Mini',
+  'Bambu Lab H2D': 'H2D',
+  'Bambu Lab H2D Pro': 'H2D Pro',
+  'Bambu Lab H2C': 'H2C',
+  'Bambu Lab H2S': 'H2S',
+  'Bambu Lab X2D': 'X2D',
+};
+
 // Two uploaded bundles, one per printer — the ground truth all matching
 // 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
 // is derived from. Note P2S: a model the old hard-coded list never knew
 // about, now covered purely because its bundle was uploaded (#1325).
 // about, now covered purely because its bundle was uploaded (#1325).
@@ -27,7 +48,7 @@ const BUNDLES: CompatibilityBundle[] = [
 
 
 describe('buildCompatibilityIndex', () => {
 describe('buildCompatibilityIndex', () => {
   it('maps each preset name to the printers whose bundles ship it', () => {
   it('maps each preset name to the printers whose bundles ship it', () => {
-    const index = buildCompatibilityIndex(BUNDLES);
+    const index = buildCompatibilityIndex(BUNDLES, PRINTER_MODELS);
     expect([...(index.process.get('0.20mm Standard @BBL X1C') ?? [])]).toEqual([X1C]);
     expect([...(index.process.get('0.20mm Standard @BBL X1C') ?? [])]).toEqual([X1C]);
     expect([...(index.process.get('0.16mm Standard @BBL P2S') ?? [])]).toEqual([P2S]);
     expect([...(index.process.get('0.16mm Standard @BBL P2S') ?? [])]).toEqual([P2S]);
     expect([...(index.filament.get('Bambu PLA Basic @BBL P2S') ?? [])]).toEqual([P2S]);
     expect([...(index.filament.get('Bambu PLA Basic @BBL P2S') ?? [])]).toEqual([P2S]);
@@ -35,30 +56,53 @@ describe('buildCompatibilityIndex', () => {
 
 
   it('unions printers when several bundles ship the same preset name', () => {
   it('unions printers when several bundles ship the same preset name', () => {
     const shared = '0.20mm Standard';
     const shared = '0.20mm Standard';
-    const index = buildCompatibilityIndex([
-      { printer_preset_name: X1C, process: [shared], filament: [] },
-      { printer_preset_name: P2S, process: [shared], filament: [] },
-    ]);
+    const index = buildCompatibilityIndex(
+      [
+        { printer_preset_name: X1C, process: [shared], filament: [] },
+        { printer_preset_name: P2S, process: [shared], filament: [] },
+      ],
+      PRINTER_MODELS,
+    );
     expect(index.process.get(shared)).toEqual(new Set([X1C, P2S]));
     expect(index.process.get(shared)).toEqual(new Set([X1C, P2S]));
   });
   });
 
 
   it("strips BambuStudio's '# ' user-clone prefix so names compare equal", () => {
   it("strips BambuStudio's '# ' user-clone prefix so names compare equal", () => {
-    const index = buildCompatibilityIndex([
-      { printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] },
-    ]);
+    const index = buildCompatibilityIndex(
+      [{ printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] }],
+      PRINTER_MODELS,
+    );
     expect(index.process.has('0.20mm Custom')).toBe(true);
     expect(index.process.has('0.20mm Custom')).toBe(true);
   });
   });
 
 
   it('skips bundles with no printer name', () => {
   it('skips bundles with no printer name', () => {
-    const index = buildCompatibilityIndex([
-      { printer_preset_name: '', process: ['Orphan Process'], filament: [] },
-    ]);
+    const index = buildCompatibilityIndex(
+      [{ printer_preset_name: '', process: ['Orphan Process'], filament: [] }],
+      PRINTER_MODELS,
+    );
     expect(index.process.size).toBe(0);
     expect(index.process.size).toBe(0);
   });
   });
+
+  it('inverts the printer-model registry into short-code → display fragment', () => {
+    const index = buildCompatibilityIndex([], PRINTER_MODELS);
+    expect(index.bambuModelByShortCode.X1C).toBe('X1 Carbon');
+    expect(index.bambuModelByShortCode.P2S).toBe('P2S');
+    expect(index.bambuModelByShortCode['A1 Mini']).toBe('A1 Mini');
+    expect(index.bambuModelByShortCode['H2D Pro']).toBe('H2D Pro');
+  });
+
+  it('tolerates an empty printer-model registry (model fetch hasn\'t resolved yet)', () => {
+    const index = buildCompatibilityIndex(BUNDLES);
+    expect(index.bambuModelByShortCode).toEqual({});
+    // Bundle matching still works on its own.
+    expect([...(index.process.get('0.20mm Standard @BBL X1C') ?? [])]).toEqual([X1C]);
+  });
 });
 });
 
 
 describe('presetCompatibility', () => {
 describe('presetCompatibility', () => {
-  const index = buildCompatibilityIndex(BUNDLES);
+  const index = buildCompatibilityIndex(BUNDLES, PRINTER_MODELS);
+  // Bundle-free index used by the #1325 follow-up fallback tests: any match
+  // here must come from the @BBL name parse alone.
+  const namesOnlyIndex = buildCompatibilityIndex([], PRINTER_MODELS);
 
 
   it('uses compatible_printers exactly when present (imported / local tier)', () => {
   it('uses compatible_printers exactly when present (imported / local tier)', () => {
     const preset = { name: 'My Process', compatible_printers: [X1C] };
     const preset = { name: 'My Process', compatible_printers: [X1C] };
@@ -88,21 +132,33 @@ describe('presetCompatibility', () => {
     );
     );
   });
   });
 
 
-  it('is unknown when no uploaded bundle covers the preset', () => {
+  it('falls back to @BBL name parsing when no bundle covers the preset (#1325 follow-up)', () => {
+    // No A1 bundle uploaded, but the preset's @BBL A1 tag is enough to
+    // resolve it: A1 ≠ X1C so it belongs in "Other printers".
     expect(
     expect(
       presetCompatibility({ name: '0.20mm Standard @BBL A1' }, 'process', X1C, index),
       presetCompatibility({ name: '0.20mm Standard @BBL A1' }, 'process', X1C, index),
-    ).toBe('unknown');
+    ).toBe('mismatch');
   });
   });
 
 
-  it('is unknown when no bundles are imported at all', () => {
+  it('falls back to @BBL name parsing when no bundles are imported at all', () => {
+    // Brand-new user, zero bundles, every preset would have been "unknown"
+    // under the bundle-only design — now resolves via the name suffix.
     expect(
     expect(
       presetCompatibility(
       presetCompatibility(
         { name: '0.20mm Standard @BBL X1C' },
         { name: '0.20mm Standard @BBL X1C' },
         'process',
         'process',
         X1C,
         X1C,
-        EMPTY_COMPATIBILITY_INDEX,
+        namesOnlyIndex,
       ),
       ),
-    ).toBe('unknown');
+    ).toBe('match');
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL P2S' },
+        'process',
+        X1C,
+        namesOnlyIndex,
+      ),
+    ).toBe('mismatch');
   });
   });
 
 
   it('is unknown when no printer is selected', () => {
   it('is unknown when no printer is selected', () => {
@@ -112,9 +168,156 @@ describe('presetCompatibility', () => {
   });
   });
 
 
   it("matches across the '# ' user-clone prefix", () => {
   it("matches across the '# ' user-clone prefix", () => {
-    const index2 = buildCompatibilityIndex([
-      { printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] },
-    ]);
+    const index2 = buildCompatibilityIndex(
+      [{ printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] }],
+      PRINTER_MODELS,
+    );
     expect(presetCompatibility({ name: '0.20mm Custom' }, 'process', X1C, index2)).toBe('match');
     expect(presetCompatibility({ name: '0.20mm Custom' }, 'process', X1C, index2)).toBe('match');
   });
   });
+
+  it('compatible_printers wins over @BBL even when the name suggests a different printer', () => {
+    // Authoritative slicer declaration: this @BBL P2S preset has been
+    // manually reassigned to X1C. The compatible_printers list must win.
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL P2S', compatible_printers: [X1C] },
+        'process',
+        X1C,
+        namesOnlyIndex,
+      ),
+    ).toBe('match');
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL P2S', compatible_printers: [X1C] },
+        'process',
+        P2S,
+        namesOnlyIndex,
+      ),
+    ).toBe('mismatch');
+  });
+
+  it('bundle index wins over @BBL when they disagree', () => {
+    // Hypothetical bundle that ships a P2S-tagged preset as compatible
+    // with the X1C printer too — bundle-as-ground-truth overrules the
+    // name-suffix inference.
+    const reassigned = buildCompatibilityIndex(
+      [{ printer_preset_name: X1C, process: ['0.20mm Standard @BBL P2S'], filament: [] }],
+      PRINTER_MODELS,
+    );
+    expect(
+      presetCompatibility({ name: '0.20mm Standard @BBL P2S' }, 'process', X1C, reassigned),
+    ).toBe('match');
+  });
+});
+
+// ─── #1325 follow-up: @BBL name fallback ──────────────────────────────────
+
+describe('presetCompatibility — @BBL name fallback (no bundles)', () => {
+  // No bundles, but with the registry loaded — exactly the new-user shape.
+  const idx = buildCompatibilityIndex([], PRINTER_MODELS);
+
+  // Bambu's short codes vs the long forms in printer-preset names: the
+  // entire reason the fallback needs a registry to consult.
+  it.each<[string, string, 'match' | 'mismatch']>([
+    // @BBL X1C → "X1 Carbon" (the case the old hardcoded list got right)
+    ['0.20mm Standard @BBL X1C', X1C, 'match'],
+    ['0.20mm Standard @BBL X1C', 'Bambu Lab P1S 0.4 nozzle', 'mismatch'],
+    // @BBL X1 must NOT match X1 Carbon (X1 and X1C are physically different printers)
+    ['0.20mm Standard @BBL X1', 'Bambu Lab X1 0.4 nozzle', 'match'],
+    ['0.20mm Standard @BBL X1', X1C, 'mismatch'],
+    // @BBL A1 must NOT match A1 mini (case the original hardcoded list got wrong)
+    ['0.20mm Standard @BBL A1', 'Bambu Lab A1 0.4 nozzle', 'match'],
+    ['0.20mm Standard @BBL A1', 'Bambu Lab A1 mini 0.4 nozzle', 'mismatch'],
+    // @BBL "A1 Mini" — multi-word token
+    ['0.20mm Standard @BBL A1 Mini', 'Bambu Lab A1 mini 0.4 nozzle', 'match'],
+    // @BBL H2D vs H2D Pro disambiguation
+    ['0.20mm Standard @BBL H2D', 'Bambu Lab H2D 0.4 nozzle', 'match'],
+    ['0.20mm Standard @BBL H2D', 'Bambu Lab H2D Pro 0.4 nozzle', 'mismatch'],
+    ['0.20mm Standard @BBL H2D Pro', 'Bambu Lab H2D Pro 0.4 nozzle', 'match'],
+    // Models missing from the original hardcoded list (the #1325 bug),
+    // now resolved via the backend registry.
+    ['Bambu PLA Basic @BBL P2S', P2S, 'match'],
+    ['Bambu PLA Basic @BBL P2S', X1C, 'mismatch'],
+    ['0.20mm Standard @BBL X2D', 'Bambu Lab X2D 0.4 nozzle', 'match'],
+    ['0.20mm Standard @BBL H2C', 'Bambu Lab H2C 0.4 nozzle', 'match'],
+    ['0.20mm Standard @BBL H2S', 'Bambu Lab H2S 0.4 nozzle', 'match'],
+  ])('classifies %s against %s as %s', (presetName, printerName, expected) => {
+    expect(presetCompatibility({ name: presetName }, 'process', printerName, idx)).toBe(expected);
+  });
+
+  it('handles a trailing nozzle-size suffix on the @BBL tag', () => {
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL X1C 0.4 nozzle' },
+        'process',
+        X1C,
+        idx,
+      ),
+    ).toBe('match');
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL X1C 0.6 nozzle' },
+        'process',
+        X1C,
+        idx,
+      ),
+    ).toBe('match');
+  });
+
+  it('is unknown when the preset has no @BBL tag at all (custom name, no other signal)', () => {
+    expect(presetCompatibility({ name: 'My Custom Process' }, 'process', X1C, idx)).toBe('unknown');
+  });
+
+  it('is unknown for a Bambu preset against a non-Bambu printer (can\'t parse the printer name)', () => {
+    expect(
+      presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', 'CustomBuild 0.4', idx),
+    ).toBe('unknown');
+  });
+
+  it('falls back to raw-token comparison for a model not yet in the registry', () => {
+    // A future "Q1" printer with cloud presets named "@BBL Q1" should
+    // match without any code change to the registry — both names resolve
+    // to "Q1" directly.
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL Q1' },
+        'process',
+        'Bambu Lab Q1 0.4 nozzle',
+        idx,
+      ),
+    ).toBe('match');
+    // And mismatch against a different printer.
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL Q1' },
+        'process',
+        X1C,
+        idx,
+      ),
+    ).toBe('mismatch');
+  });
+
+  it('still resolves @BBL when the registry has not loaded yet (raw-token only)', () => {
+    // EMPTY_COMPATIBILITY_INDEX = no bundles, no models — first paint of
+    // the SliceModal before the /slicer/printer-models fetch resolves.
+    // Short codes that match their printer-name fragment directly (P2S,
+    // H2D, etc.) still work; codes that differ in form (X1C vs "X1
+    // Carbon") gracefully fall through to 'unknown'.
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL P2S' },
+        'process',
+        P2S,
+        EMPTY_COMPATIBILITY_INDEX,
+      ),
+    ).toBe('match');
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Standard @BBL X1C' },
+        'process',
+        X1C,
+        EMPTY_COMPATIBILITY_INDEX,
+      ),
+    ).toBe('mismatch'); // X1C ≠ "X1 Carbon" without the registry
+  });
 });
 });

+ 7 - 0
frontend/src/api/client.ts

@@ -5786,6 +5786,13 @@ export const api = {
   getSlicerPresets: () =>
   getSlicerPresets: () =>
     request<UnifiedPresetsResponse>('/slicer/presets'),
     request<UnifiedPresetsResponse>('/slicer/presets'),
 
 
+  // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
+  // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
+  // SliceModal uses this to classify cloud / standard presets by their
+  // `@BBL <code>` suffix against the selected printer-preset name (#1325).
+  getSlicerPrinterModels: () =>
+    request<Record<string, string>>('/slicer/printer-models'),
+
   // Slicer Bundles (.bbscfg) — Printer Preset Bundles imported from BambuStudio.
   // Slicer Bundles (.bbscfg) — Printer Preset Bundles imported from BambuStudio.
   // Settings → Slicer Bundles uploads/lists/deletes; the SliceModal picks
   // Settings → Slicer Bundles uploads/lists/deletes; the SliceModal picks
   // presets by name from a chosen bundle (separate follow-up).
   // presets by name from a chosen bundle (separate follow-up).

+ 16 - 5
frontend/src/components/SliceModal.tsx

@@ -443,6 +443,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     // retry tight loops in that case.
     // retry tight loops in that case.
     retry: false,
     retry: false,
   });
   });
+  // Canonical Bambu printer-model registry — drives the @BBL <code> name
+  // fallback in slicerPrinterMatch when no slicer bundle covers a cloud /
+  // standard preset (#1325 follow-up). Long staleTime: the registry only
+  // changes across backend releases.
+  const printerModelsQuery = useQuery({
+    queryKey: ['slicerPrinterModels'],
+    queryFn: api.getSlicerPrinterModels,
+    staleTime: Infinity,
+  });
   const selectedBundle: SlicerBundle | null = useMemo(() => {
   const selectedBundle: SlicerBundle | null = useMemo(() => {
     if (!selectedBundleId || !bundlesQuery.data) return null;
     if (!selectedBundleId || !bundlesQuery.data) return null;
     return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
     return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
@@ -454,12 +463,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     if (!presetsQuery.data || !printerPreset) return null;
     if (!presetsQuery.data || !printerPreset) return null;
     return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
     return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
   }, [presetsQuery.data, printerPreset]);
   }, [presetsQuery.data, printerPreset]);
-  // 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).
+  // Compatibility ground truth: the user's uploaded Slicer Bundles plus the
+  // backend Bambu printer-model registry (#1325 + follow-up). The bundle
+  // path handles imported / custom presets; the registry-driven @BBL name
+  // fallback inside slicerPrinterMatch picks up cloud / standard presets
+  // for users who haven't uploaded bundles yet.
   const compatIndex = useMemo<PrinterCompatibilityIndex>(
   const compatIndex = useMemo<PrinterCompatibilityIndex>(
-    () => buildCompatibilityIndex(bundlesQuery.data ?? []),
-    [bundlesQuery.data],
+    () => buildCompatibilityIndex(bundlesQuery.data ?? [], printerModelsQuery.data ?? {}),
+    [bundlesQuery.data, printerModelsQuery.data],
   );
   );
 
 
   // Printer / process preset names the source 3MF was prepared with. The
   // Printer / process preset names the source 3MF was prepared with. The

+ 127 - 25
frontend/src/utils/slicerPrinterMatch.ts

@@ -1,18 +1,27 @@
 // Printer-compatibility matching for the SliceModal's process / filament
 // Printer-compatibility matching for the SliceModal's process / filament
 // dropdowns (#1325).
 // dropdowns (#1325).
 //
 //
-// Compatibility is read from ground truth, never guessed from preset names:
+// Compatibility is resolved in this order, stopping on the first non-unknown
+// answer:
 //
 //
-//   - 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.
+//   1. Imported (local-tier) presets carry the slicer's own
+//      `compatible_printers` list — an exact list of printer-preset names.
+//   2. Uploaded Slicer Bundles (.bbscfg). A bundle is scoped to one printer
+//      and lists the process / filament presets shipped with it, so a preset
+//      a bundle covers is compatible with exactly that bundle's printer. A
+//      newly released Bambu model is covered the moment its bundle is
+//      uploaded — no code change required.
+//   3. BambuStudio's own `@BBL <model>` naming convention on shipped cloud
+//      / standard presets. This used to be the only signal, was removed in
+//      the first cut of #1325 in favour of (2) — which works for the author
+//      and anyone who uploaded their bundles, but silently no-ops for users
+//      who hadn't (the reporter's case). Restored as a fallback below the
+//      bundle path so the table is only consulted when bundles can't decide.
+//      The token → printer-fragment table is derived from the backend's
+//      canonical PRINTER_MODEL_MAP (fetched via /slicer/printer-models),
+//      not duplicated here.
 //
 //
-// The result drives grouping, not hard hiding: a preset no bundle covers
+// The result drives grouping, not hard hiding: a preset no rule covers
 // stays in the main list, and only a preset that resolves to a *different*
 // stays in the main list, and only a preset that resolves to a *different*
 // printer is pushed into an "Other printers" group.
 // printer is pushed into an "Other printers" group.
 
 
@@ -27,17 +36,24 @@ export interface CompatibilityBundle {
   filament: string[];
   filament: string[];
 }
 }
 
 
-// A preset-name → set-of-compatible-printer-names index, one map per slot,
-// built from every uploaded bundle. Empty when no bundles are imported.
+// Lookup tables consumed by `presetCompatibility`. `process` / `filament` are
+// preset-name → set-of-compatible-printer-names built from uploaded bundles.
+// `bambuModelByShortCode` is the @BBL token → printer-preset fragment map
+// derived from the backend's PRINTER_MODEL_MAP — e.g. `X1C` → `X1 Carbon`.
+// All three are empty by default; an empty `bambuModelByShortCode` means the
+// @BBL fallback still works when token and printer-name fragment match
+// directly (raw-token comparison), and gracefully degrades otherwise.
 export interface PrinterCompatibilityIndex {
 export interface PrinterCompatibilityIndex {
   process: Map<string, Set<string>>;
   process: Map<string, Set<string>>;
   filament: Map<string, Set<string>>;
   filament: Map<string, Set<string>>;
+  bambuModelByShortCode: Record<string, string>;
 }
 }
 
 
-/** An empty index — used when no bundles are imported / available yet. */
+/** An empty index — used when no bundles / models are loaded yet. */
 export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
 export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
   process: new Map(),
   process: new Map(),
   filament: new Map(),
   filament: new Map(),
+  bambuModelByShortCode: {},
 };
 };
 
 
 // Bundle preset names occasionally carry BambuStudio's "# " user-clone
 // Bundle preset names occasionally carry BambuStudio's "# " user-clone
@@ -47,12 +63,37 @@ function normalizePresetName(name: string): string {
 }
 }
 
 
 /**
 /**
- * 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.
+ * Invert the backend's PRINTER_MODEL_MAP into the shape the @BBL fallback
+ * needs: short code → printer-preset fragment (the part of "Bambu Lab X1
+ * Carbon" the user sees in a printer preset name, minus the "Bambu Lab "
+ * brand prefix).
+ *
+ * Backend ships e.g. `{"Bambu Lab X1 Carbon": "X1C", "Bambu Lab A1 mini":
+ * "A1 Mini", "Bambu Lab A1 Mini": "A1 Mini"}` — multiple long forms can map
+ * to the same short. We pick the first long-form encountered for each short
+ * code; case normalisation happens at match time so "A1 mini" vs "A1 Mini"
+ * never matters.
+ */
+function buildShortCodeMap(
+  printerModels: Record<string, string>,
+): Record<string, string> {
+  const out: Record<string, string> = {};
+  for (const [longName, shortCode] of Object.entries(printerModels)) {
+    if (shortCode in out) continue;
+    out[shortCode] = longName.replace(/^Bambu Lab\s+/, '');
+  }
+  return out;
+}
+
+/**
+ * Build the compatibility index from the user's uploaded Slicer Bundles and
+ * the backend printer-model registry. Each bundle contributes its printer
+ * to every process / filament name it ships; a name shipped by several
+ * bundles accumulates every printer.
  */
  */
 export function buildCompatibilityIndex(
 export function buildCompatibilityIndex(
   bundles: readonly CompatibilityBundle[],
   bundles: readonly CompatibilityBundle[],
+  printerModels: Record<string, string> = {},
 ): PrinterCompatibilityIndex {
 ): PrinterCompatibilityIndex {
   const process = new Map<string, Set<string>>();
   const process = new Map<string, Set<string>>();
   const filament = new Map<string, Set<string>>();
   const filament = new Map<string, Set<string>>();
@@ -69,7 +110,62 @@ export function buildCompatibilityIndex(
     for (const name of bundle.process) add(process, name, printer);
     for (const name of bundle.process) add(process, name, printer);
     for (const name of bundle.filament) add(filament, name, printer);
     for (const name of bundle.filament) add(filament, name, printer);
   }
   }
-  return { process, filament };
+  return {
+    process,
+    filament,
+    bambuModelByShortCode: buildShortCodeMap(printerModels),
+  };
+}
+
+function normalizeModelFragment(s: string): string {
+  return s.replace(/\s+/g, '').toLowerCase();
+}
+
+// Pull the model token out of a "@BBL <token> [0.4 nozzle]" suffix. The token
+// may contain a space (e.g. "A1 mini") so we strip a trailing nozzle-size
+// segment rather than splitting on the first whitespace.
+function extractBblToken(presetName: string): string | null {
+  const marker = '@BBL ';
+  const idx = presetName.indexOf(marker);
+  if (idx < 0) return null;
+  const rest = presetName.slice(idx + marker.length).trim();
+  const cleaned = rest.replace(/\s+[\d.]+\s*nozzle\s*$/i, '').trim();
+  return cleaned || null;
+}
+
+// Pull the model fragment out of a "Bambu Lab <model> [0.4 nozzle]" printer
+// preset name. Returns null for non-Bambu printer presets — there is no
+// reliable name-based match against those.
+function extractPrinterPresetModel(printerPresetName: string): string | null {
+  const m = printerPresetName.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?\s*$/i);
+  return m ? m[1].trim() : null;
+}
+
+/**
+ * Name-based fallback for presets BambuStudio ships with a `@BBL <model>`
+ * tag (#1325 follow-up). Used only after `compatible_printers` and the
+ * uploaded-bundle index have already returned `'unknown'`.
+ */
+function classifyByBambuName(
+  presetName: string,
+  selectedPrinterName: string,
+  bambuModelByShortCode: Record<string, string>,
+): PrinterCompatibility {
+  const token = extractBblToken(presetName);
+  if (!token) return 'unknown';
+  // If the token isn't in the table (a brand-new Bambu model whose short
+  // code the backend registry hasn't added yet, or the model map hasn't
+  // loaded yet), fall back to comparing the raw token. That keeps the
+  // matcher working when token and printer-name fragment happen to be
+  // identical — e.g. "Q1" preset against "Bambu Lab Q1 0.4 nozzle" —
+  // without us having to ship a code update. When they differ in form
+  // (X1C vs "X1 Carbon"), the registry is what makes the match work.
+  const inferredModel = bambuModelByShortCode[token] ?? token;
+  const selectedModel = extractPrinterPresetModel(selectedPrinterName);
+  if (!selectedModel) return 'unknown';
+  return normalizeModelFragment(selectedModel) === normalizeModelFragment(inferredModel)
+    ? 'match'
+    : 'mismatch';
 }
 }
 
 
 /**
 /**
@@ -77,9 +173,9 @@ export function buildCompatibilityIndex(
  *
  *
  * - 'match'    — the preset is compatible with the selected printer.
  * - 'match'    — the preset is compatible with the selected printer.
  * - 'mismatch' — the preset resolves to a *different* printer.
  * - 'mismatch' — the preset resolves to a *different* printer.
- * - '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.
+ * - 'unknown'  — compatibility can't be determined (no `compatible_printers`,
+ *                no uploaded bundle, no recognizable `@BBL` tag, or no
+ *                printer is selected); the caller must not hide it.
  */
  */
 export function presetCompatibility(
 export function presetCompatibility(
   preset: { name: string; compatible_printers?: string[] | null },
   preset: { name: string; compatible_printers?: string[] | null },
@@ -87,14 +183,20 @@ export function presetCompatibility(
   selectedPrinterName: string | null,
   selectedPrinterName: string | null,
   index: PrinterCompatibilityIndex,
   index: PrinterCompatibilityIndex,
 ): PrinterCompatibility {
 ): PrinterCompatibility {
-  // Imported presets carry the slicer's own compatible_printers list.
+  if (!selectedPrinterName) return 'unknown';
+  // (1) Imported presets carry the slicer's own compatible_printers list —
+  // authoritative when set.
   const compat = preset.compatible_printers;
   const compat = preset.compatible_printers;
   if (compat && compat.length > 0) {
   if (compat && compat.length > 0) {
-    if (!selectedPrinterName) return 'unknown';
     return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
     return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
   }
   }
-  // Otherwise consult the uploaded bundles.
+  // (2) Consult the uploaded Slicer Bundles.
   const printers = index[slot].get(normalizePresetName(preset.name));
   const printers = index[slot].get(normalizePresetName(preset.name));
-  if (!printers || printers.size === 0 || !selectedPrinterName) return 'unknown';
-  return printers.has(selectedPrinterName) ? 'match' : 'mismatch';
+  if (printers && printers.size > 0) {
+    return printers.has(selectedPrinterName) ? 'match' : 'mismatch';
+  }
+  // (3) BambuStudio's `@BBL <model>` name convention — covers cloud /
+  // standard presets for users who haven't uploaded bundles for every
+  // printer their cloud catalogue includes.
+  return classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
 }
 }

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


+ 1 - 1
static/index.html

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

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