fix(kprofiles): populate the filament picker from all preset tiers (issue #2719)

Add K-Profile built its Filament dropdown from the profiles already on
the printer, so on a printer with none the field was empty, required
and unsatisfiable (#2719, reporter @jmoore-skild). The modal's own
hint described the dead end: create the profile in Bambu Studio first.

The dropdown now uses the app-wide lookup order -- local imported,
Orca Cloud, Bambu Cloud, hardcoded built-in table -- same as the AMS
slot picker and the SliceModal tier groups. The built-in table is
compiled into the backend, so the list can never be empty: a new
printer with no cloud account and nothing imported still gets a first
profile.

Not fixed the way the report suggested. Seeding from
/printers/available-filaments would have offered only what happens to
be in an AMS right now, which on the reported printer is nothing; its
tray_info_idx is empty or a cloud user preset rather than a filament
id; it aggregates across every printer of the same model; and it is
gated on QUEUE_CREATE, which the K-Profiles page does not hold.

The printer indexes its calibration table by filament_id, so the
picked preset is reduced to one before anything is sent. Built-in
entries and Bambu official cloud presets carry one; a cloud user
preset needs its detail fetched (never base_id -- that collapses a
custom preset onto its inherited generic, #1053); imported and Orca
presets have no Bambu id at all and take the closest generic for
their material, via the same table the AMS slot configure flow uses
so the two agree. A filament that resolves to nothing is refused with
a named error rather than written under a wrong id.

Collapses duplicates from two separate causes. A cloud account
carries one copy of each filament per printer model, and with the
"@BBL <model>" suffix stripped for display those rows are
indistinguishable -- deduped within each tier by resolved filament id,
by display name for user presets that have none. Cloud setting_ids
also carry a "_NN" variant suffix, so the built-in tier's
already-covered check never matched and listed the same filament
again; the bare id is now recorded alongside.

Groups the options by source with an optgroup per tier, styled in
index.css: browsers render optgroup labels small, grey and italic,
which buries the one thing distinguishing a "Bambu PLA Basic" you
imported from the one the built-in table ships.

Drops the second getKProfiles(printer, "0.4") query that existed only
to seed the old dropdown. It ran concurrently with the main fetch
whenever a non-0.4mm nozzle was selected -- the two-requests-in-flight
case that made K-profile fetches time out.

---

fix(ui): cancel a dialog's deferred close when it unmounts

The AMS slot configure and K-Profile dialogs hold a success state
briefly and then close themselves -- 1.5s to 4s after the command
goes out, so the printer has time to process it before the list
refetches. Each did that with a bare setTimeout closing over setState
and the parent's onClose, and nothing cancelled it.

The timer therefore ran whether or not the dialog was still there.
Dismissing it inside that window, or the printer card re-rendering
underneath it, left a pending close that fired later and dismissed
whatever dialog was open by then. It also threw outright when the
surrounding environment was gone first: a test tearing down its DOM
before the 1.5s elapsed produced "ReferenceError: window is not
defined" out of react-dom's resolveUpdatePriority, reported as an
unhandled error against a suite that otherwise passed.

Routes all five through a useCancellableTimeout hook -- two in
ConfigureAmsSlotModal, three in KProfileModal, the latter with the
longest windows and so the widest exposure. Scheduling replaces any
pending timer and unmounting clears it.
This commit is contained in:
maziggy
2026-08-01 09:36:42 +02:00
parent a35ba8fa5f
commit af282b3527
24 changed files with 1181 additions and 253 deletions
+247
View File
@@ -0,0 +1,247 @@
// Tiered filament-preset list, shared by every picker that has to offer "all
// the filaments this install knows about".
//
// Lookup order is fixed across the app: local imported > Orca Cloud > Bambu
// Cloud > hardcoded built-in table. It mirrors ConfigureAmsSlotModal's picker
// and SliceModal's tier groups, so a filament the user sees in one place is
// named and ranked the same way in the others.
//
// The built-in table is the floor, not an equal source: it is a static list
// compiled into the backend, so it is the only tier that can never be empty
// and the only one that works with no cloud account and nothing imported.
import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../api/client';
import { parsePresetName, toFilamentId } from '../components/spool-form/utils';
export type FilamentPresetSource = 'local' | 'orca_cloud' | 'cloud' | 'builtin';
export interface FilamentPresetOption {
/** Opaque, source-prefixed handle: ``local_12`` / ``orca_<uuid>`` / a Bambu
* cloud setting_id / ``builtin_GFA00``. Prefixes match the convention
* ConfigureAmsSlotModal already uses so the two can share resolvers. */
id: string;
name: string;
source: FilamentPresetSource;
/** The Bambu filament id this preset resolves to, when it is derivable
* without a network round trip. Empty for Bambu Cloud *user* presets, whose
* real filament_id only exists in the cloud detail — see
* resolveFilamentId. */
filamentId: string;
/** Material as the preset itself declares it, used to derive a generic
* filament id for tiers that carry no Bambu id of their own. */
filamentType: string;
}
export interface FilamentPresetSources {
localPresets?: LocalPreset[];
orcaProfiles?: OrcaProfileMeta[];
cloudSettings?: SlicerSetting[];
builtinFilaments?: BuiltinFilament[];
}
/** Generic Bambu filament ids by material. Local and Orca Cloud presets carry
* no Bambu filament id, but the printer's calibration table is indexed by
* one, so the closest generic is what a calibration for such a preset has to
* be filed under. Same table and same fallback chain as the AMS slot
* configure flow — the two must agree or a profile created here won't match
* the slot configured there. */
const GENERIC_FILAMENT_IDS: Record<string, string> = {
'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
'ABS': 'GFB99', 'ASA': 'GFB98',
'PC': 'GFC99',
'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
'TPU': 'GFU99',
'PVA': 'GFS99', 'HIPS': 'GFS98',
'PE': 'GFP99', 'PP': 'GFP97',
};
/** Resolve a material string to a generic Bambu filament id, trying the exact
* spelling before progressively stripping the suffixes slicer presets add
* ("-CF", "+", " HF"). Returns '' when nothing matches, which callers must
* treat as "not calibratable" rather than substituting a default — filing a
* calibration under the wrong material is worse than refusing. */
export function genericFilamentIdForMaterial(material: string | null | undefined): string {
const m = (material || '').toUpperCase().trim();
if (!m) return '';
return GENERIC_FILAMENT_IDS[m]
|| GENERIC_FILAMENT_IDS[m.replace(/[-\s]?CF$/, '')]
|| GENERIC_FILAMENT_IDS[m.replace(/\+$/, '')]
|| GENERIC_FILAMENT_IDS[m.split(/[-\s]/)[0]]
|| '';
}
/** Strip the printer/nozzle suffix and the "# " custom-preset marker a preset
* name may carry, e.g. "Elegoo PLA+ @BBL X1C 0.4 nozzle" → "Elegoo PLA+". */
export function presetDisplayName(name: string): string {
const withoutSuffix = name.replace(/@.+$/, '').trim();
return withoutSuffix.startsWith('# ') ? withoutSuffix.slice(2).trim() : withoutSuffix;
}
const SOURCE_ORDER: Record<FilamentPresetSource, number> = {
local: 0,
orca_cloud: 1,
cloud: 2,
builtin: 3,
};
/**
* Merge every filament source into one ranked list.
*
* Deduplication is deliberately asymmetric, because "the same name in two
* tiers" means different things depending on which tiers:
*
* - *Within* a tier, by resolved filament id or display name. This is what
* collapses the per-printer-model copies a cloud account carries —
* "Bambu PLA Basic @BBL X1C", "@BBL P1S", "@BBL A1" are one name once the
* suffix is stripped — and repeated imports of one filament for several
* printers.
*
* - *Across* tiers, by id only. Two entries carrying the same id really are
* one record reached by two routes; two entries merely sharing a name are
* not. Imported presets and an Orca Cloud library overlap heavily by name
* (they are usually the same profiles, synced), and suppressing one for the
* other empties a tier the user curated on purpose. The heading says where
* each came from, which is the point of having tiers at all.
*
* - *Into the built-in tier*, by name as well as by id. That tier is a static
* table of the same Bambu catalogue every other source also ships, so
* without a name check it echoes back everything above it. It exists to
* guarantee the list is never empty, not to be a fourth copy.
*/
export function buildFilamentPresetOptions(sources: FilamentPresetSources): FilamentPresetOption[] {
const { localPresets, orcaProfiles, cloudSettings, builtinFilaments } = sources;
const options: FilamentPresetOption[] = [];
const nameKey = (name: string) => name.trim().toLowerCase();
// Ids seen anywhere: a cloud setting_id, an Orca profile id, a resolved
// filament id. Shared across tiers — an id collision is true identity.
const claimedIds = new Set<string>();
// Names seen, scoped to one tier, so two tiers can each list "Elegoo PLA+".
const namesInTier = new Set<string>();
// Every name any real source offered, consulted only by the built-in tier.
const namesOffered = new Set<string>();
const take = (source: FilamentPresetSource, name: string, ...ids: (string | undefined)[]): boolean => {
const usableIds = ids.filter((k): k is string => !!k);
if (usableIds.some(k => claimedIds.has(k))) return false;
const scoped = `${source}|${nameKey(name)}`;
if (namesInTier.has(scoped)) return false;
usableIds.forEach(k => claimedIds.add(k));
namesInTier.add(scoped);
namesOffered.add(nameKey(name));
return true;
};
// 1. Local imported presets. filament_id lives in the preset's setting JSON,
// which the list endpoint doesn't return, so the generic material id is what
// we can offer without a per-preset detail fetch.
for (const lp of localPresets ?? []) {
const name = presetDisplayName(lp.name);
const material = lp.filament_type || parsePresetName(name).material;
// No id is claimed here: the generic id an import maps to is shared by
// every filament of that material, so claiming it would let the first
// imported PLA swallow every other PLA in the list.
if (!take('local', name)) continue;
options.push({
id: `local_${lp.id}`,
name,
source: 'local',
filamentId: genericFilamentIdForMaterial(material),
filamentType: material || '',
});
}
// 2. Orca Cloud. setting_ids are UUIDs a Bambu printer can't resolve, so
// these also fall back to the generic id for their material.
for (const op of orcaProfiles ?? []) {
const name = presetDisplayName(op.name);
const material = parsePresetName(name).material;
// Same reasoning as the local tier for the generic id. The Orca profile id
// is claimed, so a Bambu Cloud row carrying that same id is recognised as
// the same record — a shared *name* is not, since an Orca library and an
// imported bundle are usually the same profiles reached two ways and both
// are worth showing under their own heading.
if (!take('orca_cloud', name, op.setting_id)) continue;
options.push({
id: `orca_${op.setting_id}`,
name,
source: 'orca_cloud',
filamentId: genericFilamentIdForMaterial(material),
filamentType: material,
});
}
// 3. Bambu Cloud. Official presets (GFS…) carry their filament id in the
// setting_id itself; user presets (PFUS… / PFCN…) do not, and toFilamentId
// would hand back the raw cloud id, which the printer rejects. Leave those
// empty here and let resolveFilamentId fetch the detail on selection.
for (const cp of cloudSettings ?? []) {
const name = presetDisplayName(cp.name);
// Cloud setting_ids carry a variant suffix ("GFSA00_01"); claim the bare
// filament id as well, or the built-in tier won't recognise the filament
// as covered and will list it again under its own heading.
const filamentId = cp.setting_id.startsWith('GFS') ? toFilamentId(cp.setting_id) : '';
if (!take('cloud', name, cp.setting_id, filamentId || undefined)) continue;
options.push({
id: cp.setting_id,
name,
source: 'cloud',
filamentId,
filamentType: parsePresetName(name).material,
});
}
// 4. Hardcoded fallback. Always present, so the picker is never empty even
// with no cloud account and nothing imported — but only for filaments none
// of the tiers above already offered.
for (const bf of builtinFilaments ?? []) {
// Cloud setting_ids insert an "S" after "GF" ("GFA00" → "GFSA00"); check
// both spellings so a filament a cloud tier already offered isn't listed
// a second time under a slightly different id.
const asSettingId = bf.filament_id.startsWith('GF') ? `GFS${bf.filament_id.slice(2)}` : bf.filament_id;
// Unlike the tiers above, a name match is enough to skip: this table is a
// static copy of the same catalogue, not a library of its own.
if (namesOffered.has(nameKey(bf.name))) continue;
if (!take('builtin', bf.name, bf.filament_id, asSettingId)) continue;
options.push({
id: `builtin_${bf.filament_id}`,
name: bf.name,
source: 'builtin',
filamentId: bf.filament_id,
filamentType: parsePresetName(bf.name).material,
});
}
return options.sort((a, b) => {
if (a.source !== b.source) return SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source];
return a.name.localeCompare(b.name);
});
}
/**
* The Bambu filament id to file a calibration under for a chosen preset.
*
* Everything except a Bambu Cloud *user* preset is already resolved by
* buildFilamentPresetOptions; those need the cloud detail, because the
* PFUS/PFCN setting_id is not a filament id and the printer's calibration
* table is indexed by filament id. ``fetchDetail`` is injected so the pure
* cases stay testable without a network stub.
*/
export async function resolveFilamentId(
option: FilamentPresetOption,
fetchDetail?: (settingId: string) => Promise<{ filament_id?: string | null }>,
): Promise<string> {
if (option.filamentId) return option.filamentId;
if (option.source !== 'cloud' || !fetchDetail) return '';
try {
const detail = await fetchDetail(option.id);
// Never fall back to the preset's base_id: that collapses a custom preset
// onto the generic it inherits from, and the printer then resolves the
// calibration to "Generic …" instead of the user's filament (#1053).
return detail.filament_id || '';
} catch {
return '';
}
}