Bladeren bron

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.
maziggy 1 maand geleden
bovenliggende
commit
5bbb6a73cc

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 54 - 0
frontend/src/__tests__/hooks/useCancellableTimeout.test.ts

@@ -0,0 +1,54 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useCancellableTimeout } from '../../hooks/useCancellableTimeout';
+
+describe('useCancellableTimeout', () => {
+  beforeEach(() => vi.useFakeTimers());
+  afterEach(() => vi.useRealTimers());
+
+  it('runs the callback after the delay', () => {
+    const fn = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1500));
+    expect(fn).not.toHaveBeenCalled();
+    act(() => void vi.advanceTimersByTime(1500));
+    expect(fn).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not run the callback after unmount', () => {
+    // The bug this exists for: a modal that defers its own close by 1.5s fired
+    // setState and onClose after the component was gone — which throws outright
+    // once the DOM around it has been torn down.
+    const fn = vi.fn();
+    const { result, unmount } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1500));
+    unmount();
+    act(() => void vi.advanceTimersByTime(5000));
+    expect(fn).not.toHaveBeenCalled();
+  });
+
+  it('cancel() stops a pending callback', () => {
+    const fn = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1000));
+    act(() => result.current.cancel());
+    act(() => void vi.advanceTimersByTime(2000));
+    expect(fn).not.toHaveBeenCalled();
+  });
+
+  it('scheduling again replaces the pending callback', () => {
+    const first = vi.fn();
+    const second = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(first, 1000));
+    act(() => result.current.schedule(second, 1000));
+    act(() => void vi.advanceTimersByTime(1000));
+    expect(first).not.toHaveBeenCalled();
+    expect(second).toHaveBeenCalledTimes(1);
+  });
+
+  it('is safe to cancel when nothing is pending', () => {
+    const { result } = renderHook(() => useCancellableTimeout());
+    expect(() => act(() => result.current.cancel())).not.toThrow();
+  });
+});

+ 346 - 0
frontend/src/__tests__/utils/filamentPresets.test.ts

@@ -0,0 +1,346 @@
+import { describe, it, expect, vi } from 'vitest';
+import {
+  buildFilamentPresetOptions,
+  genericFilamentIdForMaterial,
+  presetDisplayName,
+  resolveFilamentId,
+} from '../../utils/filamentPresets';
+import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../../api/client';
+
+const localPreset = (over: Partial<LocalPreset> = {}): LocalPreset => ({
+  id: 1,
+  name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle',
+  preset_type: 'filament',
+  source: 'orca',
+  filament_type: 'PLA',
+  filament_vendor: 'Elegoo',
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  pressure_advance: null,
+  default_filament_colour: null,
+  filament_cost: null,
+  filament_density: null,
+  compatible_printers: null,
+  inherits: null,
+  version: null,
+  created_at: '',
+  updated_at: '',
+  ...over,
+});
+
+const orcaProfile = (over: Partial<OrcaProfileMeta> = {}): OrcaProfileMeta => ({
+  setting_id: 'a1b2c3',
+  name: 'Sunlu PETG',
+  type: 'filament',
+  version: null,
+  user_id: null,
+  updated_time: null,
+  is_custom: true,
+  ...over,
+});
+
+const cloudSetting = (over: Partial<SlicerSetting> = {}): SlicerSetting => ({
+  setting_id: 'GFSA00',
+  name: 'Bambu PLA Basic @BBL X1C',
+  type: 'filament',
+  version: null,
+  user_id: null,
+  updated_time: null,
+  is_custom: false,
+  ...over,
+});
+
+const builtin = (filament_id: string, name: string): BuiltinFilament => ({ filament_id, name });
+
+describe('genericFilamentIdForMaterial', () => {
+  it('maps an exact material', () => {
+    expect(genericFilamentIdForMaterial('PETG')).toBe('GFG99');
+  });
+
+  it('is case and whitespace tolerant', () => {
+    expect(genericFilamentIdForMaterial('  pla  ')).toBe('GFL99');
+  });
+
+  it('falls back to the base material when a suffix is unknown', () => {
+    // "PLA-GF" has no generic of its own; the PLA generic is the honest answer.
+    expect(genericFilamentIdForMaterial('PLA-GF')).toBe('GFL99');
+  });
+
+  it('returns empty rather than guessing for an unknown material', () => {
+    expect(genericFilamentIdForMaterial('UNOBTANIUM')).toBe('');
+    expect(genericFilamentIdForMaterial('')).toBe('');
+    expect(genericFilamentIdForMaterial(null)).toBe('');
+  });
+});
+
+describe('presetDisplayName', () => {
+  it('strips the printer/nozzle suffix', () => {
+    expect(presetDisplayName('Bambu PLA Basic @BBL X1C 0.4 nozzle')).toBe('Bambu PLA Basic');
+  });
+
+  it('strips the custom-preset marker', () => {
+    expect(presetDisplayName('# My PLA @BBL P1S')).toBe('My PLA');
+  });
+});
+
+describe('buildFilamentPresetOptions', () => {
+  it('is empty when every source is', () => {
+    expect(buildFilamentPresetOptions({})).toEqual([]);
+  });
+
+  it('ranks the tiers local > orca > cloud > builtin', () => {
+    const options = buildFilamentPresetOptions({
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
+      cloudSettings: [cloudSetting({ setting_id: 'GFSB99', name: 'Generic ABS' })],
+      orcaProfiles: [orcaProfile()],
+      localPresets: [localPreset()],
+    });
+    expect(options.map(o => o.source)).toEqual(['local', 'orca_cloud', 'cloud', 'builtin']);
+  });
+
+  it('sorts by name inside a tier', () => {
+    const options = buildFilamentPresetOptions({
+      builtinFilaments: [builtin('GFA01', 'Bambu PLA Matte'), builtin('GFA00', 'Bambu PLA Basic')],
+    });
+    expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
+  });
+
+  it('takes a builtin filament id straight from the table', () => {
+    const [option] = buildFilamentPresetOptions({ builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')] });
+    expect(option).toMatchObject({ id: 'builtin_GFA00', filamentId: 'GFA00' });
+  });
+
+  it('derives a Bambu official cloud preset id from its setting_id', () => {
+    const [option] = buildFilamentPresetOptions({ cloudSettings: [cloudSetting({ setting_id: 'GFSG98_09' })] });
+    expect(option.filamentId).toBe('GFG98');
+  });
+
+  it('leaves a cloud user preset unresolved for the detail lookup', () => {
+    // PFUS ids are setting ids, not filament ids — the printer rejects them,
+    // so guessing one here would file the calibration under nothing.
+    const [option] = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'PFUS9ac902733670a9', name: 'My PETG', is_custom: true })],
+    });
+    expect(option.filamentId).toBe('');
+  });
+
+  it('gives local and orca presets the generic id for their material', () => {
+    const options = buildFilamentPresetOptions({
+      localPresets: [localPreset({ filament_type: 'PETG' })],
+      orcaProfiles: [orcaProfile({ name: 'Sunlu ABS @BBL X1C' })],
+    });
+    expect(options.find(o => o.source === 'local')?.filamentId).toBe('GFG99');
+    expect(options.find(o => o.source === 'orca_cloud')?.filamentId).toBe('GFB99');
+  });
+
+  it('parses the material from the name when a local preset declares none', () => {
+    const [option] = buildFilamentPresetOptions({
+      localPresets: [localPreset({ filament_type: null, name: 'Overture TPU @BBL X1C' })],
+    });
+    expect(option.filamentId).toBe('GFU99');
+  });
+
+  it('collapses a cloud filament duplicated once per printer model', () => {
+    // Every Bambu Cloud account carries one copy per model. They share a
+    // filament id and, with the "@…" suffix stripped, one visible name.
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
+        cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL P1S' }),
+        cloudSetting({ setting_id: 'GFSA00_02', name: 'Bambu PLA Basic @BBL A1' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+    expect(options[0]).toMatchObject({ name: 'Bambu PLA Basic', filamentId: 'GFA00' });
+  });
+
+  it('collapses a cloud user preset duplicated per model, which has no filament id to key on', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'PFUSaaa', name: 'My PETG @BBL X1C', is_custom: true }),
+        cloudSetting({ setting_id: 'PFUSbbb', name: 'My PETG @BBL P1S', is_custom: true }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('keeps distinct cloud filaments apart', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
+        cloudSetting({ setting_id: 'GFSA01_00', name: 'Bambu PLA Matte @BBL X1C' }),
+      ],
+    });
+    expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
+  });
+
+  it('collapses one imported filament re-imported for several printers', () => {
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle' }),
+        localPreset({ id: 2, name: 'Elegoo PLA+ @BBL P1S 0.4 nozzle' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('keeps imported presets of different materials that share a generic id path', () => {
+    // Keyed by name, not by generic id — otherwise two distinct PLA imports
+    // would collapse into one because both map to GFL99.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+' }),
+        localPreset({ id: 2, name: 'Polymaker PolyLite PLA' }),
+      ],
+    });
+    expect(options).toHaveLength(2);
+  });
+
+  it('collapses Orca Cloud copies of one filament', () => {
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [
+        orcaProfile({ setting_id: 'u1', name: 'Sunlu PETG @BBL X1C' }),
+        orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL A1' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('drops a builtin the cloud tier covers under a variant setting_id', () => {
+    // Cloud ids carry a "_NN" variant suffix; without normalising it the
+    // builtin tier lists the same filament a second time.
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL X1C' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
+  });
+
+  it('drops a builtin already offered by a cloud tier, matching the S-infix spelling', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSA00', name: 'Bambu PLA Basic' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
+  });
+
+  it('drops a bambu cloud preset Orca Cloud already covers', () => {
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [orcaProfile({ setting_id: 'shared-id' })],
+      cloudSettings: [cloudSetting({ setting_id: 'shared-id' })],
+    });
+    expect(options.map(o => o.source)).toEqual(['orca_cloud']);
+  });
+
+  it('keeps an Orca Cloud library that overlaps an imported bundle by name', () => {
+    // These are usually the same profiles reached two ways. Letting the
+    // imported tier claim the name emptied the Orca Cloud group down to
+    // whatever happened not to be imported too.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C' }),
+        localPreset({ id: 2, name: 'Sunlu PETG @BBL X1C' }),
+      ],
+      orcaProfiles: [
+        orcaProfile({ setting_id: 'u1', name: 'Elegoo PLA+ @BBL X1C' }),
+        orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL X1C' }),
+      ],
+    });
+    expect(options.filter(o => o.source === 'local')).toHaveLength(2);
+    expect(options.filter(o => o.source === 'orca_cloud')).toHaveLength(2);
+  });
+
+  it('still drops a cross-tier row that carries an id a higher tier claimed', () => {
+    // A shared id is true identity, unlike a shared name.
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [orcaProfile({ setting_id: 'shared-id', name: 'Sunlu PETG' })],
+      cloudSettings: [cloudSetting({ setting_id: 'shared-id', name: 'Something Else' })],
+    });
+    expect(options.map(o => o.source)).toEqual(['orca_cloud']);
+  });
+
+  it('never echoes a filament the tiers above already offered back from the builtin table', () => {
+    // The builtin tier is a static copy of the same Bambu catalogue, so
+    // without a name check it re-listed everything under a fourth heading.
+    const options = buildFilamentPresetOptions({
+      localPresets: [localPreset({ name: 'Bambu PLA Basic @BBL X1C 0.4 nozzle' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.map(o => [o.source, o.name])).toEqual([
+      ['local', 'Bambu PLA Basic'],
+      ['builtin', 'Bambu PLA Matte'],
+    ]);
+  });
+
+  it('suppresses a builtin a cloud tier already named, even with no id overlap', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'PFUSaaa', name: 'Bambu PLA Basic @BBL X1C', is_custom: true })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
+    });
+    expect(options.map(o => o.source)).toEqual(['cloud']);
+  });
+
+  it('does not let one import swallow every filament of the same material', () => {
+    // Imports resolve to a shared generic id (all PLA → GFL99); claiming that
+    // id would hide every other PLA behind the first one imported.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+', filament_type: 'PLA' }),
+        localPreset({ id: 2, name: 'Polymaker PolyLite PLA', filament_type: 'PLA' }),
+      ],
+      builtinFilaments: [builtin('GFL99', 'Generic PLA')],
+    });
+    expect(options.map(o => o.name)).toEqual([
+      'Elegoo PLA+',
+      'Polymaker PolyLite PLA',
+      'Generic PLA',
+    ]);
+  });
+
+  it('strips printer suffixes from displayed names', () => {
+    const [option] = buildFilamentPresetOptions({ localPresets: [localPreset()] });
+    expect(option.name).toBe('Elegoo PLA+');
+  });
+});
+
+describe('resolveFilamentId', () => {
+  const option = (over = {}) => ({
+    id: 'PFUS9ac902733670a9',
+    name: 'My PETG',
+    source: 'cloud' as const,
+    filamentId: '',
+    filamentType: 'PETG',
+    ...over,
+  });
+
+  it('returns an already-known id without fetching', async () => {
+    const fetchDetail = vi.fn();
+    await expect(resolveFilamentId(option({ filamentId: 'GFA00' }), fetchDetail)).resolves.toBe('GFA00');
+    expect(fetchDetail).not.toHaveBeenCalled();
+  });
+
+  it('fetches the cloud detail for a user preset', async () => {
+    const fetchDetail = vi.fn().mockResolvedValue({ filament_id: 'P285e239' });
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('P285e239');
+    expect(fetchDetail).toHaveBeenCalledWith('PFUS9ac902733670a9');
+  });
+
+  it('returns empty when the detail carries no filament_id', async () => {
+    // Never fall back to base_id: that collapses a custom preset onto the
+    // generic it inherits from (#1053).
+    const fetchDetail = vi.fn().mockResolvedValue({ base_id: 'GFSG98_09' });
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
+  });
+
+  it('returns empty when the detail lookup fails', async () => {
+    const fetchDetail = vi.fn().mockRejectedValue(new Error('offline'));
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
+  });
+
+  it('does not fetch for a non-cloud tier that resolved to nothing', async () => {
+    const fetchDetail = vi.fn();
+    const unknown = option({ source: 'local' as const, filamentType: 'UNOBTANIUM' });
+    await expect(resolveFilamentId(unknown, fetchDetail)).resolves.toBe('');
+    expect(fetchDetail).not.toHaveBeenCalled();
+  });
+});

+ 6 - 2
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -8,6 +8,7 @@ import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex
 import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
+import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 
 interface SlotInfo {
   amsId: number;
@@ -305,6 +306,9 @@ export function ConfigureAmsSlotModal({
   const [showSuccess, setShowSuccess] = useState(false);
   const [showExtendedColors, setShowExtendedColors] = useState(false);
   const scrolledToRef = useRef<string>('');
+  // The success state is held briefly before the modal closes itself; that
+  // timer must not outlive the modal.
+  const { schedule: scheduleClose } = useCancellableTimeout();
 
   // Fetch cloud settings (gracefully handle 401 when logged out)
   const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
@@ -614,7 +618,7 @@ export function ConfigureAmsSlotModal({
       setShowSuccess(true);
       onSuccess?.();
       // Close after showing success briefly
-      setTimeout(() => {
+      scheduleClose(() => {
         setShowSuccess(false);
         onClose();
       }, 1500);
@@ -629,7 +633,7 @@ export function ConfigureAmsSlotModal({
     onSuccess: () => {
       setShowSuccess(true);
       onSuccess?.();
-      setTimeout(() => {
+      scheduleClose(() => {
         setShowSuccess(false);
         onClose();
       }, 1500);

+ 195 - 87
frontend/src/components/KProfilesView.tsx

@@ -21,10 +21,17 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
+import {
+  buildFilamentPresetOptions,
+  resolveFilamentId,
+  type FilamentPresetOption,
+  type FilamentPresetSource,
+} from '../utils/filamentPresets';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
+import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 
 interface KProfileCardProps {
   profile: KProfile;
@@ -154,8 +161,9 @@ interface KProfileModalProps {
   profile?: KProfile;
   printerId: number;
   nozzleDiameter: string;
-  existingProfiles?: KProfile[];  // Existing profiles for filament selection
+  existingProfiles?: KProfile[];  // Existing profiles, used for name resolution
   builtinFilaments?: { filament_id: string; name: string }[];  // Filament ID → name lookup
+  filamentPresets?: FilamentPresetOption[];  // Every filament this install knows, tiered
   isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer
   initialNote?: string;  // Initial note value for the profile
   initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
@@ -171,6 +179,7 @@ function KProfileModal({
   nozzleDiameter,
   existingProfiles = [],
   builtinFilaments = [],
+  filamentPresets = [],
   isDualNozzle = false,
   initialNote = '',
   initialNoteKey = null,
@@ -186,7 +195,12 @@ function KProfileModal({
   const [kValue, setKValue] = useState(
     profile?.k_value ? truncateK(profile.k_value) : '0.020'
   );
-  const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
+  // What the Filament select is bound to. When editing, the printer's own
+  // filament_id (the select is read-only). For a new profile, the *preset
+  // handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
+  // setting_id or a builtin filament id — which is resolved to a real
+  // filament_id on submit, since only some tiers carry one directly.
+  const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
   // Split nozzle into type and diameter
   // Both selects are read-only while editing: they report what the printer
   // holds, they don't set it. '' means the printer reported no nozzle_id, which
@@ -206,40 +220,49 @@ function KProfileModal({
   const [isSyncing, setIsSyncing] = useState(false);
   const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
   const [note, setNote] = useState(initialNote);
-
-  // Extract unique filaments from existing K-profiles on the printer
-  // Use builtin filament table for accurate name resolution (filament_id → name)
-  // Falls back to extracting from profile name for custom/unknown presets
-  const knownFilaments = React.useMemo(() => {
-    // Build lookup map from builtin filament names (includes cloud presets from parent)
-    const builtinMap = new Map<string, string>();
-    for (const bf of builtinFilaments) {
-      builtinMap.set(bf.filament_id, bf.name);
-    }
-
-    const filamentMap = new Map<string, { id: string; name: string }>();
-    for (const p of existingProfiles) {
-      if (p.filament_id && !filamentMap.has(p.filament_id)) {
-        // Prefer builtin name (accurate), fall back to extracting from profile name
-        const builtinName = builtinMap.get(p.filament_id);
-        const filamentName = builtinName || extractFilamentName(p.name || '');
-        filamentMap.set(p.filament_id, {
-          id: p.filament_id,
-          name: filamentName || p.filament_id,
-        });
-      }
-    }
-    return Array.from(filamentMap.values()).sort((a, b) =>
-      a.name.localeCompare(b.name)
-    );
-  }, [existingProfiles, builtinFilaments]);
+  const [filamentQuery, setFilamentQuery] = useState('');
+  // The modal defers its own close so the printer has time to process the
+  // command; that timer must not outlive the modal.
+  const { schedule: scheduleClose } = useCancellableTimeout();
+
+  // Name for the filament an existing profile is bound to. The builtin table
+  // (which the parent has already merged with the user's cloud presets) is
+  // authoritative; a profile whose filament_id is in neither falls back to the
+  // name the printer stored for it.
+  const editedFilamentName = React.useMemo(() => {
+    if (!profile?.filament_id) return '';
+    const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
+    if (builtinName) return builtinName;
+    const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
+    return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
+  }, [profile, existingProfiles, builtinFilaments]);
+
+  // The tiered list, grouped for rendering. Order is fixed app-wide —
+  // imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
+  // and buildFilamentPresetOptions has already sorted by it, so grouping is
+  // just a partition that preserves that order.
+  const presetGroups = React.useMemo(() => {
+    const labels: [FilamentPresetSource, string][] = [
+      ['local', t('kProfiles.modal.source.local')],
+      ['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
+      ['cloud', t('kProfiles.modal.source.bambuCloud')],
+      ['builtin', t('kProfiles.modal.source.builtin')],
+    ];
+    const query = filamentQuery.trim().toLowerCase();
+    const matches = query
+      ? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
+      : filamentPresets;
+    return labels
+      .map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
+      .filter(g => g.items.length > 0);
+  }, [filamentPresets, filamentQuery, t]);
 
   const saveMutation = useMutation({
     mutationFn: (data: KProfileCreate) => {
       console.log('[KProfile] Calling API...');
       return api.setKProfile(printerId, data);
     },
-    onSuccess: (result) => {
+    onSuccess: (result, variables) => {
       console.log('[KProfile] Save success:', result);
       showToast(t('kProfiles.toast.profileSaved'));
       // Save note if it changed (including clearing it)
@@ -252,8 +275,10 @@ function KProfileModal({
           // Editing: use setting_id if available, or composite key with slot_id
           profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
         } else {
-          // New profile: use name as key (will be matched when profile is loaded)
-          profileKey = `name_${name}_${filamentId}`;
+          // New profile: use name as key (matched against the reloaded profile,
+          // so it has to be the resolved filament_id that was sent — not the
+          // preset handle the user picked).
+          profileKey = `name_${name}_${variables.filament_id}`;
         }
         onSaveNote(profileKey, note);
       }
@@ -261,7 +286,7 @@ function KProfileModal({
       setIsSyncing(true);
       // Add delay before closing to give printer time to process the save
       // onSave will trigger refetch in the parent component
-      setTimeout(() => {
+      scheduleClose(() => {
         setIsSyncing(false);
         onSave();
       }, 2500);
@@ -285,7 +310,7 @@ function KProfileModal({
       setIsSyncing(true);
       // Add longer delay for delete - printer needs more time to process
       // before it can return the updated profile list
-      setTimeout(() => {
+      scheduleClose(() => {
         setIsSyncing(false);
         onClose();
       }, 4000);
@@ -333,12 +358,33 @@ function KProfileModal({
     const editNozzleId = profile ? profile.nozzle_id : nozzleId;
     const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
 
+    // The printer indexes its calibration table by filament_id, so the preset
+    // the user picked has to be reduced to one before anything is sent. Only
+    // the builtin tier and Bambu's official cloud presets carry one outright;
+    // a cloud *user* preset needs its detail fetched, and imported / Orca
+    // presets have no Bambu id at all and map to the generic for their
+    // material. Refuse rather than guess when nothing resolves — a profile
+    // filed under the wrong filament is invisible to the slot that needs it.
+    let resolvedFilamentId = profile?.filament_id || '';
+    if (!profile) {
+      const picked = filamentPresets.find(p => p.id === filamentChoice);
+      if (!picked) {
+        showToast(t('kProfiles.toast.selectFilament'), 'error');
+        return;
+      }
+      resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
+      if (!resolvedFilamentId) {
+        showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
+        return;
+      }
+    }
+
     // For editing or single extruder: just save one profile
     if (profile || selectedExtruders.length === 1) {
       const payload = {
         name: name,
         k_value: formattedKValue,
-        filament_id: filamentId,
+        filament_id: resolvedFilamentId,
         nozzle_id: editNozzleId,
         nozzle_diameter: editDiameter,
         extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
@@ -358,7 +404,7 @@ function KProfileModal({
     const batchPayload = selectedExtruders.map(extruderId => ({
       name: name,
       k_value: formattedKValue,
-      filament_id: filamentId,
+      filament_id: resolvedFilamentId,
       nozzle_id: nozzleId,
       nozzle_diameter: modalDiameter,
       extruder_id: extruderId,
@@ -373,7 +419,7 @@ function KProfileModal({
       showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
       // Save note for new batch profiles
       if (onSaveNote && note) {
-        const profileKey = `name_${name}_${filamentId}`;
+        const profileKey = `name_${name}_${resolvedFilamentId}`;
         onSaveNote(profileKey, note);
       }
     } catch (error) {
@@ -387,7 +433,7 @@ function KProfileModal({
     setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
     // Wait for final sync before closing
     // onSave will trigger refetch in the parent component
-    setTimeout(() => {
+    scheduleClose(() => {
       setIsSyncing(false);
       setSavingProgress({ current: 0, total: 0 });
       onSave();
@@ -471,43 +517,68 @@ function KProfileModal({
             {/* Filament - read-only when editing */}
             <div>
               <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
-              <select
-                value={filamentId}
-                onChange={(e) => {
-                  const newFilamentId = e.target.value;
-                  setFilamentId(newFilamentId);
-                  // Auto-generate profile name when filament is selected (for new profiles)
-                  // Only auto-generate if name is empty - don't overwrite user input
-                  if (!profile && newFilamentId && !name) {
-                    const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
-                    if (selectedFilament) {
-                      const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
-                      setName(`${flowLabel} ${selectedFilament.name}`);
-                    }
-                  }
-                }}
-                disabled={!!profile}
-                className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
-                required={!profile}
-              >
-                <option value="">{t('kProfiles.modal.selectFilament')}</option>
-                {/* Show current filament when editing - look up from knownFilaments */}
-                {profile?.filament_id && (
-                  <option key={profile.filament_id} value={profile.filament_id}>
-                    {knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
-                  </option>
-                )}
-                {/* Show known filaments from existing K-profiles (for new profiles) */}
-                {!profile && knownFilaments.map((f) => (
-                  <option key={f.id} value={f.id}>
-                    {f.name}
-                  </option>
-                ))}
-              </select>
-              {!profile && knownFilaments.length === 0 && (
-                <p className="text-xs text-bambu-gray mt-1">
-                  {t('kProfiles.modal.noFilamentsHelp')}
-                </p>
+              {profile ? (
+                // Editing or copying: the filament is fixed, so this is a
+                // readout rather than a control.
+                <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
+                  {editedFilamentName || profile.filament_id}
+                </div>
+              ) : (
+                // A real list rather than a <select>: Chrome ignores almost
+                // every CSS property on <optgroup>, so a source heading inside
+                // a native dropdown can't be made to stand out.
+                <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+                  <div className="relative border-b border-bambu-dark-tertiary">
+                    <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                    <input
+                      type="text"
+                      value={filamentQuery}
+                      onChange={(e) => setFilamentQuery(e.target.value)}
+                      placeholder={t('kProfiles.modal.searchFilaments')}
+                      className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
+                    />
+                  </div>
+                  <div className="max-h-56 overflow-y-auto bg-bambu-dark">
+                    {presetGroups.length === 0 ? (
+                      <p className="px-3 py-3 text-xs text-bambu-gray">
+                        {filamentPresets.length === 0
+                          ? t('kProfiles.modal.noFilamentsHelp')
+                          : t('kProfiles.modal.noFilamentMatches')}
+                      </p>
+                    ) : presetGroups.map((group) => (
+                      <div key={group.source}>
+                        <div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
+                          <span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
+                            {group.label}
+                          </span>
+                          <span className="text-[10px] text-bambu-gray">{group.items.length}</span>
+                        </div>
+                        {group.items.map((f) => (
+                          <button
+                            key={f.id}
+                            type="button"
+                            onClick={() => {
+                              setFilamentChoice(f.id);
+                              // Auto-generate the profile name, but never over
+                              // an entry the user typed.
+                              if (!name) {
+                                const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
+                                setName(`${flowLabel} ${f.name}`);
+                              }
+                            }}
+                            className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
+                              filamentChoice === f.id
+                                ? 'bg-bambu-green/20 text-white'
+                                : 'text-white hover:bg-bambu-dark-tertiary'
+                            }`}
+                          >
+                            {f.name}
+                          </button>
+                        ))}
+                      </div>
+                    ))}
+                  </div>
+                </div>
               )}
             </div>
 
@@ -522,8 +593,8 @@ function KProfileModal({
                     setNozzleType(newNozzleType);
                     // Update profile name when flow type changes (for new profiles)
                     // Only auto-generate if name is empty - don't overwrite user input
-                    if (!profile && filamentId && !name) {
-                      const selectedFilament = knownFilaments.find(f => f.id === filamentId);
+                    if (!profile && filamentChoice && !name) {
+                      const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
                       if (selectedFilament) {
                         const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
                         setName(`${flowLabel} ${selectedFilament.name}`);
@@ -795,13 +866,12 @@ export function KProfilesView() {
     refetchOnMount: 'always',  // Always refetch when component mounts
   });
 
-  // Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
-  const { data: allProfiles } = useQuery({
-    queryKey: ['kprofiles', selectedPrinter, '0.4'],
-    queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
-    enabled: !!selectedPrinter,
-    staleTime: 60000,  // Cache for 1 minute
-  });
+  // A second fetch for 0.4mm profiles used to seed the Add-Profile filament
+  // dropdown. The dropdown is built from the filament preset tiers now
+  // (#2719), so the round trip bought nothing — and it fired concurrently
+  // with the fetch above whenever a different nozzle was selected, which is
+  // exactly the two-requests-in-flight case that made K-profile fetches time
+  // out (#1748).
 
   // Fetch builtin filament names for accurate filament_id → name resolution
   const { data: builtinFilaments } = useQuery({
@@ -817,6 +887,28 @@ export function KProfilesView() {
     staleTime: 300000,  // Cache for 5 minutes
   });
 
+  // The other three filament tiers, so a printer with no K-profiles yet can
+  // still be given its first one (#2719). Each query stands alone and fails
+  // quietly: not being signed in to a cloud should thin the list, not break
+  // the page, and the builtin tier above guarantees it is never empty.
+  const { data: localPresets } = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+    retry: false,
+  });
+
+  const { data: orcaCloudList } = useQuery({
+    queryKey: ['orcaCloudProfilesForKProfiles'],
+    queryFn: () => api.orcaCloudListProfiles(),
+    retry: false,
+  });
+
+  const { data: cloudSettings } = useQuery({
+    queryKey: ['cloudSettings'],
+    queryFn: () => api.getCloudSettings(),
+    retry: false,
+  });
+
   // Fetch K-profile notes (stored locally)
   const {
     data: notesData,
@@ -883,6 +975,19 @@ export function KProfilesView() {
     }));
   }, [builtinFilamentMap]);
 
+  // Every filament this install knows about, ranked in the app-wide order:
+  // imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
+  // built-in table as the floor.
+  const filamentPresets = React.useMemo(
+    () => buildFilamentPresetOptions({
+      localPresets: localPresets?.filament,
+      orcaProfiles: orcaCloudList?.filament,
+      cloudSettings: cloudSettings?.filament,
+      builtinFilaments,
+    }),
+    [localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
+  );
+
   // Resolve filament name: builtin table first, then extract from profile name
   const resolveFilamentName = React.useCallback((profile: KProfile) => {
     return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
@@ -1494,8 +1599,9 @@ export function KProfilesView() {
             profile={editingProfile}
             printerId={selectedPrinter}
             nozzleDiameter={nozzleDiameter}
-            existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+            existingProfiles={kprofiles?.profiles}
             builtinFilaments={enrichedBuiltinFilaments}
+            filamentPresets={filamentPresets}
             isDualNozzle={isDualNozzle}
             initialNote={note}
             initialNoteKey={key}
@@ -1519,8 +1625,9 @@ export function KProfilesView() {
         <KProfileModal
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          existingProfiles={kprofiles?.profiles}
           builtinFilaments={enrichedBuiltinFilaments}
+          filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
@@ -1540,8 +1647,9 @@ export function KProfilesView() {
         <KProfileModal
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          existingProfiles={kprofiles?.profiles}
           builtinFilaments={enrichedBuiltinFilaments}
+          filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}

+ 37 - 0
frontend/src/hooks/useCancellableTimeout.ts

@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+/**
+ * setTimeout that cannot outlive the component that scheduled it.
+ *
+ * Modals here defer their own close by a second or more so the printer has
+ * time to process the command that was just sent. A plain setTimeout for that
+ * keeps a reference to setState and to the parent's onClose, and fires whether
+ * or not the modal is still mounted — closing an already-dismissed dialog, or
+ * throwing outright once the surrounding environment is gone ("window is not
+ * defined" when a test's DOM is torn down before the timer fires).
+ *
+ * Returns a schedule function. Scheduling again replaces any pending timer, and
+ * unmounting cancels it.
+ */
+export function useCancellableTimeout() {
+  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const cancel = useCallback(() => {
+    if (timer.current !== null) {
+      clearTimeout(timer.current);
+      timer.current = null;
+    }
+  }, []);
+
+  const schedule = useCallback((fn: () => void, ms: number) => {
+    cancel();
+    timer.current = setTimeout(() => {
+      timer.current = null;
+      fn();
+    }, ms);
+  }, [cancel]);
+
+  useEffect(() => cancel, [cancel]);
+
+  return { schedule, cancel };
+}

+ 11 - 1
frontend/src/i18n/locales/de.ts

@@ -5034,7 +5034,15 @@ export default {
       kValueHelp: 'Typischer Bereich: 0,01 - 0,06 für PLA, 0,02 - 0,10 für PETG',
       filament: 'Filament',
       selectFilament: 'Filament auswählen...',
-      noFilamentsHelp: 'Keine Filamente gefunden. Erstellen Sie zuerst ein K-Profil in Bambu Studio.',
+      source: {
+        local: 'Importiert',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integriert',
+      },
+      noFilamentsHelp: 'Keine Filamente verfügbar. Melde dich bei Bambu Cloud an oder importiere Presets unter Profile → Lokale Profile.',
+      searchFilaments: 'Filamente durchsuchen...',
+      noFilamentMatches: 'Kein Filament passt zu dieser Suche',
       flowType: 'Flusstyp',
       highFlow: 'Hoher Durchfluss',
       standard: 'Standard',
@@ -5068,6 +5076,8 @@ export default {
       profileSaved: 'K-Profil gespeichert',
       profilesSaved: 'K-Profil auf {{count}} Extrudern gespeichert',
       selectAtLeastOneExtruder: 'Bitte wählen Sie mindestens einen Extruder aus',
+      selectFilament: 'Bitte zuerst ein Filament auswählen',
+      filamentNotResolvable: 'Keine Bambu-Filament-ID für {{name}} — der Drucker kann dafür kein Profil speichern',
       profileDeleted: 'K-Profil gelöscht',
       profilesDeleted: '{{count}} Profile gelöscht',
       exportedProfiles: '{{count}} Profile exportiert',

+ 11 - 1
frontend/src/i18n/locales/en.ts

@@ -5078,7 +5078,15 @@ export default {
       kValueHelp: 'Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG',
       filament: 'Filament',
       selectFilament: 'Select filament...',
-      noFilamentsHelp: 'No filaments found. Create a K-profile in Bambu Studio first.',
+      source: {
+        local: 'Imported',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Built-in',
+      },
+      noFilamentsHelp: 'No filaments available. Sign in to Bambu Cloud, or import presets under Profiles → Local Profiles.',
+      searchFilaments: 'Search filaments...',
+      noFilamentMatches: 'No filament matches that search',
       flowType: 'Flow Type',
       highFlow: 'High Flow',
       standard: 'Standard',
@@ -5112,6 +5120,8 @@ export default {
       profileSaved: 'K-profile saved',
       profilesSaved: 'K-profile saved to {{count}} extruders',
       selectAtLeastOneExtruder: 'Please select at least one extruder',
+      selectFilament: 'Select a filament first',
+      filamentNotResolvable: 'No Bambu filament ID for {{name}} — the printer cannot store a profile for it',
       profileDeleted: 'K-profile deleted',
       profilesDeleted: 'Deleted {{count}} profiles',
       exportedProfiles: 'Exported {{count}} profiles',

+ 11 - 1
frontend/src/i18n/locales/es.ts

@@ -5043,7 +5043,15 @@ export default {
       kValueHelp: 'Rango típico: 0,01 - 0,06 para PLA, 0,02 - 0,10 para PETG',
       filament: 'Filamento',
       selectFilament: 'Seleccionar filamento...',
-      noFilamentsHelp: 'No se encontraron filamentos. Cree primero un perfil K en Bambu Studio.',
+      source: {
+        local: 'Importado',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrado',
+      },
+      noFilamentsHelp: 'No hay filamentos disponibles. Inicia sesión en Bambu Cloud o importa ajustes en Perfiles → Perfiles locales.',
+      searchFilaments: 'Buscar filamentos...',
+      noFilamentMatches: 'Ningún filamento coincide con esa búsqueda',
       flowType: 'Tipo de flujo',
       highFlow: 'Flujo alto',
       standard: 'Estándar',
@@ -5077,6 +5085,8 @@ export default {
       profileSaved: 'Perfil K guardado',
       profilesSaved: 'Perfil K guardado en {{count}} extrusores',
       selectAtLeastOneExtruder: 'Seleccione al menos un extrusor',
+      selectFilament: 'Selecciona primero un filamento',
+      filamentNotResolvable: 'No hay ID de filamento Bambu para {{name}}: la impresora no puede guardar un perfil',
       profileDeleted: 'Perfil K eliminado',
       profilesDeleted: 'Se eliminaron {{count}} perfiles',
       exportedProfiles: 'Se exportaron {{count}} perfiles',

+ 11 - 1
frontend/src/i18n/locales/fr.ts

@@ -5024,7 +5024,15 @@ export default {
       kValueHelp: 'Plage type : 0.01-0.06 (PLA), 0.02-0.10 (PETG)',
       filament: 'Filament',
       selectFilament: 'Choisir filament...',
-      noFilamentsHelp: 'Créez d\'abord un profil dans Bambu Studio.',
+      source: {
+        local: 'Importé',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Inclus',
+      },
+      noFilamentsHelp: 'Aucun filament disponible. Connectez-vous à Bambu Cloud ou importez des préréglages dans Profils → Profils locaux.',
+      searchFilaments: 'Rechercher des filaments...',
+      noFilamentMatches: 'Aucun filament ne correspond à cette recherche',
       flowType: 'Type de débit',
       highFlow: 'Haut Débit (HF)',
       standard: 'Standard',
@@ -5058,6 +5066,8 @@ export default {
       profileSaved: 'Profil K enregistré',
       profilesSaved: 'Profil K enregistré sur {{count}} extrudeur(s)',
       selectAtLeastOneExtruder: 'Sélectionnez un extrudeur',
+      selectFilament: 'Sélectionnez d’abord un filament',
+      filamentNotResolvable: 'Aucun identifiant de filament Bambu pour {{name}} — l’imprimante ne peut pas enregistrer de profil',
       profileDeleted: 'Profil K supprimé',
       profilesDeleted: '{{count}} profils supprimés',
       exportedProfiles: '{{count}} profils exportés',

+ 11 - 1
frontend/src/i18n/locales/it.ts

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: 'Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG',
       filament: 'Filamento',
       selectFilament: 'Seleziona filamento...',
-      noFilamentsHelp: 'Nessun filamento trovato. Crea prima un K-profile in Bambu Studio.',
+      source: {
+        local: 'Importato',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrato',
+      },
+      noFilamentsHelp: 'Nessun filamento disponibile. Accedi a Bambu Cloud o importa i preset da Profili → Profili locali.',
+      searchFilaments: 'Cerca filamenti...',
+      noFilamentMatches: 'Nessun filamento corrisponde alla ricerca',
       flowType: 'Tipo flow',
       highFlow: 'Alto flusso',
       standard: 'Standard',
@@ -5057,6 +5065,8 @@ export default {
       profileSaved: 'K-profile salvato',
       profilesSaved: 'K-profile salvato su {{count}} estrusori',
       selectAtLeastOneExtruder: 'Seleziona almeno un estrusore',
+      selectFilament: 'Seleziona prima un filamento',
+      filamentNotResolvable: 'Nessun ID filamento Bambu per {{name}}: la stampante non può salvare un profilo',
       profileDeleted: 'K-profile eliminato',
       profilesDeleted: 'Eliminati {{count}} profili',
       exportedProfiles: 'Esportati {{count}} profili',

+ 11 - 1
frontend/src/i18n/locales/ja.ts

@@ -5035,7 +5035,15 @@ export default {
       kValueHelp: '一般的な範囲: PLA 0.01〜0.06、PETG 0.02〜0.10',
       filament: 'フィラメント',
       selectFilament: 'フィラメントを選択...',
-      noFilamentsHelp: 'フィラメントが見つかりません。Bambu Studioでまずプロファイルを作成してください。',
+      source: {
+        local: 'インポート済み',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '内蔵',
+      },
+      noFilamentsHelp: '利用できるフィラメントがありません。Bambu Cloud にログインするか、プロファイル → ローカルプロファイル でプリセットをインポートしてください。',
+      searchFilaments: 'フィラメントを検索...',
+      noFilamentMatches: '検索に一致するフィラメントはありません',
       flowType: 'フロータイプ',
       highFlow: 'ハイフロー',
       standard: 'スタンダード',
@@ -5069,6 +5077,8 @@ export default {
       profileSaved: 'Kプロファイルを保存しました',
       profilesSaved: 'Kプロファイルを{{count}}台のエクストルーダーに保存しました',
       selectAtLeastOneExtruder: 'エクストルーダーを1つ以上選択してください',
+      selectFilament: '先にフィラメントを選択してください',
+      filamentNotResolvable: '{{name}} に対応する Bambu フィラメント ID がないため、プリンターはプロファイルを保存できません',
       profileDeleted: 'Kプロファイルを削除しました',
       profilesDeleted: '{{count}}件のプロファイルを削除しました',
       exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',

+ 11 - 1
frontend/src/i18n/locales/ko.ts

@@ -4778,7 +4778,15 @@ export default {
       kValueHelp: '일반 범위: PLA 0.01~0.06, PETG 0.02~0.10',
       filament: '필라멘트',
       selectFilament: '필라멘트 선택...',
-      noFilamentsHelp: '필라멘트를 찾을 수 없습니다. 먼저 Bambu Studio에서 K-프로필을 만드세요.',
+      source: {
+        local: '가져온 것',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '기본 제공',
+      },
+      noFilamentsHelp: '사용할 수 있는 필라먼트가 없습니다. Bambu Cloud에 로그인하거나 프로파일 → 로컬 프로파일에서 프리셋을 가져오세요.',
+      searchFilaments: '필라먼트 검색...',
+      noFilamentMatches: '검색과 일치하는 필라먼트가 없습니다',
       flowType: '유량 유형',
       highFlow: '고유량',
       standard: '표준',
@@ -4809,6 +4817,8 @@ export default {
       profileSaved: 'K-프로필 저장됨',
       profilesSaved: '{{count}}개 압출기에 K-프로필 저장됨',
       selectAtLeastOneExtruder: '적어도 하나의 압출기를 선택해 주세요',
+      selectFilament: '먼저 필라먼트를 선택하세요',
+      filamentNotResolvable: '{{name}}에 해당하는 Bambu 필라먼트 ID가 없어 프린터가 프로파일을 저장할 수 없습니다',
       profileDeleted: 'K-프로필 삭제됨',
       profilesDeleted: '{{count}}개 프로필 삭제됨',
       exportedProfiles: '{{count}}개 프로필 내보냄',

+ 11 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',
       filament: 'Filamento',
       selectFilament: 'Selecionar filamento...',
-      noFilamentsHelp: 'Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.',
+      source: {
+        local: 'Importado',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrado',
+      },
+      noFilamentsHelp: 'Nenhum filamento disponível. Entre na Bambu Cloud ou importe predefinições em Perfis → Perfis Locais.',
+      searchFilaments: 'Buscar filamentos...',
+      noFilamentMatches: 'Nenhum filamento corresponde a essa busca',
       flowType: 'Tipo de Fluxo',
       highFlow: 'Alto Fluxo',
       standard: 'Padrão',
@@ -5057,6 +5065,8 @@ export default {
       profileSaved: 'K-profile salvo',
       profilesSaved: 'K-profile salvo em {{count}} extrusores',
       selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
+      selectFilament: 'Selecione um filamento primeiro',
+      filamentNotResolvable: 'Sem ID de filamento Bambu para {{name}} — a impressora não consegue armazenar um perfil',
       profileDeleted: 'K-profile excluído',
       profilesDeleted: '{{count}} perfis excluídos',
       exportedProfiles: '{{count}} perfis exportados',

+ 11 - 1
frontend/src/i18n/locales/ru.ts

@@ -4766,7 +4766,15 @@ export default {
       kValueHelp: "Типичный диапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       filament: "Филамент",
       selectFilament: "Выберите филамент...",
-      noFilamentsHelp: "Филаменты не найдены. Сначала создайте K-профиль в Bambu Studio.",
+      source: {
+        local: "Импортированные",
+        orcaCloud: "Orca Cloud",
+        bambuCloud: "Bambu Cloud",
+        builtin: "Встроенный",
+      },
+      noFilamentsHelp: "Нет доступных филаментов. Войдите в Bambu Cloud или импортируйте пресеты в разделе Профили → Локальные профили.",
+      searchFilaments: "Поиск филаментов...",
+      noFilamentMatches: "Нет филаментов, соответствующих запросу",
       flowType: "Тип потока",
       highFlow: "Высокопоточный",
       standard: "Стандартный",
@@ -4797,6 +4805,8 @@ export default {
       profileSaved: "K-профиль сохранён",
       profilesSaved: "K-профиль сохранён для {{count}} экструдеров",
       selectAtLeastOneExtruder: "Выберите хотя бы один экструдер",
+      selectFilament: "Сначала выберите филамент",
+      filamentNotResolvable: "Нет идентификатора филамента Bambu для {{name}} — принтер не сможет сохранить профиль",
       profileDeleted: "K-профиль удалён",
       profilesDeleted: "Удалено профилей: {{count}}",
       exportedProfiles: "Экспортировано профилей: {{count}}",

+ 11 - 1
frontend/src/i18n/locales/tr.ts

@@ -5003,7 +5003,15 @@ export default {
       kValueHelp: 'Tipik aralık: PLA için 0.01 - 0.06, PETG için 0.02 - 0.10',
       filament: 'Filament',
       selectFilament: 'Filament seç...',
-      noFilamentsHelp: 'Filament bulunamadı. Önce Bambu Studio\'da bir K-profili oluşturun.',
+      source: {
+        local: 'İçe aktarılmış',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Yerleşik',
+      },
+      noFilamentsHelp: 'Kullanılabilir filament yok. Bambu Cloud’a giriş yapın veya Profiller → Yerel Profiller altından hızır ayarları içe aktarın.',
+      searchFilaments: 'Filament ara...',
+      noFilamentMatches: 'Bu aramayla eşleşen filament yok',
       flowType: 'Akış Türü',
       highFlow: 'Yüksek Akış',
       standard: 'Standart',
@@ -5034,6 +5042,8 @@ export default {
       profileSaved: 'K-profili kaydedildi',
       profilesSaved: '{{count}} ekstrüdere K-profili kaydedildi',
       selectAtLeastOneExtruder: 'Lütfen en az bir ekstrüder seçin',
+      selectFilament: 'Önce bir filament seçin',
+      filamentNotResolvable: '{{name}} için Bambu filament kimliği yok — yazıcı bunun için profil saklayamaz',
       profileDeleted: 'K-profili silindi',
       profilesDeleted: '{{count}} profil silindi',
       exportedProfiles: '{{count}} profil dışa aktarıldı',

+ 11 - 1
frontend/src/i18n/locales/uk.ts

@@ -5078,7 +5078,15 @@ export default {
       kValueHelp: "Типовий діапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       filament: "Філамент",
       selectFilament: "Виберіть філамент...",
-      noFilamentsHelp: "Філаменти не знайдено. Спочатку створіть K-профіль у Bambu Studio.",
+      source: {
+        local: "Імпортовані",
+        orcaCloud: "Orca Cloud",
+        bambuCloud: "Bambu Cloud",
+        builtin: "Вбудований",
+      },
+      noFilamentsHelp: "Немає доступних філаментів. Увійдіть у Bambu Cloud або імпортуйте пресети в розділі Профілі → Локальні профілі.",
+      searchFilaments: "Пошук філаментів...",
+      noFilamentMatches: "Немає філаментів, що відповідають запиту",
       flowType: "Тип потоку",
       highFlow: "Сопло з високим потоком",
       standard: "Стандартний",
@@ -5112,6 +5120,8 @@ export default {
       profileSaved: "K-профіль збережено",
       profilesSaved: "K-профіль збережено в екструдери {{count}}.",
       selectAtLeastOneExtruder: "Виберіть принаймні один екструдер",
+      selectFilament: "Спочатку виберіть філамент",
+      filamentNotResolvable: "Немає ідентифікатора філаменту Bambu для {{name}} — принтер не зможе зберегти профіль",
       profileDeleted: "K-профіль видалено",
       profilesDeleted: "Видалені профілі {{count}}.",
       exportedProfiles: "Експортовані профілі {{count}}.",

+ 11 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: '典型范围:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       filament: '耗材',
       selectFilament: '选择耗材...',
-      noFilamentsHelp: '未找到耗材。请先在 Bambu Studio 中创建 K 值配置。',
+      source: {
+        local: '已导入',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '内置',
+      },
+      noFilamentsHelp: '没有可用的耗材。请登录 Bambu Cloud,或在“配置 → 本地配置”中导入预设。',
+      searchFilaments: '搜索耗材...',
+      noFilamentMatches: '没有符合搜索条件的耗材',
       flowType: '流量类型',
       highFlow: '高流量',
       standard: '标准',
@@ -5057,6 +5065,8 @@ export default {
       profileSaved: 'K 值配置已保存',
       profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',
       selectAtLeastOneExtruder: '请至少选择一个挤出机',
+      selectFilament: '请先选择耗材',
+      filamentNotResolvable: '没有与 {{name}} 对应的 Bambu 耗材 ID,打印机无法保存该配置',
       profileDeleted: 'K 值配置已删除',
       profilesDeleted: '已删除 {{count}} 个配置',
       exportedProfiles: '已导出 {{count}} 个配置',

+ 11 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: '典型範圍:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       filament: '耗材',
       selectFilament: '選擇耗材...',
-      noFilamentsHelp: '未找到耗材。請先在 Bambu Studio 中建立 K 值設定。',
+      source: {
+        local: '已匯入',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '內建',
+      },
+      noFilamentsHelp: '沒有可用的耗材。請登入 Bambu Cloud,或在「設定檔 → 本地設定檔」中匯入預設。',
+      searchFilaments: '搜尋耗材...',
+      noFilamentMatches: '沒有符合搜尋條件的耗材',
       flowType: '流量類型',
       highFlow: '高流量',
       standard: '標準',
@@ -5057,6 +5065,8 @@ export default {
       profileSaved: 'K 值設定已儲存',
       profilesSaved: 'K 值設定已儲存到 {{count}} 個擠出機',
       selectAtLeastOneExtruder: '請至少選擇一個擠出機',
+      selectFilament: '請先選擇耗材',
+      filamentNotResolvable: '沒有與 {{name}} 對應的 Bambu 耗材 ID,印表機無法儲存該設定檔',
       profileDeleted: 'K 值設定已刪除',
       profilesDeleted: '已刪除 {{count}} 個設定',
       exportedProfiles: '已匯出 {{count}} 個設定',

+ 247 - 0
frontend/src/utils/filamentPresets.ts

@@ -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 '';
+  }
+}

File diff suppressed because it is too large
+ 1 - 0
static/assets/index-C_6BSgrK.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-E-CRp_kM.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-oReXTzKG.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-QysAxcAd.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
+    <script type="module" crossorigin src="/assets/index-E-CRp_kM.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff