| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 |
- import { api } from '../../api/client';
- import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
- import { installedNozzleDiameters } from '../../utils/amsHelpers';
- import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
- import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
- /**
- * Fetch a printer's K-profiles across every nozzle it actually has installed
- * (#2618) and flatten them into CalibrationProfile rows for the PA-Profil
- * picker. `getKProfiles` filters strictly by nozzle diameter and defaults to
- * "0.4", so a single call hides non-0.4 profiles (e.g. a 0.6mm PAHT-CF K
- * value) — the picker then shows only one of two nozzle-specific profiles.
- * We query each installed diameter and merge. Falls back to "0.4" when the
- * printer hasn't reported nozzle hardware, preserving prior behaviour.
- * Per-diameter failures are swallowed (a printer that doesn't support the
- * endpoint just yields no rows), matching the callers' previous try/catch.
- */
- export async function fetchPrinterCalibrations(
- printerId: number,
- status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
- ): Promise<CalibrationProfile[]> {
- const diameters = installedNozzleDiameters(status);
- const toFetch = diameters.length > 0 ? diameters : ['0.4'];
- const responses = await Promise.all(
- toFetch.map(d => api.getKProfiles(printerId, d).catch(() => null)),
- );
- const calibrations: CalibrationProfile[] = [];
- for (const res of responses) {
- if (!res) continue;
- for (const p of res.profiles) {
- calibrations.push({
- cali_idx: p.slot_id,
- filament_id: p.filament_id,
- setting_id: p.setting_id || '',
- name: p.name,
- k_value: parseFloat(p.k_value) || 0,
- n_coef: parseFloat(p.n_coef) || 0,
- extruder_id: p.extruder_id,
- nozzle_diameter: p.nozzle_diameter,
- });
- }
- }
- return calibrations;
- }
- // Fallback filament presets when cloud is not available
- const FALLBACK_PRESETS: FilamentOption[] = [
- { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
- { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
- { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
- { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
- { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
- { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
- { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
- { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
- { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
- { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
- { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
- { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
- { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
- { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
- { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
- { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
- { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
- ];
- // Parse a slicer preset name to extract brand, material, and variant
- export function parsePresetName(name: string): { brand: string; material: string; variant: string } {
- // Remove @printer suffix (e.g., "@Bambu Lab H2D 0.4 nozzle")
- let cleanName = name.replace(/@.*$/, '').trim();
- // Remove (Custom) tag
- cleanName = cleanName.replace(/\(Custom\)/i, '').trim();
- // Remove leading # or * markers
- cleanName = cleanName.replace(/^[#*]+\s*/, '').trim();
- // Materials list - order matters (longer/more specific first)
- const materials = [
- 'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',
- 'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',
- 'PCTG', 'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PEEK', 'PEI',
- ];
- // Find material in the name
- let material = '';
- let materialIdx = -1;
- for (const m of materials) {
- const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());
- if (idx !== -1) {
- material = m;
- materialIdx = idx;
- break;
- }
- }
- // Brand is everything before the material
- let brand = '';
- if (materialIdx > 0) {
- brand = cleanName.substring(0, materialIdx).trim();
- brand = brand.replace(/[-_\s]+$/, '');
- }
- // Everything after material is potential variant
- let afterMaterial = '';
- if (materialIdx !== -1 && material) {
- afterMaterial = cleanName.substring(materialIdx + material.length).trim();
- afterMaterial = afterMaterial.replace(/^[-_\s]+/, '');
- }
- // Check for known variant - could be before OR after material
- let variant = '';
- // First check after material (most common)
- for (const v of KNOWN_VARIANTS) {
- if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {
- variant = v;
- break;
- }
- }
- // If no variant found after material, check if brand contains a known variant
- if (!variant && brand) {
- for (const v of KNOWN_VARIANTS) {
- const variantPattern = new RegExp(`\\s+${v}$`, 'i');
- if (variantPattern.test(brand)) {
- variant = v;
- brand = brand.replace(variantPattern, '').trim();
- break;
- }
- }
- }
- return { brand, material, variant };
- }
- // Extract unique brands from cloud presets and local presets
- export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
- const brandSet = new Set<string>(DEFAULT_BRANDS);
- for (const preset of presets) {
- const { brand } = parsePresetName(preset.name);
- if (brand && brand.length > 1) {
- brandSet.add(brand);
- }
- }
- // Also extract brands from local presets
- if (localPresets) {
- for (const preset of localPresets) {
- if (preset.filament_vendor && preset.filament_vendor.length > 1) {
- brandSet.add(preset.filament_vendor);
- } else {
- const { brand } = parsePresetName(preset.name);
- if (brand && brand.length > 1) {
- brandSet.add(brand);
- }
- }
- }
- }
- return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
- }
- // Build filament options from local presets (OrcaSlicer / BambuStudio imports).
- // Each preset gets its own entry — no base-name collapse — so the spool form
- // shows all per-printer/per-nozzle variants the user has imported. The spool
- // itself is printer-agnostic, so the variant the user picks just becomes the
- // stored slicer_filament code (consumed by normalize_slicer_filament during
- // slicing — kept as preset.filament_type when available so the existing
- // "GFL05"-style normalisation still resolves).
- function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
- const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
- if (filamentPresets.length === 0) return [];
- const options: FilamentOption[] = filamentPresets.map(preset => {
- // Use the unique preset.id (stringified) as the code so each local preset
- // has its own identity. Earlier this was preset.filament_type (e.g. "PLA")
- // which collapsed every PLA local preset onto the same code — picking any
- // of them saved slicer_filament="PLA", a material name the backend cannot
- // resolve back to a specific preset row. The backend handler at
- // inventory.py expects numeric IDs for local-preset slicer_filament values.
- // allCodes still carries the legacy filament_type so findPresetOption
- // resolves existing saved spools that have the old material-name code.
- const code = String(preset.id);
- const legacyCode = preset.filament_type || code;
- const allCodes = Array.from(new Set([code, legacyCode]));
- return {
- code,
- name: preset.name,
- displayName: preset.name,
- isCustom: false,
- allCodes,
- };
- });
- return options.sort((a, b) => a.displayName.localeCompare(b.displayName));
- }
- // Build filament options by merging cloud presets, local profiles, and built-in
- // filaments — matching the behavior of ConfigureAmsSlotModal and the wiki's
- // "Where Presets Come From" section. Earlier versions were precedence-based
- // (cloud-only when cloud had any presets), which silently hid Local Profiles
- // from users logged into Bambu Cloud — see #1248.
- export function buildFilamentOptions(
- cloudPresets: SlicerSetting[],
- configuredPrinterModels: Set<string>,
- localPresets?: LocalPreset[],
- builtinFilaments?: BuiltinFilament[],
- ): FilamentOption[] {
- const customPresets: FilamentOption[] = [];
- const defaultPresets: FilamentOption[] = [];
- const cloudCodes = new Set<string>();
- // 1. Cloud presets — each setting_id gets its own entry. The spool form is
- // printer-agnostic so we deliberately do NOT collapse "@P1S" / "@X1C"
- // variants into a single row; the user picks the variant they want and
- // its setting_id is what gets persisted.
- for (const preset of cloudPresets) {
- if (preset.is_custom) {
- const presetNameUpper = preset.name.toUpperCase();
- const matchesPrinter = configuredPrinterModels.size === 0 ||
- Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
- !presetNameUpper.includes('@');
- if (matchesPrinter) {
- customPresets.push({
- code: preset.setting_id,
- name: preset.name,
- displayName: `${preset.name} (Custom)`,
- isCustom: true,
- allCodes: [preset.setting_id],
- });
- cloudCodes.add(preset.setting_id);
- }
- } else {
- defaultPresets.push({
- code: preset.setting_id,
- name: preset.name,
- displayName: preset.name,
- isCustom: false,
- allCodes: [preset.setting_id],
- });
- cloudCodes.add(preset.setting_id);
- }
- }
- // 2. Local profiles (OrcaSlicer / BambuStudio imports)
- const localOptions = localPresets && localPresets.length > 0
- ? buildLocalFilamentOptions(localPresets)
- : [];
- // 3. Built-in filaments — only those not already represented by a cloud preset.
- // Cloud setting_ids look like "GFSA00", built-in filament_ids look like "GFA00";
- // map between the two so we don't render the same filament twice.
- const builtinOptions: FilamentOption[] = [];
- if (builtinFilaments && builtinFilaments.length > 0) {
- for (const bf of builtinFilaments) {
- const settingId = bf.filament_id.startsWith('GF')
- ? 'GFS' + bf.filament_id.slice(2)
- : bf.filament_id;
- if (cloudCodes.has(bf.filament_id) || cloudCodes.has(settingId)) continue;
- builtinOptions.push({
- code: bf.filament_id,
- name: bf.name,
- displayName: bf.name,
- isCustom: false,
- allCodes: [bf.filament_id, settingId],
- });
- }
- }
- const merged = [
- ...customPresets,
- ...defaultPresets,
- ...localOptions,
- ...builtinOptions,
- ];
- // 4. Hardcoded fallback only when literally every source is empty.
- if (merged.length === 0) return FALLBACK_PRESETS;
- return merged.sort((a, b) => a.displayName.localeCompare(b.displayName));
- }
- // Find selected preset option
- export function findPresetOption(
- slicerFilament: string,
- filamentOptions: FilamentOption[],
- ): FilamentOption | undefined {
- if (!slicerFilament) return undefined;
- // First try exact match on primary code
- let option = filamentOptions.find(o => o.code === slicerFilament);
- if (!option) {
- // Try matching against any code in allCodes
- option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
- }
- if (!option) {
- // Try case-insensitive match
- const slicerLower = slicerFilament.toLowerCase();
- option = filamentOptions.find(o =>
- o.code.toLowerCase() === slicerLower ||
- o.allCodes.some(c => c.toLowerCase() === slicerLower),
- );
- }
- return option;
- }
- // Keep the value a spool already carries selectable in its own dropdown (#1905).
- // A brand or material entered as a custom value isn't part of the color catalog
- // or any slicer preset, so without this the edit form offered no way back to it
- // once the user opened the dropdown.
- export function withCurrentValue(options: string[], current: string): string[] {
- const trimmed = current.trim();
- if (!trimmed || options.some(o => o.toLowerCase() === trimmed.toLowerCase())) return options;
- return [...options, trimmed].sort((a, b) => a.localeCompare(b));
- }
- // Brands/materials the catalog and slicer presets pair with the other field's
- // current value (#1905). Used to rank the dropdown, never to filter it — the
- // pairs are incomplete (Elegoo ships ASA even though the catalog only knows its
- // PLA), and hiding the rest made valid entries look impossible.
- export function pairedOptions(
- options: string[],
- counterpart: string,
- pairMap: Map<string, Set<string>>,
- ): string[] {
- if (!counterpart) return [];
- const keys = pairMap.get(counterpart.toLowerCase());
- if (!keys || keys.size === 0) return [];
- return options.filter(o => keys.has(o.toLowerCase()));
- }
- // Recent colors management
- export function loadRecentColors(): ColorPreset[] {
- try {
- const stored = localStorage.getItem(RECENT_COLORS_KEY);
- if (stored) {
- return JSON.parse(stored) as ColorPreset[];
- }
- } catch {
- // Ignore errors
- }
- return [];
- }
- export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
- const filtered = currentRecent.filter(
- c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
- );
- const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
- try {
- localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
- } catch {
- // Ignore errors
- }
- 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 — one per material, shared across every physical filament the user hasn't
- // given a specific preset. They still identify a material unambiguously, so an
- // exact generic id match is only ambiguous about *brand*, never about material.
- export function isGenericFilamentId(id: string | null | undefined): boolean {
- return !!id && /^GF[A-Z]99$/i.test(id);
- }
- // The material each generic Bambu filament ID stands for. Used to sanity-check
- // a generic id-match against the material the caller already knows (#2710): a
- // PETG spool must never claim GFL99 (generic PLA) profiles just because both
- // sides happen to have stored the same generic id.
- const GENERIC_FILAMENT_MATERIALS: Record<string, string> = {
- GFB99: 'ABS',
- GFC99: 'PC',
- GFG99: 'PETG',
- GFL99: 'PLA',
- GFN99: 'PA',
- GFP99: 'PE',
- GFR99: 'EVA',
- GFS99: 'PVA',
- GFU99: 'TPU',
- };
- // Material a generic filament ID stands for ("GFL99" → "PLA"), or '' when the
- // ID isn't a known generic one.
- export function materialForGenericFilamentId(id: string | null | undefined): string {
- if (!id) return '';
- return GENERIC_FILAMENT_MATERIALS[id.toUpperCase()] || '';
- }
- // Bambu labels nylon "PA"; users routinely type "Nylon". Compare materials
- // through this so the two spellings agree.
- function normaliseMaterial(material: string): string {
- const upper = material.trim().toUpperCase();
- return upper === 'NYLON' ? 'PA' : upper;
- }
- // True when a generic filament ID may stand in for the given material — i.e.
- // the ID is generic and describes that same material.
- export function genericFilamentIdMatchesMaterial(id: string, material: string): boolean {
- const generic = materialForGenericFilamentId(id);
- return !!generic && !!material && normaliseMaterial(generic) === normaliseMaterial(material);
- }
- // 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; 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) {
- if (!isGenericFilamentId(calFid)) {
- return true;
- }
- // Both sides carry the same *generic* id (#2710). That still pins the
- // material, so the only thing left ambiguous is brand — a printer holds
- // one flat calibration table per generic id and users routinely name
- // those entries by colour ("Dark Brown", "Marble"), which no amount of
- // name parsing can tie back to a material. Accept the match when the
- // material agrees and the spool claims no brand of its own; a spool that
- // does name a brand keeps the stricter name-based path below so its
- // suggestions stay brand-specific.
- const brand = formData.brand.trim();
- const brandIsGeneric = !brand || brand.toUpperCase() === 'GENERIC';
- if (brandIsGeneric && genericFilamentIdMatchesMaterial(calFid, formData.material)) {
- return true;
- }
- }
- const profileName = cal.name || '';
- // Remove flow type prefixes
- const cleanName = profileName
- .replace(/^High Flow[_\s]+/i, '')
- .replace(/^Standard[_\s]+/i, '')
- .replace(/^HF[_\s]+/i, '')
- .replace(/^S[_\s]+/i, '')
- .trim();
- const parsed = parsePresetName(cleanName);
- // Match material (required)
- const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
- if (!materialMatch) return false;
- // Match brand if specified in form
- if (formData.brand) {
- const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
- formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
- if (!brandMatch) return false;
- }
- // Match variant/subtype if specified in form
- if (formData.subtype) {
- const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
- formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
- cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
- if (!variantMatch) return false;
- }
- return true;
- }
|