fix(slicer): classify filament profiles by their real printer scope, not just their name (#2628 follow-up)

Slicing for a P2S failed with "filament preset Bambu PLA Basic @BBL X1C 0.2
nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle" —
naming a profile shown nowhere in the dialog. The picked profile was
"Overture PLA Matte @0.2", whose inheritance chain roots in that X1C profile.

The dialog classifies a profile by its compatible_printers list and falls back
to reading the printer out of its name. That name carries no model, and the
list — present on the imported copy — is not shipped by every source: Bambu
Cloud omits it deliberately (rate limits), and Orca Cloud shipped it but
Bambuddy only mined filament type and colour from the same content.

Orca Cloud entries now carry their own compatible_printers, and the existing
same-name enrichment bridge carries the list onto entries that lack one, in
both directions between the cloud tiers. A bare "@<size>" name tag is read as
a nozzle size as a last resort: it can rule a printer out but never rules one
in, and implausible values are ignored rather than guessed at.
This commit is contained in:
maziggy
2026-07-22 10:44:05 +02:00
parent 56accd24de
commit fdd6ec416f
11 changed files with 767 additions and 187 deletions
@@ -1348,3 +1348,83 @@ describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
});
});
describe('pickFilamentForSlot — long-form printer tag (#2628)', () => {
const index = buildCompatibilityIndex({
'Bambu Lab A1': 'A1',
'Bambu Lab H2D': 'H2D',
});
it('never auto-picks a user-saved preset scoped to another printer', () => {
// michaelklos's registry: a cloud-tier user preset carrying the full
// "@Bambu Lab H2D 0.4 nozzle" tag outscores the A1 preset on tier bonus
// alone. Until the matcher learned the long form it classified 'unknown'
// — indistinguishable from compatible — so it won the slot, landed in a
// dropdown the modal disables (slot not used by the plate), and the CLI
// rejected the whole slice.
const presets = makeUnified({
cloud: {
printer: [],
process: [],
filament: [
{
id: 'sunlu-tpu-h2d',
name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
source: 'cloud',
filament_type: 'PLA',
filament_colour: '#FF0000',
},
],
},
standard: {
printer: [],
process: [],
filament: [
{
id: 'Bambu PLA Basic @BBL A1',
name: 'Bambu PLA Basic @BBL A1',
source: 'standard',
filament_type: 'PLA',
filament_colour: '#FFFFFF',
},
],
},
});
const pick = pickFilamentForSlot(
presets,
{ type: 'PLA', color: '#FF0000' },
'Bambu Lab A1 0.4 nozzle',
index,
);
expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
});
it('still picks a long-form preset for its own printer', () => {
const presets = makeUnified({
cloud: {
printer: [],
process: [],
filament: [
{
id: 'sunlu-tpu-h2d',
name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
source: 'cloud',
filament_type: 'TPU',
filament_colour: '#FF0000',
},
],
},
});
const pick = pickFilamentForSlot(
presets,
{ type: 'TPU', color: '#FF0000' },
'Bambu Lab H2D 0.4 nozzle',
index,
);
expect(pick).toEqual({ source: 'cloud', id: 'sunlu-tpu-h2d' });
});
});
@@ -361,3 +361,127 @@ describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => {
).toBe('mismatch');
});
});
describe('presetCompatibility — long-form @Bambu Lab printer tag (#2628)', () => {
const A1 = 'Bambu Lab A1 0.4 nozzle';
const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle';
const H2D = 'Bambu Lab H2D 0.4 nozzle';
const idx = buildCompatibilityIndex(PRINTER_MODELS);
it('flags a user-saved H2D filament preset as a mismatch on an A1', () => {
// michaelklos's report: this exact preset sat in an unused filament slot
// of a multi-plate 3MF. Classified 'unknown' it was treated as usable,
// auto-picked, and the CLI rejected the slice with "filament preset
// (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle".
expect(
presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx),
).toBe('mismatch');
});
it('matches the same preset against its own printer', () => {
expect(
presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx),
).toBe('match');
});
it('resolves a long-form model whose display name differs from its short code', () => {
const name = 'My PETG @Bambu Lab X1 Carbon 0.4 nozzle';
expect(presetCompatibility({ name }, 'filament', X1C, idx)).toBe('match');
expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch');
});
it('applies the nozzle filter to the long form too', () => {
expect(
presetCompatibility({ name: 'My PETG @Bambu Lab A1 0.6 nozzle' }, 'filament', A1, idx),
).toBe('mismatch');
});
it('ignores a trailing "(Custom)" suffix rather than mangling the model token', () => {
// The slicer appends this to user-saved presets. Parsed naively the tag
// becomes "H2D 0.4 nozzle (Custom)" — which would brand the preset a
// mismatch against its own printer and hide it from the dropdown.
const name = 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)';
expect(presetCompatibility({ name }, 'filament', H2D, idx)).toBe('match');
expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch');
});
it('keeps the A1M alias working through the long form', () => {
expect(
presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1_MINI, idx),
).toBe('match');
expect(
presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1, idx),
).toBe('mismatch');
});
it('stays unknown for an @-tag that names no recognisable printer', () => {
// "@Voron 0.4 nozzle" is not a Bambu printer preset — the matcher must
// not guess, so the preset keeps its place in the main dropdown list.
expect(
presetCompatibility({ name: 'My PLA @Voron 0.4 nozzle' }, 'filament', A1, idx),
).toBe('unknown');
});
it('reads the tag from the last @ so a stray earlier one cannot swallow it', () => {
expect(
presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx),
).toBe('mismatch');
expect(
presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx),
).toBe('match');
});
});
describe('presetCompatibility — nozzle-only @<size> tag (#2628 follow-up)', () => {
const P2S_04 = 'Bambu Lab P2S 0.4 nozzle';
const X1C_02 = 'Bambu Lab X1 Carbon 0.2 nozzle';
const idx = buildCompatibilityIndex(PRINTER_MODELS);
it('rules out a 0.2-nozzle profile on a 0.4-nozzle printer', () => {
// The live case: "Overture PLA Matte @0.2" inherits from the X1C 0.2
// nozzle system profile, so the slicer rejected a P2S 0.4 slice with
// "not compatible with printer Bambu Lab P2S 0.4 nozzle". The name
// carries no model, so this size is the only signal available.
expect(
presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', P2S_04, idx),
).toBe('mismatch');
});
it('stays unknown when the size agrees — a size is not a model', () => {
// The profile might belong to a different printer with the same nozzle;
// promoting this to 'match' would claim knowledge we don't have.
expect(
presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', X1C_02, idx),
).toBe('unknown');
});
it('accepts the "0.2 nozzle" and "0.2mm" spellings of the same tag', () => {
for (const name of ['My PLA @0.2 nozzle', 'My PLA @0.2mm']) {
expect(presetCompatibility({ name }, 'filament', P2S_04, idx)).toBe('mismatch');
}
});
it('compares sizes numerically so 0.20 and 0.2 are one size', () => {
expect(
presetCompatibility({ name: 'My PLA @0.20' }, 'filament', X1C_02, idx),
).toBe('unknown');
});
it('ignores a numeric tag that cannot be a nozzle', () => {
// "@2026" is a year, not a 2026 mm nozzle — guessing here would brand
// the profile incompatible with every printer that exists.
expect(presetCompatibility({ name: 'My PLA @2026' }, 'filament', P2S_04, idx)).toBe('unknown');
expect(presetCompatibility({ name: 'My PLA @0.05' }, 'filament', P2S_04, idx)).toBe('unknown');
});
it('leaves a model-bearing tag on the model path', () => {
// Regression guard: the nozzle-only branch must not swallow the forms
// that carry a model — those still resolve to match/mismatch.
expect(
presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S_04, idx),
).toBe('match');
expect(
presetCompatibility({ name: 'My PLA @Bambu Lab X1 Carbon 0.4 nozzle' }, 'filament', P2S_04, idx),
).toBe('mismatch');
});
});
+87 -10
View File
@@ -6,9 +6,11 @@
//
// 1. Imported (local-tier) presets carry the slicer's own
// `compatible_printers` list — an exact list of printer-preset names.
// 2. BambuStudio's own `@BBL <model>` naming convention on shipped cloud
// / standard presets. The token → printer-fragment table is derived
// from the backend's canonical PRINTER_MODEL_MAP (fetched via
// 2. The `@<printer>` naming convention, in both shapes the slicer
// writes: `@BBL <model>` on shipped cloud / standard presets, and
// `@Bambu Lab <model> <size> nozzle` on presets a user saved for a
// specific printer (#2628). 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 rule covers
@@ -136,10 +138,72 @@ function extractPrinterPresetModel(printerPresetName: string): { model: string;
return stripped ? { model: stripped, nozzle } : null;
}
// Trailing parenthetical the slicer appends to user-saved presets —
// "… @Bambu Lab H2D 0.4 nozzle (Custom)". Dropped before the nozzle suffix
// is parsed, or the tag would resolve to a nonsense model token and the
// preset would be branded a mismatch against its OWN printer.
function stripTrailingParenthetical(s: string): string {
return s.replace(/\s*\([^)]*\)\s*$/, '').trim();
}
// Nozzle sizes Bambu ships run 0.2 – 0.8. The range guard keeps a tag that
// merely looks numeric ("PLA @2026") from being read as a nozzle and branded
// incompatible with every printer.
const MIN_NOZZLE_MM = 0.1;
const MAX_NOZZLE_MM = 2.0;
// Compare two nozzle strings numerically, so "0.20" and "0.2" are the same
// size. Unparseable values never match — a size we can't read is not evidence.
function sameNozzle(a: string, b: string): boolean {
const x = Number.parseFloat(a);
const y = Number.parseFloat(b);
if (Number.isNaN(x) || Number.isNaN(y)) return false;
return x === y;
}
// Pull the model token and nozzle out of a preset name's printer tag.
// Three shapes exist in the wild (#2628):
//
// "0.20mm Standard @BBL X1C" — short code, the form
// Bambu ships its own cloud / standard presets under.
// "SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle" — the full printer-preset
// name, the form the slicer writes when a user saves their own preset
// for a printer. Handling only the short form left these classified
// 'unknown', so an H2D-scoped filament was offered (and auto-picked)
// for an A1 slice, which the CLI then rejected.
// "Overture PLA Matte @0.2" — nozzle only, no model.
// Returned with a null token: the size can rule a printer OUT, but
// says nothing about which models the profile belongs to.
//
// The first two shapes are also parsed in ConfigureAmsSlotModal (#1623).
function extractPrinterTag(presetName: string): { token: string | null; nozzle: string | null } | null {
const cleaned = stripTrailingParenthetical(presetName);
const bbl = extractBblToken(cleaned);
if (bbl) return bbl;
// The printer tag is a suffix by convention, so read from the LAST '@' —
// a stray earlier one ("My @work PLA @Bambu Lab H2D 0.4 nozzle") must not
// swallow it. Anything that doesn't parse as a Bambu printer preset name
// falls through to 'unknown', never to a guessed mismatch.
const at = cleaned.lastIndexOf('@');
if (at < 0) return null;
const suffix = cleaned.slice(at + 1).trim();
const longForm = extractPrinterPresetModel(suffix);
if (longForm) return { token: longForm.model, nozzle: longForm.nozzle };
const nozzleOnly = suffix.match(/^([\d.]+)\s*(?:mm)?\s*(?:nozzle)?$/i);
if (nozzleOnly) {
const size = Number.parseFloat(nozzleOnly[1]);
if (!Number.isNaN(size) && size >= MIN_NOZZLE_MM && size <= MAX_NOZZLE_MM) {
return { token: null, nozzle: nozzleOnly[1] };
}
}
return null;
}
/**
* Name-based fallback for presets BambuStudio ships with a `@BBL <model>`
* tag (#1325 follow-up). Used only after `compatible_printers` has returned
* `'unknown'`.
* Name-based fallback for presets carrying a printer tag — BambuStudio's own
* `@BBL <model>` (#1325 follow-up), the full `@Bambu Lab <model> <size>
* nozzle` form user-saved presets get, or a bare `@<size>` (#2628).
* Used only after `compatible_printers` has returned `'unknown'`.
*
* Compares BOTH model AND nozzle. The nozzle filter is required because
* Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 /
@@ -152,8 +216,23 @@ function classifyByBambuName(
selectedPrinterName: string,
bambuModelByShortCode: Record<string, string>,
): PrinterCompatibility {
const parsed = extractBblToken(presetName);
const parsed = extractPrinterTag(presetName);
if (!parsed) return 'unknown';
const selectedParts = extractPrinterPresetModel(selectedPrinterName);
if (!selectedParts) return 'unknown';
if (parsed.token === null) {
// Nozzle-only tag ("Overture PLA Matte @0.2"). The size can rule a
// printer OUT, but a matching size proves nothing about the model, so
// the best this can ever return is 'unknown' — never 'match'.
if (
selectedParts.nozzle !== null
&& parsed.nozzle !== null
&& !sameNozzle(parsed.nozzle, selectedParts.nozzle)
) {
return 'mismatch';
}
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
@@ -162,8 +241,6 @@ function classifyByBambuName(
// 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[parsed.token] ?? parsed.token;
const selectedParts = extractPrinterPresetModel(selectedPrinterName);
if (!selectedParts) return 'unknown';
// 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
@@ -180,7 +257,7 @@ function classifyByBambuName(
// or non-Bambu printer names that happened to match the model.
if (selectedParts.nozzle !== null) {
const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE;
if (presetNozzle !== selectedParts.nozzle) return 'mismatch';
if (!sameNozzle(presetNozzle, selectedParts.nozzle)) return 'mismatch';
}
return 'match';
}