fix(presets): match Bambu cloud @BBL A1M as A1 Mini (#1649)

Reporter on an A1 Mini saw the AMS slot Configure dropdown render no
  Bambu / Generic filament profiles, and saw the Profiles tab strip
  A1 Mini results when filtering by that model. Bambu rolled out a
  profile rename mid-2026: the @BBL <code> suffix on 106 cloud profiles
  shifted from the long display form to a terse model code -- e.g.
  "Bambu PLA Basic @BBL A1 Mini ..." is now
  "Bambu PLA Basic @BBL A1M ...". User-authored profiles still use the
  long form. Bambuddy's filters did a verbatim uppercase compare
  ("A1M" vs "A1 MINI"), so every renamed cloud profile silently
  disappeared from the picker.

  Centralize the alias check in slicerPrinterMatch.ts. New
  PRINTER_MODEL_SUFFIX_ALIASES table maps "A1 Mini" <-> "A1M"
  bidirectionally; exported matchesPrinterModelSuffix() does the
  case-insensitive compare with the alias fallback. Two consumer
  sites swap to the helper:

    * ConfigureAmsSlotModal.tsx (Orca cloud and Bambu cloud filter
      branches) -- the AMS slot picker, hit directly and reached from
      SpoolBuddy's AMS page via mapModelCode(printer?.model)
    * slicerPrinterMatch.ts:classifyByBambuName -- the SliceModal
      Process / Filament compatibility check

  Backend printer_models.py also gets a "Bambu Lab A1M" -> "A1 Mini"
  entry so server-side 3MF model normalization stays consistent if a
  3MF ever embeds the short form.

  Kept the alias table narrow on purpose. Wide-net aliasing (e.g.
  "X1" <-> "X1C") would silently collapse physically distinct
  printers. When Bambu introduces the next rename, it is one new row
  in the table -- /api/v1/cloud/settings is the place to grep, called
  out in the source comment.
This commit is contained in:
maziggy
2026-06-06 09:10:27 +02:00
parent e895d8350a
commit b8916ac3de
8 changed files with 579 additions and 444 deletions
+1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -15,6 +15,9 @@ PRINTER_MODEL_MAP = {
"Bambu Lab A1": "A1",
"Bambu Lab A1 Mini": "A1 Mini",
"Bambu Lab A1 mini": "A1 Mini",
# Bambu cloud rolled out a terse model-code rename mid-2026 (#1649);
# 3MFs prepared with newer cloud presets may carry this short form.
"Bambu Lab A1M": "A1 Mini",
"Bambu Lab H2D": "H2D",
"Bambu Lab H2D Pro": "H2D Pro",
"Bambu Lab H2C": "H2C",
@@ -323,6 +323,37 @@ describe('ConfigureAmsSlotModal', () => {
expect(screen.queryByText(/Bambu PLA Basic @BBL X1C/)).not.toBeInTheDocument();
});
it('treats Bambu cloud rename @BBL A1M as a match for A1 Mini (#1649)', async () => {
// Bambu cloud shifted A1 Mini filament profiles from
// "Bambu PLA Basic @BBL A1 Mini ..." to the terse "@BBL A1M" mid-2026.
// Without an alias-aware compare, the model filter strips every cloud
// profile from the picker when the user selects an A1 Mini printer.
(api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
filament: [
{ setting_id: 'GFA00_A1M', name: 'Bambu PLA Basic @BBL A1M', filament_id: 'GFA00' },
{ setting_id: 'GFA00_A1', name: 'Bambu PLA Basic @BBL A1', filament_id: 'GFA00' },
],
});
render(<ConfigureAmsSlotModal {...defaultProps} printerModel="A1 Mini" />);
await waitFor(() => {
expect(screen.getByText('Bambu PLA Basic @BBL A1M')).toBeInTheDocument();
});
// The A1 (non-mini) preset must still be filtered out — the alias
// table must not collapse two physically distinct printers.
expect(screen.queryByText('Bambu PLA Basic @BBL A1')).not.toBeInTheDocument();
});
it('still filters cross-model cloud profiles when the printer is A1 Mini', async () => {
// Sanity check that the alias addition didn't accidentally widen the
// matcher: an X1C cloud preset stays hidden when the picker is for an
// A1 Mini printer.
render(<ConfigureAmsSlotModal {...defaultProps} printerModel="A1 Mini" />);
await waitFor(() => {
expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
});
expect(screen.queryByText('Bambu PLA Basic @BBL X1C')).not.toBeInTheDocument();
});
it('shows current preset even when it does not match model filter', async () => {
// Render with printerModel="H2D" but savedPresetId pointing to the X1C preset
const slotInfo = {
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
buildCompatibilityIndex,
matchesPrinterModelSuffix,
presetCompatibility,
EMPTY_COMPATIBILITY_INDEX,
type CompatibilityBundle,
@@ -425,3 +426,63 @@ describe('presetCompatibility — nozzle filtering on @BBL name fallback', () =>
).toBe('mismatch');
});
});
describe('matchesPrinterModelSuffix (#1649)', () => {
it('matches the canonical short code against itself', () => {
expect(matchesPrinterModelSuffix('X1C', 'X1C')).toBe(true);
});
it('is case-insensitive on both sides', () => {
expect(matchesPrinterModelSuffix('x1c', 'X1C')).toBe(true);
expect(matchesPrinterModelSuffix('a1 mini', 'A1 Mini')).toBe(true);
});
it('matches Bambu cloud rename A1M against the long form A1 Mini', () => {
expect(matchesPrinterModelSuffix('A1M', 'A1 Mini')).toBe(true);
});
it('matches the long form A1 Mini against the short Bambu cloud code A1M', () => {
expect(matchesPrinterModelSuffix('A1 Mini', 'A1M')).toBe(true);
});
it('does NOT match A1M against A1 (different printer, must not collapse)', () => {
expect(matchesPrinterModelSuffix('A1M', 'A1')).toBe(false);
});
it('does NOT match A1 against A1 Mini', () => {
expect(matchesPrinterModelSuffix('A1', 'A1 Mini')).toBe(false);
});
it('does NOT match unrelated models', () => {
expect(matchesPrinterModelSuffix('X1C', 'P1S')).toBe(false);
});
});
describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => {
const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle';
const A1 = 'Bambu Lab A1 0.4 nozzle';
const idx = buildCompatibilityIndex([], PRINTER_MODELS);
it('matches a cloud preset using the new @BBL A1M suffix against an A1 Mini printer', () => {
// The slicer-mirrored case: technopaw's report — A1 Mini cloud presets
// newly ship as "Bambu PLA Basic @BBL A1M ..." and used to be filtered
// out by the model check that compared "A1M" vs "A1 mini" verbatim.
expect(
presetCompatibility({ name: 'Bambu PLA Basic @BBL A1M' }, 'filament', A1_MINI, idx),
).toBe('match');
});
it('matches a 0.4-nozzle process with @BBL A1M against an A1 Mini printer', () => {
expect(
presetCompatibility({ name: '0.20mm Standard @BBL A1M' }, 'process', A1_MINI, idx),
).toBe('match');
});
it('does NOT match @BBL A1M against an A1 (non-mini) printer', () => {
// The alias must not collapse two physically different printers — A1
// and A1 Mini ship distinct profile sets.
expect(
presetCompatibility({ name: '0.20mm Standard @BBL A1M' }, 'process', A1, idx),
).toBe('mismatch');
});
});
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
import { api } from '../api/client';
import type { KProfile } from '../api/client';
import { matchesPrinterModelSuffix } from '../utils/slicerPrinterMatch';
import { Button } from './Button';
interface SlotInfo {
@@ -583,7 +584,7 @@ export function ConfigureAmsSlotModal({
if (query && !op.name.toLowerCase().includes(query)) continue;
if (printerModel) {
const presetModel = extractPresetModel(op.name);
if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;
if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
}
// All Orca Cloud profiles are user-authored, so isUser is always true.
items.push({ id: orcaId, name: op.name, source: 'orca_cloud', isUser: true });
@@ -601,10 +602,12 @@ export function ConfigureAmsSlotModal({
|| (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
// Search filter applies to ALL presets (including saved) — no bypass
if (query && !cp.name.toLowerCase().includes(query)) continue;
// Filter by printer model if set (skip for current preset)
// Filter by printer model if set (skip for current preset). Uses the
// alias-aware match so Bambu's "A1 Mini" → "A1M" cloud rename (#1649)
// doesn't hide A1 Mini cloud profiles.
if (!isCurrentPreset && printerModel) {
const presetModel = extractPresetModel(cp.name);
if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;
if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
}
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
}
+37 -1
View File
@@ -62,6 +62,35 @@ function normalizePresetName(name: string): string {
return name.replace(/^#\s*/, '').trim();
}
// Bambu cloud started shipping terse model codes in `@BBL <code>` suffixes
// mid-2026 — the most visible one is "A1 Mini" → "A1M" (#1649, reported by
// @technopaw). User-authored profiles still use the long display name, so
// both shapes have to match the same printer. The table is uppercase-normalised
// for case-insensitive lookups; add a row when a future rename is spotted via
// `/api/v1/cloud/settings`. Keep narrow on purpose — wide-net aliasing
// (e.g. "X1" ⇄ "X1C") would silently group truly distinct printers.
const PRINTER_MODEL_SUFFIX_ALIASES: Record<string, readonly string[]> = {
'A1 MINI': ['A1M'],
};
/**
* True when ``presetSuffix`` (the token extracted from a "@BBL <code>" or
* preset-name suffix) refers to the same printer as ``printerModel``
* (the display name selected in the picker). Case-insensitive; consults
* the alias table for short codes Bambu introduced after the long forms
* shipped (#1649).
*/
export function matchesPrinterModelSuffix(presetSuffix: string, printerModel: string): boolean {
const p = presetSuffix.toUpperCase();
const m = printerModel.toUpperCase();
if (p === m) return true;
const aliasesOfM = PRINTER_MODEL_SUFFIX_ALIASES[m];
if (aliasesOfM && aliasesOfM.includes(p)) return true;
const aliasesOfP = PRINTER_MODEL_SUFFIX_ALIASES[p];
if (aliasesOfP && aliasesOfP.includes(m)) return true;
return false;
}
/**
* 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
@@ -187,7 +216,14 @@ function classifyByBambuName(
const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token;
const selectedParts = extractPrinterPresetModel(selectedPrinterName);
if (!selectedParts) return 'unknown';
if (normalizeModelFragment(selectedParts.model) !== normalizeModelFragment(inferredModel)) {
// The raw inferred model and the printer-preset fragment may differ only by
// the Bambu short-code rename (e.g. preset token "A1M" vs printer "A1 Mini").
// ``matchesPrinterModelSuffix`` consults the alias table before declaring a
// mismatch — see #1649.
if (
normalizeModelFragment(selectedParts.model) !== normalizeModelFragment(inferredModel)
&& !matchesPrinterModelSuffix(parsed.token, selectedParts.model)
) {
return 'mismatch';
}
// Nozzle compare — only when we have a usable size from the printer
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-DMeff9V8.js"></script>
<script type="module" crossorigin src="/assets/index-BCHcur4g.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DgecYhis.css">
</head>
<body>