Просмотр исходного кода

fix(ams): offer every K profile the printer holds for a generic filament preset (#2710)

    The reporter's A1 mini has nine Flow Dynamics calibrations, all of them saved
    under Generic PLA and named after the spool's colour — "Dark Brown", "Glow",
    "Marble". Bambu Studio lists all nine for that slot. Configure AMS Slot offered
    one: the profile already bound to the slot. After a slot reset it offered none,
    leaving the slicer as the only way to assign a K value.

    Two independent faults, both tripped by picking a built-in generic preset.

    The filament-id match discarded Bambu's generic GFx99 ids as too broad. But the
    comparison already requires both sides to carry the same id, so that exclusion
    could only ever fire when the selected preset was itself the generic one —
    precisely the case where the match is right. The printer keeps one calibration
    table per filament id, so a slot on Generic PLA should offer everything
    calibrated under Generic PLA. Equal ids now match, generic or not.

    The name fallback was dead for the same presets: parsePresetName reads the
    leading "Generic" in "Generic PLA" as a manufacturer, which put the matcher into
    brand-gated mode and demanded the word GENERIC appear in the profile name. No
    real profile has it. "Generic" is no longer treated as a brand, so profiles still
    match on material when a printer reports no filament_id with its calibrations.

    The one profile that did appear came from the #1689 safety net that always
    surfaces the slot's active cali_idx — which is also why a reset slot, having no
    active profile, showed an empty list.

    Neither fix can be complete on its own, because profile names are free text and
    nothing ties "Marble" to a material. The picker now also lists every remaining
    profile on the printer under "Other K profiles on this printer", so a profile
    that exists can always be selected. Applying one from that group needs no new
    backend work: configure_ams_slot already realigns the slot's filament context to
    the chosen profile's, which is what makes the cali_idx stick.

    Options are keyed by name+k_value rather than the bare name, so two profiles
    sharing a name are no longer indistinguishable in the select. Both render blocks
    carry the change — the modal duplicates the picker for its full-screen variant.

    isMatchingCalibration gets the same generic-id rule for the spool form's PA
    suggester, with two guards. A new generic-id-to-material table means a PETG spool
    can never claim GFL99 profiles just because both sides stored a generic id
    (Nylon and PA compare as one material). And a spool that names its own brand
    keeps the stricter name path, so its suggestions stay brand-specific rather than
    becoming the printer's whole generic table.
maziggy 1 месяц назад
Родитель
Сommit
180e2acbe8

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 192 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -536,6 +536,198 @@ describe('ConfigureAmsSlotModal', () => {
     });
   });
 
+  describe('Generic presets and the K-profile picker (#2710)', () => {
+    // Reporter's printer: nine Flow-Dynamics entries, every one of them
+    // calibrated against Generic PLA (filament_id GFL99) and named after the
+    // spool's colour rather than its material. Bambu Studio lists all nine for
+    // a Generic PLA slot; Bambuddy showed only the one already bound to the
+    // slot via cali_idx.
+    const genericPlaProfiles = [
+      'Black PLA+', 'Dark Brown', 'Glow', 'Gray', 'Lt Brown',
+      'Marble', 'Orange PLA', 'Sunlu White PLA+', 'White PLA+ Duramic',
+    ].map((name, i) => ({
+      slot_id: i + 1,
+      extruder_id: 0,
+      nozzle_id: 'HH00-0.4',
+      nozzle_diameter: '0.4',
+      filament_id: 'GFL99',
+      name,
+      k_value: `0.0${30 + i}`,
+      n_coef: '0',
+      ams_id: 0,
+      tray_id: 0,
+      setting_id: '',
+    }));
+
+    const genericPlaSlot = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'builtin_GFL99',
+      extruderId: 0,
+    };
+
+    beforeEach(() => {
+      (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { filament_id: 'GFL99', name: 'Generic PLA', filament_type: 'PLA' },
+      ]);
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: genericPlaProfiles,
+      });
+    });
+
+    it('offers every Generic PLA profile when the slot preset is Generic PLA', async () => {
+      render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={genericPlaSlot} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument();
+      });
+      // All nine, not just the one bound via cali_idx — matching the printer's
+      // own calibration table for GFL99.
+      for (const profile of genericPlaProfiles) {
+        expect(
+          screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` }),
+        ).toBeInTheDocument();
+      }
+    });
+
+    it('offers them on a freshly reset slot with no active cali_idx', async () => {
+      // "When I reset the AMS slot, the generic PLA shows no k-values at all."
+      // With no cali_idx the #1689 safety net has nothing to surface, so the
+      // list came back empty; the id match has to stand on its own.
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument();
+      });
+      expect(screen.getByRole('option', { name: /Glow/ })).toBeInTheDocument();
+    });
+
+    it('matches on name when the profile carries no filament_id at all', async () => {
+      // Not every firmware fills filament_id in extrusion_cali_get. "Generic"
+      // is not a brand, so the name path must fall back to the material
+      // instead of demanding "GENERIC" in the profile name.
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: [
+          { ...genericPlaProfiles[0], filament_id: '', name: 'Orange PLA' },
+          { ...genericPlaProfiles[1], filament_id: '', name: 'Dark Brown' },
+        ],
+      });
+      render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={genericPlaSlot} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Orange PLA/ })).toBeInTheDocument();
+      });
+    });
+
+    it('does not sweep generic profiles into a brand preset', async () => {
+      // Guard against the id path widening: "Bambu PLA Basic" is GFL05, so
+      // GFL99 profiles must still be filtered out of the matching group and
+      // only reachable through the explicit "other profiles" group.
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...defaultProps.slotInfo, savedPresetId: 'GFSL05_09', extruderId: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
+      });
+      // Every GFL99 profile is demoted to the other group: the brand gate on
+      // "Bambu" still applies, and none of these names carry it.
+      for (const profile of genericPlaProfiles) {
+        const option = screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` });
+        expect(option.closest('optgroup')).toHaveAttribute(
+          'label',
+          'Other K profiles on this printer',
+        );
+      }
+    });
+
+    it("offers the printer's other profiles even when nothing matches the preset", async () => {
+      // The escape hatch: a PETG preset matches none of the PLA profiles, but
+      // the user can still reach every profile the printer holds instead of
+      // being sent to the slicer.
+      (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' },
+      ]);
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, savedPresetId: 'builtin_GFG99', caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument();
+      });
+      expect(screen.getByRole('option', { name: /Dark Brown/ }).closest('optgroup')).toHaveAttribute(
+        'label',
+        'Other K profiles on this printer',
+      );
+    });
+
+    it('sends the cali_idx of a profile picked from the other-profiles group', async () => {
+      (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' },
+      ]);
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, savedPresetId: 'builtin_GFG99', caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument();
+      });
+      const marble = genericPlaProfiles.find(p => p.name === 'Marble')!;
+      fireEvent.change(screen.getByRole('combobox'), {
+        target: { value: `${marble.name}|${marble.k_value}` },
+      });
+      fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+      await waitFor(() => {
+        expect(api.configureAmsSlot).toHaveBeenCalled();
+      });
+      const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+      expect(payload.cali_idx).toBe(marble.slot_id);
+      expect(payload.kprofile_filament_id).toBe('GFL99');
+    });
+
+    it('distinguishes two profiles that share a name but differ in K', async () => {
+      // The picker used to key options by name alone, so same-named profiles
+      // were indistinguishable and the first always won.
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: [
+          { ...genericPlaProfiles[0], slot_id: 1, name: 'PLA', k_value: '0.020' },
+          { ...genericPlaProfiles[1], slot_id: 2, name: 'PLA', k_value: '0.045' },
+        ],
+      });
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /K=0.045/ })).toBeInTheDocument();
+      });
+      fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA|0.045' } });
+      fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+      await waitFor(() => {
+        expect(api.configureAmsSlot).toHaveBeenCalled();
+      });
+      expect((api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3].cali_idx).toBe(2);
+    });
+  });
+
   it('does not include the active K-profile when caliIdx is 0 or null (#1689 guard)', async () => {
     // cali_idx == 0 / null means no profile is active (printer default 0.020).
     // The safety net only triggers for activeIdx > 0 — otherwise unrelated

+ 78 - 3
frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts

@@ -12,6 +12,8 @@ import {
   isMatchingCalibration,
   toFilamentId,
   isGenericFilamentId,
+  materialForGenericFilamentId,
+  genericFilamentIdMatchesMaterial,
 } from '../../../components/spool-form/utils';
 
 describe('toFilamentId', () => {
@@ -70,6 +72,37 @@ describe('isGenericFilamentId', () => {
   });
 });
 
+describe('materialForGenericFilamentId (#2710)', () => {
+  it('resolves the material each generic id stands for', () => {
+    expect(materialForGenericFilamentId('GFL99')).toBe('PLA');
+    expect(materialForGenericFilamentId('GFG99')).toBe('PETG');
+    expect(materialForGenericFilamentId('gfu99')).toBe('TPU');
+  });
+
+  it('returns empty for specific ids and for nothing', () => {
+    expect(materialForGenericFilamentId('GFL05')).toBe('');
+    expect(materialForGenericFilamentId(null)).toBe('');
+    expect(materialForGenericFilamentId('')).toBe('');
+  });
+});
+
+describe('genericFilamentIdMatchesMaterial (#2710)', () => {
+  it('agrees when the generic id describes that material', () => {
+    expect(genericFilamentIdMatchesMaterial('GFL99', 'PLA')).toBe(true);
+    expect(genericFilamentIdMatchesMaterial('GFL99', 'pla')).toBe(true);
+  });
+
+  it('treats Nylon and PA as the same material', () => {
+    expect(genericFilamentIdMatchesMaterial('GFN99', 'Nylon')).toBe(true);
+  });
+
+  it('rejects a mismatched material, a specific id, or a missing material', () => {
+    expect(genericFilamentIdMatchesMaterial('GFL99', 'PETG')).toBe(false);
+    expect(genericFilamentIdMatchesMaterial('GFL05', 'PLA')).toBe(false);
+    expect(genericFilamentIdMatchesMaterial('GFL99', '')).toBe(false);
+  });
+});
+
 describe('isMatchingCalibration (#1688)', () => {
   const formData = {
     material: 'PETG',
@@ -98,9 +131,10 @@ describe('isMatchingCalibration (#1688)', () => {
     ).toBe(true);
   });
 
-  it('skips id-match for generic GFx99 ids and falls through to name match', () => {
-    // GFL99 = generic PLA, shared across many real filaments. Even if the
-    // spool stored GFL99, name parsing must drive the decision.
+  it('skips id-match when a generic id contradicts the spool material', () => {
+    // GFL99 is generic *PLA* but the spool says PETG — the ids agreeing is not
+    // enough, the material has to agree too. Name parsing then drives the
+    // decision and rejects it.
     const result = isMatchingCalibration(
       { name: 'Random thing with no PETG in it', filament_id: 'GFL99' },
       { ...formData, slicer_filament: 'GFL99' },
@@ -108,6 +142,47 @@ describe('isMatchingCalibration (#1688)', () => {
     expect(result).toBe(false);
   });
 
+  it('matches a generic id when the material agrees and the spool claims no brand (#2710)', () => {
+    // Reporter's printer: every K-profile calibrated under Generic PLA and
+    // named after the colour. No name parsing can tie "Dark Brown" to PLA, so
+    // the shared GFL99 is the only signal there is.
+    expect(
+      isMatchingCalibration(
+        { name: 'Dark Brown', filament_id: 'GFL99' },
+        { material: 'PLA', brand: '', subtype: '', slicer_filament: 'GFL99' },
+      ),
+    ).toBe(true);
+  });
+
+  it('treats "Generic" as no brand on the generic id path', () => {
+    expect(
+      isMatchingCalibration(
+        { name: 'Marble', filament_id: 'GFL99' },
+        { material: 'PLA', brand: 'Generic', subtype: '', slicer_filament: 'GFL99' },
+      ),
+    ).toBe(true);
+  });
+
+  it('keeps generic id matches brand-specific when the spool names a brand', () => {
+    // A spool that says "Sunlu" should still get Sunlu-specific suggestions
+    // rather than the printer's whole generic-PLA table.
+    expect(
+      isMatchingCalibration(
+        { name: 'Dark Brown', filament_id: 'GFL99' },
+        { material: 'PLA', brand: 'Sunlu', subtype: '', slicer_filament: 'GFL99' },
+      ),
+    ).toBe(false);
+  });
+
+  it('accepts Nylon/PA as the same material on the generic id path', () => {
+    expect(
+      isMatchingCalibration(
+        { name: 'spool-of-doom', filament_id: 'GFN99' },
+        { material: 'Nylon', brand: '', subtype: '', slicer_filament: 'GFN99' },
+      ),
+    ).toBe(true);
+  });
+
   it('falls through to name match when spool has no slicer_filament', () => {
     expect(
       isMatchingCalibration(

+ 90 - 19
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -5,7 +5,7 @@ import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'luc
 import { api } from '../api/client';
 import type { KProfile } from '../api/client';
 import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
-import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
+import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
 
@@ -99,6 +99,13 @@ function parsePresetName(name: string): { material: string; brand: string; varia
   return { material: withoutSuffix, brand: '', variant: '' };
 }
 
+// Identity of a K-profile inside the picker. Both profile lists are
+// deduplicated on name+k_value, so this is unique across the whole option set
+// — unlike the bare name, which two profiles can share (#2710).
+function kProfileOptionValue(profile: KProfile): string {
+  return `${profile.name}|${profile.k_value}`;
+}
+
 // Check if a preset is a user preset (not built-in)
 function isUserPreset(settingId: string): boolean {
   // Built-in presets have specific patterns, user presets are UUIDs
@@ -870,7 +877,12 @@ export function ConfigureAmsSlotModal({
     const { fullName, material, brand, filamentId } = selectedPresetInfo;
     const upperFullName = fullName.toUpperCase();
     const upperMaterial = material.toUpperCase();
-    const upperBrand = brand.toUpperCase();
+    // "Generic" leads every built-in Bambu preset name ("Generic PLA",
+    // "Generic PETG") but is not a manufacturer (#2710). Treating it as one
+    // put the filter into brand-gated mode and demanded "GENERIC" in the
+    // K-profile name, which no real profile has — so selecting a built-in
+    // generic preset matched nothing at all.
+    const upperBrand = brand.toUpperCase() === 'GENERIC' ? '' : brand.toUpperCase();
     const presetFid = filamentId; // already normalised via toFilamentId
 
     // Material must be at least 2 chars to avoid false positives
@@ -880,11 +892,18 @@ export function ConfigureAmsSlotModal({
     const filtered = kprofilesData.profiles.filter(p => {
       // Preferred: exact filament_id match (#1688). A user's custom K-profile
       // whose name doesn't agree with the slicer preset still surfaces when
-      // both sides agree on filament_id. Generic GFx99 IDs are excluded —
-      // they're shared across many filaments and over-match if id-compared.
+      // both sides agree on filament_id.
+      //
+      // Generic GFx99 ids count here too (#2710). The equality test already
+      // means both sides carry the *same* id, so the old "generic ids
+      // over-match" exclusion could only ever fire when the selected preset
+      // was itself the generic one — precisely the case where the match is
+      // right. The printer keeps one calibration table per filament_id, so a
+      // slot on "Generic PLA" should offer every profile calibrated under
+      // Generic PLA, whatever the user named them.
       if (presetFid) {
         const calFid = toFilamentId(p.filament_id);
-        if (calFid && calFid === presetFid && !isGenericFilamentId(calFid)) {
+        if (calFid && calFid === presetFid) {
           return true;
         }
       }
@@ -966,6 +985,46 @@ export function ConfigureAmsSlotModal({
     return result;
   }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
 
+  // Every remaining K-profile the printer holds, offered under a separate group
+  // after the matching ones (#2710). The matcher works off preset names and
+  // filament ids, neither of which the user controls when they name a profile
+  // after its colour — so there is always a residual chance it filters out a
+  // profile the user wants. This makes that recoverable in the UI instead of
+  // sending them to the slicer: the printer's own calibration table is the
+  // authority on what can be selected, and the backend realigns the slot's
+  // filament context to whichever profile is picked.
+  const otherKProfiles = useMemo(() => {
+    if (!kprofilesData?.profiles) return [];
+    const matched = new Set(matchingKProfiles.map(p => kProfileOptionValue(p)));
+    // Same name+k_value dedup as the matching list, so a multi-nozzle printer's
+    // duplicate rows don't show up twice here either.
+    const seen = new Map<string, KProfile>();
+    for (const profile of kprofilesData.profiles) {
+      const key = kProfileOptionValue(profile);
+      if (matched.has(key)) continue;
+      const existing = seen.get(key);
+      if (!existing) {
+        seen.set(key, profile);
+      } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
+        seen.set(key, profile);
+      }
+    }
+    return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
+  }, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]);
+
+  const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0;
+
+  const selectKProfileByValue = useCallback((value: string) => {
+    if (!value) {
+      setSelectedKProfile(null);
+      return;
+    }
+    const profile = matchingKProfiles.find(p => kProfileOptionValue(p) === value)
+      || otherKProfiles.find(p => kProfileOptionValue(p) === value)
+      || null;
+    setSelectedKProfile(profile);
+  }, [matchingKProfiles, otherKProfiles]);
+
   // Pre-select current profile when modal opens, reset when closes
   useEffect(() => {
     if (isOpen) {
@@ -1234,22 +1293,28 @@ export function ConfigureAmsSlotModal({
                       </span>
                     )}
                   </label>
-                  {matchingKProfiles.length > 0 ? (
+                  {hasAnyKProfile ? (
                     <div className="relative">
                       <select
-                        value={selectedKProfile?.name || ''}
-                        onChange={(e) => {
-                          const profile = matchingKProfiles.find(p => p.name === e.target.value);
-                          setSelectedKProfile(profile || null);
-                        }}
+                        value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
+                        onChange={(e) => selectKProfileByValue(e.target.value)}
                         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 appearance-none pr-10"
                       >
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         {matchingKProfiles.map((profile) => (
-                          <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
+                          <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                             {profile.name} (K={profile.k_value})
                           </option>
                         ))}
+                        {otherKProfiles.length > 0 && (
+                          <optgroup label={t('configureAmsSlot.otherKProfiles')}>
+                            {otherKProfiles.map((profile) => (
+                              <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
+                                {profile.name} (K={profile.k_value})
+                              </option>
+                            ))}
+                          </optgroup>
+                        )}
                       </select>
                       <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
                     </div>
@@ -1473,22 +1538,28 @@ export function ConfigureAmsSlotModal({
                     </span>
                   )}
                 </label>
-                {matchingKProfiles.length > 0 ? (
+                {hasAnyKProfile ? (
                   <div className="relative">
                     <select
-                      value={selectedKProfile?.name || ''}
-                      onChange={(e) => {
-                        const profile = matchingKProfiles.find(p => p.name === e.target.value);
-                        setSelectedKProfile(profile || null);
-                      }}
+                      value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
+                      onChange={(e) => selectKProfileByValue(e.target.value)}
                       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 appearance-none pr-10"
                     >
                       <option value="">{t('configureAmsSlot.noKProfile')}</option>
                       {matchingKProfiles.map((profile) => (
-                        <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
+                        <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                           {profile.name} (K={profile.k_value})
                         </option>
                       ))}
+                      {otherKProfiles.length > 0 && (
+                        <optgroup label={t('configureAmsSlot.otherKProfiles')}>
+                          {otherKProfiles.map((profile) => (
+                            <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
+                              {profile.name} (K={profile.k_value})
+                            </option>
+                          ))}
+                        </optgroup>
+                      )}
                     </select>
                     <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
                   </div>

+ 57 - 5
frontend/src/components/spool-form/utils.ts

@@ -378,13 +378,50 @@ export function toFilamentId(id: string | null | undefined): string {
 }
 
 // "GFx99" identifiers (GFL99, GFG99, GFB99, ...) are Bambu's *generic* filament
-// IDs — shared across many different physical filaments. Matching K-profiles
-// by an exact generic ID would over-match, so the id-match path skips them and
-// the caller falls through to name-based matching.
+// IDs — one per material, shared across every physical filament the user hasn't
+// given a specific preset. They still identify a material unambiguously, so an
+// exact generic id match is only ambiguous about *brand*, never about material.
 export function isGenericFilamentId(id: string | null | undefined): boolean {
   return !!id && /^GF[A-Z]99$/i.test(id);
 }
 
+// The material each generic Bambu filament ID stands for. Used to sanity-check
+// a generic id-match against the material the caller already knows (#2710): a
+// PETG spool must never claim GFL99 (generic PLA) profiles just because both
+// sides happen to have stored the same generic id.
+const GENERIC_FILAMENT_MATERIALS: Record<string, string> = {
+  GFB99: 'ABS',
+  GFC99: 'PC',
+  GFG99: 'PETG',
+  GFL99: 'PLA',
+  GFN99: 'PA',
+  GFP99: 'PE',
+  GFR99: 'EVA',
+  GFS99: 'PVA',
+  GFU99: 'TPU',
+};
+
+// Material a generic filament ID stands for ("GFL99" → "PLA"), or '' when the
+// ID isn't a known generic one.
+export function materialForGenericFilamentId(id: string | null | undefined): string {
+  if (!id) return '';
+  return GENERIC_FILAMENT_MATERIALS[id.toUpperCase()] || '';
+}
+
+// Bambu labels nylon "PA"; users routinely type "Nylon". Compare materials
+// through this so the two spellings agree.
+function normaliseMaterial(material: string): string {
+  const upper = material.trim().toUpperCase();
+  return upper === 'NYLON' ? 'PA' : upper;
+}
+
+// True when a generic filament ID may stand in for the given material — i.e.
+// the ID is generic and describes that same material.
+export function genericFilamentIdMatchesMaterial(id: string, material: string): boolean {
+  const generic = materialForGenericFilamentId(id);
+  return !!generic && !!material && normaliseMaterial(generic) === normaliseMaterial(material);
+}
+
 // Check if a calibration matches based on brand, material, and variant
 export function isMatchingCalibration(
   cal: { name?: string; filament_id?: string },
@@ -399,8 +436,23 @@ export function isMatchingCalibration(
   // "GFG98" without going anywhere near parsePresetName.
   const spoolFid = toFilamentId(formData.slicer_filament);
   const calFid = toFilamentId(cal.filament_id);
-  if (spoolFid && calFid && spoolFid === calFid && !isGenericFilamentId(calFid)) {
-    return true;
+  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 || '';

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

@@ -5935,6 +5935,7 @@ export default {
     filteringFor: 'Filtern nach: {{material}}',
     noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)',
     noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.',
+    otherKProfiles: 'Weitere K-Profile auf diesem Drucker',
     selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen',
     kFromCalibration: 'K={{value}} aus Druckerkalibrierung',
     customColorLabel: 'Benutzerdefinierte Farbe (optional)',

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

@@ -5979,6 +5979,7 @@ export default {
     filteringFor: 'Filtering for: {{material}}',
     noKProfile: 'No K profile (use default 0.020)',
     noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.',
+    otherKProfiles: 'Other K profiles on this printer',
     selectFilamentFirst: 'Select a filament profile first',
     kFromCalibration: 'K={{value}} from printer calibration',
     customColorLabel: 'Custom Color (optional)',

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

@@ -5944,6 +5944,7 @@ export default {
     filteringFor: 'Filtrando por: {{material}}',
     noKProfile: 'Sin perfil K (usar el predeterminado 0,020)',
     noMatchingKProfiles: 'No se encontraron perfiles K coincidentes. Se usará el K=0,020 predeterminado.',
+    otherKProfiles: 'Otros perfiles K en esta impresora',
     selectFilamentFirst: 'Seleccione primero un perfil de filamento',
     kFromCalibration: 'K={{value}} de la calibración de la impresora',
     customColorLabel: 'Color personalizado (opcional)',

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

@@ -5925,6 +5925,7 @@ export default {
     filteringFor: 'Filtrage pour : {{material}}',
     noKProfile: 'Pas de profil K (utiliser défaut 0.020)',
     noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.',
+    otherKProfiles: 'Autres profils K sur cette imprimante',
     selectFilamentFirst: 'Sélectionnez d\'abord un profil filament',
     kFromCalibration: 'K={{value}} de la calibration imprimante',
     customColorLabel: 'Couleur personnalisée (optionnel)',

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

@@ -5924,6 +5924,7 @@ export default {
     filteringFor: 'Filtrando per: {{material}}',
     noKProfile: 'Nessun profilo K (usa predefinito 0.020)',
     noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.',
+    otherKProfiles: 'Altri profili K su questa stampante',
     selectFilamentFirst: 'Seleziona prima un profilo filamento',
     kFromCalibration: 'K={{value}} dalla calibrazione stampante',
     customColorLabel: 'Colore personalizzato (opzionale)',

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

@@ -5936,6 +5936,7 @@ export default {
     filteringFor: 'フィルター中: {{material}}',
     noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)',
     noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',
+    otherKProfiles: 'このプリンターの他のKプロファイル',
     selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',
     kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)',
     customColorLabel: 'カスタム色(オプション)',

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

@@ -5626,6 +5626,7 @@ export default {
     filteringFor: '필터링 중: {{material}}',
     noKProfile: 'K 프로필 없음 (기본값 0.020 사용)',
     noMatchingKProfiles: '일치하는 K 프로필을 찾을 수 없습니다. 기본값 K=0.020이 사용됩니다.',
+    otherKProfiles: '이 프린터의 다른 K 프로필',
     selectFilamentFirst: '먼저 필라멘트 프로필을 선택하세요',
     kFromCalibration: 'K={{value}} (프린터 보정에서)',
     customColorLabel: '사용자 지정 색상 (선택사항)',

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

@@ -5924,6 +5924,7 @@ export default {
     filteringFor: 'Filtrando por: {{material}}',
     noKProfile: 'Nenhum perfil K (usar padrão 0.020)',
     noMatchingKProfiles: 'Nenhum perfil K correspondente encontrado. O K padrão=0.020 será usado.',
+    otherKProfiles: 'Outros perfis K nesta impressora',
     selectFilamentFirst: 'Selecione um perfil de filamento primeiro',
     kFromCalibration: 'K={{value}} da calibração da impressora',
     customColorLabel: 'Cor Personalizada (opcional)',

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

@@ -5613,6 +5613,7 @@ export default {
     filteringFor: "Фильтр по материалу: {{material}}",
     noKProfile: "Без K-профиля (стандартное значение 0,020)",
     noMatchingKProfiles: "Подходящие K-профили не найдены. Будет использовано стандартное значение K=0,020.",
+    otherKProfiles: "Другие K-профили на этом принтере",
     selectFilamentFirst: "Сначала выберите профиль филамента",
     kFromCalibration: "K={{value}} из калибровки принтера",
     customColorLabel: "Пользовательский цвет (необязательно)",

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

@@ -5880,6 +5880,7 @@ export default {
     filteringFor: 'Şu için filtreleniyor: {{material}}',
     noKProfile: 'K profili yok (varsayılan 0.020 kullan)',
     noMatchingKProfiles: 'Eşleşen K profili bulunamadı. Varsayılan K=0.020 kullanılacak.',
+    otherKProfiles: 'Bu yazıcıdaki diğer K profilleri',
     selectFilamentFirst: 'Önce bir filament profili seçin',
     kFromCalibration: 'Yazıcı kalibrasyonundan K={{value}}',
     customColorLabel: 'Özel Renk (isteğe bağlı)',

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

@@ -5979,6 +5979,7 @@ export default {
     filteringFor: "Фільтрування за: {{material}}",
     noKProfile: "Немає профілю K (використовуйте значення за замовчуванням 0,020)",
     noMatchingKProfiles: "Не знайдено відповідних K профілів. Використовуватиметься K=0,020 за замовчуванням.",
+    otherKProfiles: "Інші K-профілі на цьому принтері",
     selectFilamentFirst: "Спочатку виберіть профіль філаменту",
     kFromCalibration: "K={{value}} від калібрування принтера",
     customColorLabel: "Власний колір (необов’язково)",

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

@@ -5923,6 +5923,7 @@ export default {
     filteringFor: '筛选:{{material}}',
     noKProfile: '无 K 值配置(使用默认值 0.020)',
     noMatchingKProfiles: '未找到匹配的 K 值配置。将使用默认 K=0.020。',
+    otherKProfiles: '此打印机上的其他 K 值配置',
     selectFilamentFirst: '请先选择耗材配置',
     kFromCalibration: 'K={{value}}(来自打印机校准)',
     customColorLabel: '自定义颜色(可选)',

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

@@ -5923,6 +5923,7 @@ export default {
     filteringFor: '篩選:{{material}}',
     noKProfile: '無 K 值設定(使用預設值 0.020)',
     noMatchingKProfiles: '未找到匹配的 K 值設定。將使用預設 K=0.020。',
+    otherKProfiles: '此印表機上的其他 K 值設定',
     selectFilamentFirst: '請先選擇耗材設定',
     kFromCalibration: 'K={{value}}(來自印表機校準)',
     customColorLabel: '自訂顏色(可選)',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Bx2Rwvpi.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DYtiDfeG.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bx2Rwvpi.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов