fix(k-profile): match by filament_id, surface active profile in Configure Slot (#1688 + #1689)

Two related bugs in K-profile matching, same root cause.

  #1688 — spool form's PA-profile suggester (PAProfileSection via
  isMatchingCalibration in spool-form/utils.ts) matched K-profiles by
  parsing the profile NAME for material/brand/variant. Spools already
  store slicer_filament (the slicer preset id) and K-profiles already
  carry filament_id, but both were ignored — so a user's custom
  K-profile whose name doesn't agree with the slicer preset got silently
  dropped from suggestions even when the underlying filament_id was
  identical.

  #1689 — ConfigureAmsSlotModal's matchingKProfiles ran the same
  name-only logic on the slot's selected preset. A spool assigned under
  "Generic PLA" with a custom K-profile actively bound on the printer
  landed in the modal as "K profile not assigned, default 0.020 will
  be used", while the printer-card hover-card correctly showed the
  active profile. Two paths, only one was filtering by name.

  Shared root: spool preset ids and K-profile filament_ids look
  different but are equivalent after normalising. Spools store
  slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is
  the variant suffix, the S infix marks setting_id form); K-profiles
  store filament_id as the bare form ("GFG98"). Plain === doesn't
  work; both need normalising. This conversion already existed in the
  other direction at buildFilamentOptions (filament_id → "GFS" +
  filament_id.slice(2)), so the inverse toFilamentId helper is just
  the matching reverse, not new ground.

  Fix — one shared helper, two surfaces:

  - spool-form/utils.ts: new exports toFilamentId(id) (drops "_NN"
    variant suffix and strips the "S" in "GFS", so GFSG98_09 → GFG98)
    and isGenericFilamentId(id) (flags Bambu's generic GFx99 ids
    which are shared across many filaments and must NOT id-match —
    the name fallback handles those correctly).

  - isMatchingCalibration: gains slicer_filament?: string in formData,
    tries id-match (with generic exclusion) before the existing name
    parse. PAProfileSection already passes the full formData so no
    caller edit needed. Strictly additive precedence.

  - ConfigureAmsSlotModal.selectedPresetInfo: resolves a filamentId
    field (toFilamentId(cp.setting_id) for cloud presets,
    toFilamentId(builtinFilamentId) for builtin; empty for local /
    orca paths which fall through to name match).

  - ConfigureAmsSlotModal.matchingKProfiles: id-match check at the top
    of the per-profile predicate (preferred when both sides agree
    after normalisation), then the existing name-parse logic, then
    ALWAYS unshifts the slot's currently-active K-profile by
    slot_id === slotInfo.caliIdx — gated on activeIdx > 0 (so caliIdx
    0/null doesn't leak unrelated profiles in), extruder-matched when
    slotInfo.extruderId is known. This is Spionkiller01's #1689 patch
    verbatim with the activeIdx > 0 guard added.

  SpoolBuddy: both kiosk K-profile surfaces reuse the shared
  components. SpoolBuddyWriteTagPage renders PAProfileSection;
  SpoolBuddyAmsPage renders ConfigureAmsSlotModal. Verified — fixes
  propagate automatically, no kiosk-specific edits.

  What this does NOT change: spools without slicer_filament, K-profiles
  without filament_id, and generic GFx99 ids all fall through to the
  existing name-based matching path. Strictly additive precedence; no
  input shape that matched under the old logic fails to match under the
  new. The #1053 cloud-preset PFUS* path is preserved because the
  toFilamentId regex /^GFS/ doesn't match a "PFU" prefix.
This commit is contained in:
maziggy
2026-06-09 09:53:28 +02:00
parent e9b6fefe95
commit fdcc063d9f
7 changed files with 815 additions and 462 deletions
@@ -5,6 +5,7 @@ import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'luc
import { api } from '../api/client';
import type { KProfile } from '../api/client';
import { matchesPrinterModelSuffix } from '../utils/slicerPrinterMatch';
import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
import { Button } from './Button';
interface SlotInfo {
@@ -656,15 +657,21 @@ export function ConfigureAmsSlotModal({
// Resolve the name from orca, cloud, local, or builtin presets. The
// Orca branch tolerates both ``orca_<UUID>`` and a bare UUID — see the
// configure-mutation comment for why the raw UUID also reaches us.
// ``filamentId`` is the bare Bambu filament_id (e.g. "GFG98") when the
// preset has one — used to id-match K-profiles directly without name
// parsing (#1688). Empty string for paths with no usable filament_id
// (orca presets, local presets), which makes the id-match branch skip.
let presetName: string | null = null;
let filamentId = '';
if (selectedPresetId.startsWith('local_')) {
const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
const lp = localPresets?.filament.find(p => p.id === localId);
presetName = lp?.name || null;
} else if (selectedPresetId.startsWith('builtin_')) {
const filamentId = selectedPresetId.replace('builtin_', '');
const bf = builtinFilaments?.find(b => b.filament_id === filamentId);
const builtinFilamentId = selectedPresetId.replace('builtin_', '');
const bf = builtinFilaments?.find(b => b.filament_id === builtinFilamentId);
presetName = bf?.name || null;
filamentId = toFilamentId(builtinFilamentId);
} else {
const orcaCandidateId = selectedPresetId.startsWith('orca_')
? selectedPresetId.replace('orca_', '')
@@ -675,6 +682,11 @@ export function ConfigureAmsSlotModal({
} else if (cloudSettings?.filament) {
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
presetName = cp?.name || null;
if (cp) {
// SlicerSetting only carries setting_id ("GFSG98_09"); toFilamentId
// drops the variant suffix and the "S" infix to yield "GFG98".
filamentId = toFilamentId(cp.setting_id);
}
}
}
if (!presetName) {
@@ -693,6 +705,7 @@ export function ConfigureAmsSlotModal({
fullName: nameWithoutSuffix,
material: parsed.material,
brand: parsed.brand,
filamentId,
};
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]);
@@ -737,16 +750,28 @@ export function ConfigureAmsSlotModal({
const matchingKProfiles = useMemo(() => {
if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
const { fullName, material, brand } = selectedPresetInfo;
const { fullName, material, brand, filamentId } = selectedPresetInfo;
const upperFullName = fullName.toUpperCase();
const upperMaterial = material.toUpperCase();
const upperBrand = brand.toUpperCase();
const presetFid = filamentId; // already normalised via toFilamentId
// Material must be at least 2 chars to avoid false positives
if (!upperMaterial || upperMaterial.length < 2) return [];
// Filter profiles - require brand match if brand is present in selected preset
const filtered = kprofilesData.profiles.filter(p => {
// Preferred: exact filament_id match (#1688). A user's custom K-profile
// whose name doesn't agree with the slicer preset still surfaces when
// both sides agree on filament_id. Generic GFx99 IDs are excluded —
// they're shared across many filaments and over-match if id-compared.
if (presetFid) {
const calFid = toFilamentId(p.filament_id);
if (calFid && calFid === presetFid && !isGenericFilamentId(calFid)) {
return true;
}
}
const profileName = p.name.toUpperCase();
// If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
@@ -803,8 +828,26 @@ export function ConfigureAmsSlotModal({
seen.set(key, profile);
}
}
return Array.from(seen.values());
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId]);
const result = Array.from(seen.values());
// Always include the slot's currently-active K-profile (by cali_idx / slot_id),
// even if its name and filament_id didn't match the selected preset (#1689).
// A spool assigned under "Generic PLA" can have a K-profile actively bound on
// the printer whose filament_id differs from "Generic PLA"; without this
// safety net the modal shows "not assigned, default 0.020" while the printer
// card's hover-card correctly shows the active profile.
const activeIdx = slotInfo.caliIdx;
if (activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
const active = kprofilesData.profiles.find(
p => p.slot_id === activeIdx
&& (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
);
if (active) result.unshift(active);
}
return result;
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
// Pre-select current profile when modal opens, reset when closes
useEffect(() => {
+40 -1
View File
@@ -291,13 +291,52 @@ export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]
return updated;
}
// Normalise a Bambu filament identifier to its bare filament_id form (#1688).
// Spools store ``slicer_filament`` as a setting_id like "GFSG98_09" (the "_NN"
// suffix is the variant, the "S" infix marks it as a setting_id); printer
// K-profiles store ``filament_id`` as "GFG98" (bare). Both shapes need
// normalising before comparison.
//
// This is the inverse of the filament_id→setting_id mapping at
// ``buildFilamentOptions`` ("GFS" + filament_id.slice(2)), so a round-trip
// stays consistent. Non-Bambu IDs (numeric local-preset IDs, Orca UUIDs)
// are returned unchanged uppercase — they won't match any K-profile's
// filament_id and the caller falls through to name-based matching.
export function toFilamentId(id: string | null | undefined): string {
if (!id) return '';
// Drop "_NN" variant suffix.
let s = id.split('_')[0];
// Strip the "S" infix in "GFS..." so "GFSG98" → "GFG98".
if (/^GFS/i.test(s)) s = s.slice(0, 2) + s.slice(3);
return s.toUpperCase();
}
// "GFx99" identifiers (GFL99, GFG99, GFB99, ...) are Bambu's *generic* filament
// IDs — shared across many different physical filaments. Matching K-profiles
// by an exact generic ID would over-match, so the id-match path skips them and
// the caller falls through to name-based matching.
export function isGenericFilamentId(id: string | null | undefined): boolean {
return !!id && /^GF[A-Z]99$/i.test(id);
}
// Check if a calibration matches based on brand, material, and variant
export function isMatchingCalibration(
cal: { name?: string; filament_id?: string },
formData: { material: string; brand: string; subtype: string },
formData: { material: string; brand: string; subtype: string; slicer_filament?: string },
): boolean {
if (!formData.material) return false;
// Preferred path: exact filament_id match after normalising both sides
// (#1688). When the spool has a non-generic preset assigned and it agrees
// with the K-profile's filament_id, this is unambiguous — no name parsing
// needed. A spool storing "GFSG98_09" matches a K-profile with filament_id
// "GFG98" without going anywhere near parsePresetName.
const spoolFid = toFilamentId(formData.slicer_filament);
const calFid = toFilamentId(cal.filament_id);
if (spoolFid && calFid && spoolFid === calFid && !isGenericFilamentId(calFid)) {
return true;
}
const profileName = cal.name || '';
// Remove flow type prefixes