mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
fix(ams): filter Configure AMS Slot profile list by printer model (#1623)
The Filament Profile picker in the Configure AMS Slot modal listed
profiles for every printer the user had ever imported / cloud-synced.
On H2D the reporter saw local "Custom" PETG/PLA for A1 mini and P1S
alongside Bambu Cloud and Orca Cloud profiles named "X1C eSUN PETG-
Basic Filament" - none of them usable on the slot they were configuring.
Three filter gaps in the same picker:
- Local "Custom" imported profiles were unconditionally listed. Now
parse their compatible_printers JSON and run presetCompatibility()
against the slot's full slicer preset name ("Bambu Lab H2D 0.4
nozzle") derived from the printer-model registry + slot nozzle.
Hide on 'mismatch'; 'match' and 'unknown' keep showing (back-compat
for hand-edited imports without compatible_printers).
- Cloud presets with the "@Bambu Lab <long-name>" suffix form (user-
renamed Bambu Cloud presets, most Orca Cloud profiles) slipped
through the existing "@BBL <code>" matcher. extractPresetModel now
handles both, with case-insensitive registry reverse-lookup so
"A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini
profiles (#1649 alias match preserved).
- Cloud presets with the model in the BODY of the name and no @ suffix
("X1C eSUN PETG-Basic Filament") returned null from the extractor.
Added a body-text scan against every known model token from the
registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro"
aren't eaten by their shorter siblings, word-boundary regex so
"PA1" doesn't false-match "A1".
Fail-open posture preserved: registry not loaded or printerModel empty
no-ops every filter; saved preset bypass keeps the active selection
visible; built-in filaments stay unfiltered (generic fallback); free-
form names with no recognisable token still show.
This commit is contained in:
@@ -4,7 +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 { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
|
||||
import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
|
||||
import { Button } from './Button';
|
||||
|
||||
@@ -230,13 +230,80 @@ function colorNameToHex(name: string): string | null {
|
||||
return COLOR_NAME_MAP[normalized] || null;
|
||||
}
|
||||
|
||||
// Extract printer model from preset name suffix "@BBL X1C 0.4 nozzle" → "X1C"
|
||||
function extractPresetModel(name: string): string | null {
|
||||
// Escape regex metacharacters and turn whitespace into ``\s+`` so a literal
|
||||
// model token compiles to a flexible-whitespace word-boundary regex.
|
||||
function _tokenToRegex(token: string): RegExp {
|
||||
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
||||
return new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
}
|
||||
|
||||
// Extract printer model from a preset name → normalized short code
|
||||
// (e.g. "X1C", "H2D"). Two strategies in order:
|
||||
//
|
||||
// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
|
||||
// - "@BBL X1C 0.4 nozzle" → "X1C" (short-code form,
|
||||
// Bambu Cloud system presets)
|
||||
// - "@Bambu Lab X1 Carbon 0.4 nozzle" → "X1C" (long-form, used by
|
||||
// user-renamed Bambu Cloud presets and most Orca Cloud profiles —
|
||||
// reverse-looked-up via the backend printer-model registry)
|
||||
//
|
||||
// (2) Body scan — many user-authored / Orca Cloud presets put the printer
|
||||
// model at the START of the name with no @ suffix at all (the literal
|
||||
// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
|
||||
// name for any known model token (every long-name fragment + every short
|
||||
// code from the registry) and return the first match. Long-first sort
|
||||
// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
|
||||
// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
|
||||
// false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
|
||||
// "X1Box" doesn't match "X1").
|
||||
//
|
||||
// Returns null when neither strategy resolves; the caller keeps such
|
||||
// presets visible (can't filter what we can't classify).
|
||||
//
|
||||
// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
|
||||
// keys are "Bambu Lab <long>", values are short codes.
|
||||
function extractPresetModel(
|
||||
name: string,
|
||||
printerModelsLongToShort: Record<string, string>,
|
||||
): string | null {
|
||||
const atIdx = name.indexOf('@');
|
||||
if (atIdx < 0) return null;
|
||||
const suffix = name.slice(atIdx + 1).trim();
|
||||
const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
|
||||
if (bblMatch) return bblMatch[1].trim();
|
||||
if (atIdx >= 0) {
|
||||
const suffix = name.slice(atIdx + 1).trim();
|
||||
const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
|
||||
if (bblMatch) return bblMatch[1].trim();
|
||||
const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
|
||||
if (longMatch) {
|
||||
const longFragment = longMatch[1].trim();
|
||||
const fullKey = `Bambu Lab ${longFragment}`;
|
||||
if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
|
||||
const lower = fullKey.toLowerCase();
|
||||
for (const [k, v] of Object.entries(printerModelsLongToShort)) {
|
||||
if (k.toLowerCase() === lower) return v;
|
||||
}
|
||||
return longFragment;
|
||||
}
|
||||
}
|
||||
|
||||
// Body scan — accumulate {token, short} pairs and try long-first.
|
||||
const tokens: Array<{ token: string; short: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
|
||||
const fragment = longName.replace(/^Bambu Lab\s+/, '');
|
||||
const key = fragment.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
tokens.push({ token: fragment, short });
|
||||
seen.add(key);
|
||||
}
|
||||
const shortKey = short.toLowerCase();
|
||||
if (!seen.has(shortKey)) {
|
||||
tokens.push({ token: short, short });
|
||||
seen.add(shortKey);
|
||||
}
|
||||
}
|
||||
tokens.sort((a, b) => b.token.length - a.token.length);
|
||||
for (const { token, short } of tokens) {
|
||||
if (_tokenToRegex(token).test(name)) return short;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -309,6 +376,39 @@ export function ConfigureAmsSlotModal({
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Backend Bambu printer-model registry — drives the @BBL short-code matcher
|
||||
// and (here) the reverse short-code → long-name lookup that lets us check
|
||||
// imported local presets' `compatible_printers` list against the slot's
|
||||
// printer (#1623). Long staleTime: the registry only changes across backend
|
||||
// releases.
|
||||
const { data: printerModelsData } = useQuery({
|
||||
queryKey: ['slicerPrinterModels'],
|
||||
queryFn: api.getSlicerPrinterModels,
|
||||
enabled: isOpen,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const compatIndex = useMemo(
|
||||
() => buildCompatibilityIndex(printerModelsData ?? {}),
|
||||
[printerModelsData],
|
||||
);
|
||||
|
||||
// The full printer-preset name for this slot's printer — e.g. the short
|
||||
// code "X1C" resolves to "Bambu Lab X1 Carbon 0.4 nozzle" via the backend
|
||||
// registry plus the slot's nozzle. Used to filter imported local presets
|
||||
// whose `compatible_printers` list is keyed by the full slicer preset name.
|
||||
// Null when the registry hasn't loaded or no model is known — caller skips
|
||||
// the filter in that case (fail-open, same shape as the cloud-preset filter).
|
||||
const fullPrinterName = useMemo<string | null>(() => {
|
||||
if (!printerModel || !printerModelsData) return null;
|
||||
for (const [longName, shortCode] of Object.entries(printerModelsData)) {
|
||||
if (shortCode === printerModel) {
|
||||
return `${longName.startsWith('Bambu Lab ') ? longName : `Bambu Lab ${longName}`} ${nozzleDiameter} nozzle`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [printerModel, printerModelsData, nozzleDiameter]);
|
||||
|
||||
// Configure slot mutation
|
||||
const configureMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -584,7 +684,7 @@ export function ConfigureAmsSlotModal({
|
||||
coveredIds.add(orcaId);
|
||||
if (query && !op.name.toLowerCase().includes(query)) continue;
|
||||
if (printerModel) {
|
||||
const presetModel = extractPresetModel(op.name);
|
||||
const presetModel = extractPresetModel(op.name, printerModelsData ?? {});
|
||||
if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
|
||||
}
|
||||
// All Orca Cloud profiles are user-authored, so isUser is always true.
|
||||
@@ -607,18 +707,45 @@ export function ConfigureAmsSlotModal({
|
||||
// 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);
|
||||
const presetModel = extractPresetModel(cp.name, printerModelsData ?? {});
|
||||
if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
|
||||
}
|
||||
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Local presets (always shown — user-imported profiles work on any printer)
|
||||
// 2. Local presets — filter by the slicer's own ``compatible_printers``
|
||||
// list when present (#1623). LocalPreset.compatible_printers is a JSON-
|
||||
// encoded string array; presetCompatibility returns 'mismatch' when the
|
||||
// list is set and our derived full printer name isn't in it, 'unknown'
|
||||
// when neither the list nor an @BBL token is parseable — we hide on
|
||||
// 'mismatch' only so user-imported presets without compatible_printers
|
||||
// still surface (back-compat with the prior "always show" behaviour for
|
||||
// hand-edited / lossily-imported presets).
|
||||
if (localPresets?.filament) {
|
||||
const savedLocalId = slotInfo.savedPresetId;
|
||||
for (const lp of localPresets.filament) {
|
||||
const localId = `local_${lp.id}`;
|
||||
if (query && !lp.name.toLowerCase().includes(query)) continue;
|
||||
const isCurrentPreset = savedLocalId === localId;
|
||||
if (!isCurrentPreset && fullPrinterName) {
|
||||
let compatList: string[] | null = null;
|
||||
if (lp.compatible_printers) {
|
||||
try {
|
||||
const parsed = JSON.parse(lp.compatible_printers);
|
||||
if (Array.isArray(parsed)) compatList = parsed.filter((s): s is string => typeof s === 'string');
|
||||
} catch {
|
||||
compatList = null;
|
||||
}
|
||||
}
|
||||
const verdict = presetCompatibility(
|
||||
{ name: lp.name, compatible_printers: compatList },
|
||||
'filament',
|
||||
fullPrinterName,
|
||||
compatIndex,
|
||||
);
|
||||
if (verdict === 'mismatch') continue;
|
||||
}
|
||||
items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
|
||||
}
|
||||
}
|
||||
@@ -649,7 +776,7 @@ export function ConfigureAmsSlotModal({
|
||||
if (!a.isUser && b.isUser) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
|
||||
}, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx, fullPrinterName, compatIndex, printerModelsData]);
|
||||
|
||||
// Get full preset name for K profile filtering (brand + material, without printer suffix)
|
||||
const selectedPresetInfo = useMemo(() => {
|
||||
|
||||
Reference in New Issue
Block a user