Files
bambuddy/frontend/src/utils/slicerPrinterMatch.ts
T
maziggy 1c42a9f1fd remove(slicer): drop bundle import; fix cloud preset type/from for CLI (#1712)
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
  strips system processes/filaments, so importing a bundle left users without
  process presets and slicing fell back to embedded settings on STL. Bundle
  mode also hid the standard tier behind a constrained dropdown, the actual
  trap reported here.

  Removed end-to-end:
  - backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
    SliceBundleSpec, dispatch fork in library.py, bundle-context params on
    the filament-requirements endpoints, bundle-fingerprint cache key in
    slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
    slice_with_bundle, BundleSummary / BundleNotFoundError.
  - frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
    branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
    SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
    loses its bundle path; presetCompatibility keeps compatible_printers
    plus the @BBL fallback.
  - SlicerBundlesPanel turns into a permanent static notice explaining the
    removal, alternative import paths, and the new slice-time lookup order
    (Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
  - i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
    translated across all 11 locales; slice.bundle*, slicerBundles.* keys
    removed.

  Fixed (surfaced by removing bundle mode):
  - _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
    from: "system" on the payload before json.dumps. Bambu Cloud ships
    type as "printer"/"print" and routinely empty `from`; the BS CLI's
    --load-settings parser rejects both with return -5 / "input preset
    file invalid". Standard tier already did this; cloud paths now match.
2026-06-12 10:14:06 +02:00

214 lines
9.5 KiB
TypeScript

// Printer-compatibility matching for the SliceModal's process / filament
// dropdowns (#1325).
//
// Compatibility is resolved in this order, stopping on the first non-unknown
// answer:
//
// 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
// /slicer/printer-models), not duplicated here.
//
// 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*
// printer is pushed into an "Other printers" group.
export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
// Lookup tables consumed by `presetCompatibility`. `bambuModelByShortCode`
// is the @BBL token → printer-preset fragment map derived from the backend's
// PRINTER_MODEL_MAP — e.g. `X1C` → `X1 Carbon`. An empty map means the @BBL
// fallback still works when token and printer-name fragment match directly
// (raw-token comparison), and gracefully degrades otherwise.
export interface PrinterCompatibilityIndex {
bambuModelByShortCode: Record<string, string>;
}
/** An empty index — used when the model map hasn't loaded yet. */
export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
bambuModelByShortCode: {},
};
// 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
* 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 backend printer-model registry.
*/
export function buildCompatibilityIndex(
printerModels: Record<string, string> = {},
): PrinterCompatibilityIndex {
return {
bambuModelByShortCode: buildShortCodeMap(printerModels),
};
}
function normalizeModelFragment(s: string): string {
return s.replace(/\s+/g, '').toLowerCase();
}
// Bambu Studio's naming convention for bundled presets: the 0.4 nozzle is
// the default and its variants drop the nozzle suffix; 0.2 / 0.6 / 0.8
// carry an explicit "<size> nozzle" segment. So a process with no suffix
// is implicitly a 0.4 process — required to compare correctly against a
// 0.4 printer preset, which DOES carry the suffix.
const DEFAULT_NOZZLE = '0.4';
// Strip a trailing "<size> nozzle" segment, returning the nozzle string
// (e.g. "0.6") or null when absent. Used by both BBL-token and printer-
// preset extractors so the suffix is parsed identically on both sides.
function takeNozzleSuffix(s: string): { stripped: string; nozzle: string | null } {
const m = s.match(/^(.*?)\s+([\d.]+)\s*nozzle\s*$/i);
if (!m) return { stripped: s.trim(), nozzle: null };
return { stripped: m[1].trim(), nozzle: m[2] };
}
// Pull the model token and nozzle out of a "@BBL <token> [<size> nozzle]"
// suffix. The token may contain a space (e.g. "A1 mini"), so we strip a
// trailing nozzle segment rather than splitting on the first whitespace.
function extractBblToken(presetName: string): { token: string; nozzle: string | null } | null {
const marker = '@BBL ';
const idx = presetName.indexOf(marker);
if (idx < 0) return null;
const rest = presetName.slice(idx + marker.length).trim();
const { stripped, nozzle } = takeNozzleSuffix(rest);
return stripped ? { token: stripped, nozzle } : null;
}
// Pull the model fragment and nozzle out of a "Bambu Lab <model> [<size>
// nozzle]" printer preset name. Returns null for non-Bambu printer
// presets — there is no reliable name-based match against those.
function extractPrinterPresetModel(printerPresetName: string): { model: string; nozzle: string | null } | null {
const m = printerPresetName.match(/^Bambu Lab\s+(.+)$/i);
if (!m) return null;
const { stripped, nozzle } = takeNozzleSuffix(m[1]);
return stripped ? { model: stripped, nozzle } : null;
}
/**
* Name-based fallback for presets BambuStudio ships with a `@BBL <model>`
* tag (#1325 follow-up). 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 /
* 0.8) — a 0.6-nozzle process is unusable on a 0.4-nozzle printer.
* 0.4 is Bambu's default and its variants drop the nozzle suffix, so a
* preset with no suffix counts as 0.4.
*/
function classifyByBambuName(
presetName: string,
selectedPrinterName: string,
bambuModelByShortCode: Record<string, string>,
): PrinterCompatibility {
const parsed = extractBblToken(presetName);
if (!parsed) 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[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
// 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
// side. A Bambu printer preset always carries one, so this branch is
// taken in practice; the null path is defensive degrade for hand-typed
// 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';
}
return 'match';
}
/**
* 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 `compatible_printers`,
* no recognizable `@BBL` tag, 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,
index: PrinterCompatibilityIndex,
): PrinterCompatibility {
if (!selectedPrinterName) return 'unknown';
// (1) Imported presets carry the slicer's own compatible_printers list —
// authoritative when set.
const compat = preset.compatible_printers;
if (compat && compat.length > 0) {
return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
}
// (2) BambuStudio's `@BBL <model>` name convention — covers cloud /
// standard presets that don't carry compatible_printers.
return classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
}