mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
A printer files each calibration under a nozzle id of the form HH00-0.4 (high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a maintainer's H2D carries 102 high-flow entries against 6 standard -- because the same filament reads a different K through each. Nothing read that, so a standard-flow profile could be selected for a high-flow nozzle and vice versa. The flow is now stored with the profile, shown against each option in the picker, and checked before a stored profile is applied. Two spellings have to agree for that: a calibration entry says HH00-0.4 while the fitted nozzle reports HH01, so the comparison is two characters rather than four -- the trailing digits are a hardware variant the calibration table normalises to 00. Unknown flow on either side matches anything, which is what it has to do. Every profile stored before this has none. And an X1C declares none on any profile at all -- probed live, all eight come back with an empty nozzle id, against a four-digit cali_idx and a populated setting_id -- even though the machine really does take either nozzle. supports_nozzle_flow_type is therefore the wrong thing to gate on: it returns True for an X1C, and treating that silence as Standard would have dropped every X1C profile the moment a high-flow nozzle was fitted. What the printer's own table declares per profile is the test. NozzleInfo.nozzle_type carries two vocabularies by printer generation -- the nozzle material on legacy printers, the flow code on H2 -- and the comment claiming only the former is corrected. Anything that is not HH or HS reads as unknown, which is what makes the material spelling harmless. Storing both flows for one hotend and diameter is deliberately not done: spoolman_k_profile is UNIQUE on (spool, printer, extruder, diameter) with no flow column, and allowing a second row in internal mode alone would break inventory-mode parity. The picker marks a profile whose flow does not match what is fitted instead of letting it look configured while doing nothing.
551 lines
23 KiB
TypeScript
551 lines
23 KiB
TypeScript
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, STANDARD_NOZZLE_DIAMETERS } 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[]> {
|
|
// Every standard size, plus anything unusual the printer reports as fitted --
|
|
// not just the fitted ones. A K profile is stored on the printer per nozzle
|
|
// diameter and survives a nozzle swap, so fetching only what is screwed in
|
|
// right now made a 0.6 profile invisible until a 0.6 was fitted, and left the
|
|
// user unable to prepare a spool for a nozzle they are about to change to.
|
|
// A diameter the printer has nothing for answers with an empty list rather
|
|
// than timing out, so the extra sizes cost a round trip each, not a stall --
|
|
// provided they are sent one at a time, see below.
|
|
const installed = installedNozzleDiameters(status);
|
|
const toFetch = Array.from(new Set([...STANDARD_NOZZLE_DIAMETERS, ...installed]));
|
|
|
|
// One diameter at a time, NOT in parallel. H2-series firmware answers only
|
|
// the first one or two of a concurrent burst of `extrusion_cali_get` and
|
|
// silently drops the rest, so each dropped request costs a 5s timeout before
|
|
// it is retried: measured on an H2C and an H2D, four parallel requests took
|
|
// 11s and 23s respectively, against ~1s when sent one after another. (An X1C
|
|
// answers all four concurrently, which is why this went unnoticed while only
|
|
// dual-diameter printers ever sent more than one.) Per-diameter failures are
|
|
// still swallowed: a printer that doesn't support the endpoint yields no rows
|
|
// rather than losing the diameters that did answer.
|
|
const responses = [];
|
|
for (const diameter of toFetch) {
|
|
responses.push(await api.getKProfiles(printerId, diameter).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,
|
|
nozzle_id: p.nozzle_id,
|
|
});
|
|
}
|
|
}
|
|
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'], source: 'builtin' },
|
|
{ code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'], source: 'builtin' },
|
|
{ code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'], source: 'builtin' },
|
|
{ code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'], source: 'builtin' },
|
|
{ code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'], source: 'builtin' },
|
|
{ code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'], source: 'builtin' },
|
|
{ code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'], source: 'builtin' },
|
|
{ code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'], source: 'builtin' },
|
|
{ code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'], source: 'builtin' },
|
|
{ code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'], source: 'builtin' },
|
|
{ code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'], source: 'builtin' },
|
|
{ code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'], source: 'builtin' },
|
|
{ code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'], source: 'builtin' },
|
|
{ code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'], source: 'builtin' },
|
|
{ code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'], source: 'builtin' },
|
|
{ code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'], source: 'builtin' },
|
|
{ code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'], source: 'builtin' },
|
|
];
|
|
|
|
// 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,
|
|
source: 'local',
|
|
};
|
|
});
|
|
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[],
|
|
// Which of the cloud presets came from Orca Cloud rather than Bambu Cloud.
|
|
// The two are merged into one list by the callers (OrcaProfileMeta is
|
|
// structurally identical to SlicerSetting), so the distinction has to be
|
|
// carried in rather than derived -- and it is worth carrying, because the
|
|
// origin badge is the only thing telling a user which cloud a preset is in.
|
|
orcaSettingIds?: Set<string>,
|
|
): 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],
|
|
source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
|
|
});
|
|
cloudCodes.add(preset.setting_id);
|
|
}
|
|
} else {
|
|
defaultPresets.push({
|
|
code: preset.setting_id,
|
|
name: preset.name,
|
|
displayName: preset.name,
|
|
isCustom: false,
|
|
allCodes: [preset.setting_id],
|
|
source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
|
|
});
|
|
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],
|
|
source: 'builtin',
|
|
});
|
|
}
|
|
}
|
|
|
|
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
|
|
/**
|
|
* Key one hotend's K-profile selection: printer, extruder, nozzle diameter.
|
|
*
|
|
* All three because that is what both K tables are keyed on -- a K value is
|
|
* measured on one individual hotend, and cali_idx alone cannot identify one
|
|
* (the printer numbers its calibration table PER NOZZLE, so index 16 exists on
|
|
* both hotends of a dual-nozzle machine meaning different things).
|
|
*/
|
|
export function hotendKey(printerId: number, extruder: number, diameter: string): string {
|
|
return `${printerId}:${extruder}:${diameter}`;
|
|
}
|
|
|
|
/**
|
|
* Key one per-printer-model preset override.
|
|
*
|
|
* Diameter "" is the model's own default and a bare decimal is a per-hotend
|
|
* exception, matching the (printer_model, nozzle_diameter) pair the backend
|
|
* stores. The separator is a NUL because a model name is free text from the
|
|
* printer ("A1 mini") and must never be able to collide with a diameter.
|
|
*/
|
|
export function presetKey(model: string, diameter: string): string {
|
|
return `${model}\u0000${diameter}`;
|
|
}
|
|
|
|
/** Split a `presetKey` back into its model and diameter. */
|
|
export function parsePresetKey(key: string): { model: string; diameter: string } {
|
|
const [model = '', diameter = ''] = key.split('\u0000');
|
|
return { model, diameter };
|
|
}
|
|
|
|
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;
|
|
}
|