Browse Source

feat(slicer): filter process & filament profiles by selected printer (issue #1325)

  The Slice dialog listed every process / filament preset regardless of
  the chosen printer (#1325). Picking a printer profile now filters both
  dropdowns to compatible presets; presets resolving to a different Bambu
  model move into a trailing "Other printers" group.

  Matching uses the slicer's own compatible_printers list for imported
  (local) presets, and falls back to the "@BBL <model>" name suffix for
  cloud / standard presets where no compatibility metadata is available,
  so all three tiers are covered. Compatibility-unknown presets (custom
  or untagged) are never hidden. The pre-pick and printer-switch paths
  follow the same rule.

  - UnifiedPreset gains compatible_printers, exposed for the local tier
  - new frontend util slicerPrinterMatch.ts with the matching logic
  - slice.otherPrinters added across all 9 locales
maziggy 6 days ago
parent
commit
7eba29624b

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.5b1] - Unreleased
 
 ### Added
+- **Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905)** — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own `compatible_printers` list for imported (local) presets, and falls back to the `@BBL <model>` name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. New `frontend/src/utils/slicerPrinterMatch.ts` (11 unit tests); `UnifiedPreset` now carries `compatible_printers`, exposed for the local tier (`backend/app/api/routes/slicer_presets.py`). Parity green, build clean.
 - **Spanish (es) translation (#1243, requested by @MiguelAngelLV)** — Bambuddy now ships a full European Spanish locale. New `frontend/src/i18n/locales/es.ts` translates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered in `frontend/src/i18n/index.ts` and selectable as "Español" in the language picker. The parity checker auto-discovers the file — `frontend/scripts/check-i18n-parity.mjs` gained an `ES_COGNATES` allow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean.
 - **Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns)** — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added `BZD: 'BZ$'` to `frontend/src/utils/currency.ts` next to MXN (Americas dollar-prefix grouping); `getCurrencySymbol('BZD')` returns `'BZ$'` and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in `frontend/src/__tests__/utils/currency.test.ts` covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean.
 - **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green.

+ 25 - 5
backend/app/api/routes/slicer_presets.py

@@ -172,15 +172,35 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
         slot = type_to_slot.get(p.preset_type)
         if slot is None:
             continue
-        extra: dict[str, str | None] = {}
+        preset = UnifiedPreset(id=str(p.id), name=p.name, source="local")
         if slot == "filament":
-            extra["filament_type"], extra["filament_colour"] = _parse_filament_metadata(p.setting)
-        slots[slot].append(
-            UnifiedPreset(id=str(p.id), name=p.name, source="local", **extra),
-        )
+            preset.filament_type, preset.filament_colour = _parse_filament_metadata(p.setting)
+        if slot in ("process", "filament"):
+            # Precise compatibility link — the slicer's own compatible_printers
+            # list, captured at import time. Lets the SliceModal filter the
+            # process / filament dropdowns by the selected printer without
+            # falling back to its name-suffix heuristic.
+            preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
+        slots[slot].append(preset)
     return slots
 
 
+def _parse_compatible_printers(raw: str | None) -> list[str] | None:
+    """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
+    names. Return the parsed list, or ``None`` on missing / malformed data so
+    the SliceModal falls back to its name-suffix heuristic for that preset."""
+    if not raw:
+        return None
+    try:
+        data = json.loads(raw)
+    except (ValueError, TypeError):
+        return None
+    if not isinstance(data, list):
+        return None
+    names = [s for s in data if isinstance(s, str) and s.strip()]
+    return names or None
+
+
 def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
     """Extract first-slot ``filament_type`` and ``filament_colour`` from a
     stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we

+ 9 - 0
backend/app/schemas/slicer_presets.py

@@ -31,6 +31,14 @@ class UnifiedPreset(BaseModel):
     the multi-color flow by matching against the source 3MF's per-slot type
     and color. Populated when the underlying preset JSON exposes them; left
     as ``None`` on bundled profiles where colour is a runtime spool attribute.
+
+    ``compatible_printers`` is the slicer's own list of printer-preset names a
+    process / filament preset declares itself valid for. Populated for the
+    local tier (stored at import time); left ``None`` for cloud (no per-preset
+    detail is fetched — rate limits) and standard (the sidecar's bundled
+    listing doesn't expose it). The SliceModal uses it to filter the
+    process / filament dropdowns by the selected printer (#1325), falling back
+    to a name-suffix heuristic when it is ``None``.
     """
 
     id: str
@@ -38,6 +46,7 @@ class UnifiedPreset(BaseModel):
     source: Literal["cloud", "local", "standard"]
     filament_type: str | None = None
     filament_colour: str | None = None
+    compatible_printers: list[str] | None = None
 
 
 class UnifiedPresetsBySlot(BaseModel):

+ 28 - 0
backend/tests/unit/test_slicer_presets.py

@@ -642,3 +642,31 @@ class TestBundleRoutes:
         ):
             await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None)
         assert exc.value.status_code == 404
+
+
+class TestParseCompatiblePrinters:
+    """``compatible_printers`` exposed for local process / filament presets so
+    the SliceModal can filter the dropdowns by the selected printer (#1325)."""
+
+    def test_parses_json_array(self):
+        raw = '["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle"]'
+        assert sp._parse_compatible_printers(raw) == [
+            "Bambu Lab X1 Carbon 0.4 nozzle",
+            "Bambu Lab X1 0.4 nozzle",
+        ]
+
+    def test_none_and_empty_return_none(self):
+        assert sp._parse_compatible_printers(None) is None
+        assert sp._parse_compatible_printers("") is None
+        assert sp._parse_compatible_printers("[]") is None
+
+    def test_malformed_json_returns_none(self):
+        assert sp._parse_compatible_printers("not json") is None
+        # A JSON value that isn't an array is treated as absent, not an error.
+        assert sp._parse_compatible_printers('"a string"') is None
+
+    def test_drops_non_string_and_blank_entries(self):
+        assert sp._parse_compatible_printers('["X1C", 5, "", "  ", "A1"]') == [
+            "X1C",
+            "A1",
+        ]

+ 96 - 0
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -0,0 +1,96 @@
+import { describe, it, expect } from 'vitest';
+import {
+  printerPresetCode,
+  presetModelCodes,
+  presetCompatibility,
+} from '../../utils/slicerPrinterMatch';
+
+describe('printerPresetCode', () => {
+  it('maps Bambu stock printer names to model codes', () => {
+    expect(printerPresetCode('Bambu Lab X1 Carbon 0.4 nozzle')).toBe('X1C');
+    expect(printerPresetCode('Bambu Lab X1E 0.4 nozzle')).toBe('X1E');
+    expect(printerPresetCode('Bambu Lab X1 0.4 nozzle')).toBe('X1');
+    expect(printerPresetCode('Bambu Lab P1S 0.4 nozzle')).toBe('P1S');
+    expect(printerPresetCode('Bambu Lab P1P 0.4 nozzle')).toBe('P1P');
+    expect(printerPresetCode('Bambu Lab A1 mini 0.4 nozzle')).toBe('A1M');
+    expect(printerPresetCode('Bambu Lab A1 0.4 nozzle')).toBe('A1');
+    expect(printerPresetCode('Bambu Lab H2D 0.4 nozzle')).toBe('H2D');
+  });
+
+  it('prefers the more specific name when prefixes overlap', () => {
+    // "X1 Carbon" must not be read as bare "X1"; "A1 mini" not as "A1".
+    expect(printerPresetCode('X1 Carbon')).toBe('X1C');
+    expect(printerPresetCode('A1 mini')).toBe('A1M');
+  });
+
+  it('returns null for an unrecognised / custom printer', () => {
+    expect(printerPresetCode('My Custom Voron 0.4')).toBeNull();
+    expect(printerPresetCode('')).toBeNull();
+  });
+});
+
+describe('presetModelCodes', () => {
+  it('parses a single model from the @BBL suffix', () => {
+    expect([...presetModelCodes('0.20mm Standard @BBL X1C')]).toEqual(['X1C']);
+    expect([...presetModelCodes('Bambu PLA Basic @BBL A1M')]).toEqual(['A1M']);
+  });
+
+  it('parses multiple models from one suffix', () => {
+    const codes = presetModelCodes('0.20mm Standard @BBL X1C X1');
+    expect(codes.has('X1C')).toBe(true);
+    expect(codes.has('X1')).toBe(true);
+    expect(codes.size).toBe(2);
+  });
+
+  it('returns an empty set for generic / untagged presets', () => {
+    expect(presetModelCodes('Generic PLA @base').size).toBe(0);
+    expect(presetModelCodes('My Custom Process').size).toBe(0);
+  });
+});
+
+describe('presetCompatibility', () => {
+  it('uses compatible_printers exactly when present (local-tier override)', () => {
+    const preset = {
+      name: 'My Process',
+      compatible_printers: ['Bambu Lab X1 Carbon 0.4 nozzle'],
+    };
+    expect(
+      presetCompatibility(preset, 'Bambu Lab X1 Carbon 0.4 nozzle', 'X1C'),
+    ).toBe('match');
+    expect(presetCompatibility(preset, 'Bambu Lab A1 0.4 nozzle', 'A1')).toBe(
+      'mismatch',
+    );
+  });
+
+  it('falls back to the name heuristic when compatible_printers is absent', () => {
+    const preset = { name: '0.20mm Standard @BBL X1C' };
+    expect(presetCompatibility(preset, 'Bambu Lab X1 Carbon 0.4 nozzle', 'X1C')).toBe(
+      'match',
+    );
+    expect(presetCompatibility(preset, 'Bambu Lab A1 0.4 nozzle', 'A1')).toBe(
+      'mismatch',
+    );
+  });
+
+  it('is unknown when the preset carries no resolvable model', () => {
+    expect(presetCompatibility({ name: 'Generic PLA @base' }, 'Bambu Lab X1 Carbon', 'X1C')).toBe(
+      'unknown',
+    );
+  });
+
+  it('is unknown when the selected printer is unrecognised', () => {
+    expect(
+      presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'My Custom Printer', null),
+    ).toBe('unknown');
+  });
+
+  it('is unknown when compatible_printers is set but no printer is selected', () => {
+    expect(
+      presetCompatibility(
+        { name: 'P', compatible_printers: ['Bambu Lab X1 Carbon 0.4 nozzle'] },
+        null,
+        null,
+      ),
+    ).toBe('unknown');
+  });
+});

+ 6 - 0
frontend/src/api/client.ts

@@ -1321,6 +1321,12 @@ export interface UnifiedPreset {
   // responses pre-date these fields entirely.
   filament_type?: string | null;
   filament_colour?: string | null;
+  // Printer-preset names a process / filament preset declares itself
+  // compatible with. Populated for the local tier (the slicer's own
+  // `compatible_printers`); null for cloud / standard. The SliceModal filters
+  // the process / filament dropdowns by the selected printer using this when
+  // present, falling back to a name-suffix heuristic otherwise (#1325).
+  compatible_printers?: string[] | null;
 }
 export interface UnifiedPresetsBySlot {
   printer: UnifiedPreset[];

+ 161 - 33
frontend/src/components/SliceModal.tsx

@@ -20,6 +20,7 @@ import { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
 import type { PlateFilament } from '../types/plates';
 import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
+import { presetCompatibility, printerPresetCode } from '../utils/slicerPrinterMatch';
 
 export type SliceSource =
   | { kind: 'libraryFile'; id: number; filename: string }
@@ -50,6 +51,39 @@ function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
   return null;
 }
 
+// Resolve a PresetRef back to its UnifiedPreset within the named slot, or
+// null if it no longer resolves (e.g. the preset was deleted between the
+// listing fetch and selection).
+function findPreset(
+  by: UnifiedPresetsResponse,
+  ref: PresetRef | null,
+  slot: Slot,
+): UnifiedPreset | null {
+  if (!ref) return null;
+  return by[ref.source][slot].find((p) => p.id === ref.id) ?? null;
+}
+
+// Process default (#1325): first preset compatible with the selected printer
+// in tier order, then the first whose compatibility is merely unknown, then
+// plain priority. Keeps the pre-pick honest with the printer filter instead
+// of blindly taking list[0].
+function pickProcessDefault(
+  by: UnifiedPresetsResponse,
+  printerName: string | null,
+  printerCode: string | null,
+): PresetRef | null {
+  for (const wanted of ['match', 'unknown'] as const) {
+    for (const tier of SLICE_MODAL_TIER_ORDER) {
+      for (const p of by[tier].process) {
+        if (presetCompatibility(p, printerName, printerCode) === wanted) {
+          return { source: p.source, id: p.id };
+        }
+      }
+    }
+  }
+  return pickDefault(by, 'process');
+}
+
 const TIER_BONUS: Record<PresetSource, number> = {
   local: 1.5,
   cloud: 1.0,
@@ -59,6 +93,8 @@ const TIER_BONUS: Record<PresetSource, number> = {
 function pickFilamentForSlot(
   by: UnifiedPresetsResponse,
   required: { type: string; color: string },
+  printerName: string | null,
+  printerCode: string | null,
 ): PresetRef | null {
   // Score every filament preset against the plate slot's required (type,
   // colour) and pick the highest. Mirrors the AMS slot-mapping match in the
@@ -80,6 +116,12 @@ function pickFilamentForSlot(
         else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
       }
       score += TIER_BONUS[tier];
+      // Demote printer-incompatible filaments (#1325): a penalty rather than a
+      // hard skip so the pick still degrades gracefully if every filament
+      // mismatches the selected printer.
+      if (presetCompatibility(p, printerName, printerCode) === 'mismatch') {
+        score -= 100;
+      }
       if (best == null || score > best.score) {
         best = { ref: { source: p.source, id: p.id }, score };
       }
@@ -357,32 +399,68 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   }, [selectedBundleId, bundlesQuery.data]);
   const isBundleMode = selectedBundle != null;
 
-  // Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
-  // presets first arrive; subsequent re-renders preserve any manual choice.
+  // Selected-printer context for the process / filament filter (#1325).
+  const selectedPrinterName = useMemo<string | null>(() => {
+    if (!presetsQuery.data || !printerPreset) return null;
+    return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
+  }, [presetsQuery.data, printerPreset]);
+  const selectedPrinterCode = useMemo<string | null>(
+    () => (selectedPrinterName ? printerPresetCode(selectedPrinterName) : null),
+    [selectedPrinterName],
+  );
+
+  // Printer pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when presets
+  // first arrive; subsequent re-renders preserve any manual choice.
   useEffect(() => {
     if (!presetsQuery.data) return;
     if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
-    if (processPreset == null) setProcessPreset(pickDefault(presetsQuery.data, 'process'));
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [presetsQuery.data]);
 
-  // Filament pre-pick: re-runs whenever the active filament-slot count
-  // changes (plate selection, single-plate metadata arriving). For each slot
-  // we score every available filament preset against the slot's required
-  // (type, colour) and keep the highest match. Slot count mismatch → reset
-  // and re-pick everything; same length → preserve any user override.
+  // Process pre-pick / re-pick (#1325): defaults to a process compatible with
+  // the selected printer, and re-defaults when a printer change leaves the
+  // current process incompatible. A compatible or unknown manual pick is kept.
   useEffect(() => {
-    if (!presetsQuery.data) return;
     const data = presetsQuery.data;
-    setFilamentPresets((current) => {
-      if (current.length === filamentSlots.length && current.every((r) => r != null)) {
-        return current;
+    if (!data) return;
+    setProcessPreset((current) => {
+      if (current) {
+        const p = findPreset(data, current, 'process');
+        if (p && presetCompatibility(p, selectedPrinterName, selectedPrinterCode) !== 'mismatch') {
+          return current;
+        }
       }
-      return filamentSlots.map((slot) =>
-        pickFilamentForSlot(data, { type: slot.type, color: slot.color }),
-      );
+      return pickProcessDefault(data, selectedPrinterName, selectedPrinterCode);
     });
-  }, [presetsQuery.data, filamentSlots]);
+  }, [presetsQuery.data, selectedPrinterName, selectedPrinterCode]);
+
+  // Filament pre-pick: re-runs when the active filament-slot count changes
+  // (plate selection, single-plate metadata arriving) or the selected printer
+  // changes. Each slot scores every available filament preset against the
+  // slot's required (type, colour); an existing pick (incl. a user override)
+  // is kept as long as it's still compatible with the selected printer, while
+  // null slots and printer-incompatible picks are re-picked (#1325).
+  useEffect(() => {
+    const data = presetsQuery.data;
+    if (!data) return;
+    setFilamentPresets((current) => {
+      return filamentSlots.map((slot, i) => {
+        const cur = current[i] ?? null;
+        if (cur) {
+          const p = findPreset(data, cur, 'filament');
+          if (p && presetCompatibility(p, selectedPrinterName, selectedPrinterCode) !== 'mismatch') {
+            return cur;
+          }
+        }
+        return pickFilamentForSlot(
+          data,
+          { type: slot.type, color: slot.color },
+          selectedPrinterName,
+          selectedPrinterCode,
+        );
+      });
+    });
+  }, [presetsQuery.data, filamentSlots, selectedPrinterName, selectedPrinterCode]);
 
   // Bundle-mode auto-pick: when the user picks a bundle (or the slot count
   // changes after the picker is open), default the process to the bundle's
@@ -603,6 +681,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                     value={processPreset}
                     onChange={setProcessPreset}
                     disabled={isEnqueuing}
+                    selectedPrinterName={selectedPrinterName}
+                    selectedPrinterCode={selectedPrinterCode}
                   />
                 </>
               )}
@@ -719,6 +799,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                       }
                       disabled={isEnqueuing || !isUsed}
                       swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
+                      selectedPrinterName={selectedPrinterName}
+                      selectedPrinterCode={selectedPrinterCode}
                     />
                   );
                 })
@@ -864,29 +946,66 @@ interface PresetDropdownProps {
   // filament slots so the user can see at a glance which slot they're
   // configuring against the source 3MF's per-slot colour.
   swatchColor?: string;
+  // Selected printer context (#1325). When provided for a process / filament
+  // slot, presets that resolve to a different printer move into a trailing
+  // "Other printers" group instead of the main tier list.
+  selectedPrinterName?: string | null;
+  selectedPrinterCode?: string | null;
 }
 
-function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor }: PresetDropdownProps) {
+function PresetDropdown({
+  label,
+  slot,
+  data,
+  value,
+  onChange,
+  disabled,
+  swatchColor,
+  selectedPrinterName,
+  selectedPrinterCode,
+}: PresetDropdownProps) {
   const { t } = useTranslation();
 
-  const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
-    // Order matches SLICE_MODAL_TIER_ORDER: imported first, then cloud, then
-    // standard fallback. Sections with no entries collapse out so a user
-    // without cloud / local presets only sees the tiers they actually have.
-    const tiers: { key: keyof UnifiedPresetsResponse; tier: 'cloud' | 'local' | 'standard'; label: string; fallback: string }[] = [
-      { key: 'local', tier: 'local', label: 'slice.tier.local', fallback: 'Imported' },
-      { key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
-      { key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
+  // Tier sections (imported → cloud → standard), plus — for a process /
+  // filament slot with a selected printer — a trailing group of presets that
+  // resolve to a different printer (#1325). Compatibility-unknown presets
+  // stay in their tier, so a custom / untagged preset is never hidden, and
+  // empty sections collapse out.
+  const { sections, otherEntries } = useMemo(() => {
+    const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
+      { key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
+      { key: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
+      { key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
     ];
-    return tiers
-      .map(({ key, label: lk, fallback }) => ({
-        tierLabel: t(lk, fallback),
-        entries: (data[key] as UnifiedPresetsBySlot)[slot],
-      }))
-      .filter((s) => s.entries.length > 0);
-  }, [data, slot, t]);
+    const filterByPrinter = slot !== 'printer';
+    const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
+    const other: UnifiedPreset[] = [];
+    for (const { key, label: lk, fallback } of tiers) {
+      const entries = (data[key] as UnifiedPresetsBySlot)[slot];
+      if (!filterByPrinter) {
+        if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
+        continue;
+      }
+      const compatible: UnifiedPreset[] = [];
+      for (const p of entries) {
+        if (
+          presetCompatibility(p, selectedPrinterName ?? null, selectedPrinterCode ?? null) ===
+          'mismatch'
+        ) {
+          other.push(p);
+        } else {
+          compatible.push(p);
+        }
+      }
+      if (compatible.length > 0) {
+        compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
+      }
+    }
+    return { sections: compatSections, otherEntries: other };
+  }, [data, slot, t, selectedPrinterName, selectedPrinterCode]);
 
-  const totalEntries = sections.reduce((sum, s) => sum + s.entries.length, 0);
+  const totalEntries =
+    sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;
 
   return (
     <label className="block">
@@ -920,6 +1039,15 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchCo
             ))}
           </optgroup>
         ))}
+        {otherEntries.length > 0 && (
+          <optgroup label={t('slice.otherPrinters')}>
+            {otherEntries.map((p) => (
+              <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
+                {p.name}
+              </option>
+            ))}
+          </optgroup>
+        )}
       </select>
     </label>
   );

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

@@ -3444,6 +3444,7 @@ export default {
     previewWithProgress: '{{name}} wird analysiert — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— wird von dieser Platte nicht verwendet',
     noPresetsForSlot: 'Keine Profile verfügbar',
+    otherPrinters: 'Andere Drucker',
     presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     bundle: 'Slicer-Bundle',

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

@@ -3447,6 +3447,7 @@ export default {
     previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— not used by this plate',
     noPresetsForSlot: 'No presets available',
+    otherPrinters: 'Other printers',
     presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
     allPresetsRequired: 'All presets must be selected',
     bundle: 'Slicer bundle',

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

@@ -3447,6 +3447,7 @@ export default {
     previewWithProgress: 'Analizando {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— no usado por esta cama',
     noPresetsForSlot: 'No hay preajustes disponibles',
+    otherPrinters: 'Otras impresoras',
     presetsLoadFailed: 'Error al cargar los preajustes. Abra Ajustes → Perfiles para importarlos primero.',
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
     bundle: 'Paquete del laminador',

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

@@ -3433,6 +3433,7 @@ export default {
     previewWithProgress: 'Analyse de {{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
     notUsedByPlate: '— non utilisé par cette plaque',
     noPresetsForSlot: 'Aucun préréglage disponible',
+    otherPrinters: 'Autres imprimantes',
     presetsLoadFailed: 'Échec du chargement des préréglages. Ouvrez Paramètres → Profils pour les importer d\'abord.',
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
     bundle: 'Pack de découpage',

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

@@ -3432,6 +3432,7 @@ export default {
     previewWithProgress: 'Analisi di {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     notUsedByPlate: '— non usato da questo piano',
     noPresetsForSlot: 'Nessun preset disponibile',
+    otherPrinters: 'Altre stampanti',
     presetsLoadFailed: 'Caricamento preset fallito. Apri Impostazioni → Profili per importarli prima.',
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
     bundle: 'Bundle slicer',

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

@@ -3444,6 +3444,7 @@ export default {
     previewWithProgress: '{{name}}を分析中 – {{stage}} ({{percent}}%) – {{elapsed}}',
     notUsedByPlate: '— このプレートでは使用しない',
     noPresetsForSlot: 'プリセットなし',
+    otherPrinters: '他のプリンター',
     presetsLoadFailed: 'プリセットの読み込みに失敗。先に設定 → プロファイルからインポートしてください。',
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
     bundle: 'スライサーバンドル',

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

@@ -3432,6 +3432,7 @@ export default {
     previewWithProgress: 'Analisando {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
     notUsedByPlate: '— não usado por esta mesa',
     noPresetsForSlot: 'Nenhuma predefinição disponível',
+    otherPrinters: 'Outras impressoras',
     presetsLoadFailed: 'Falha ao carregar predefinições. Abra Configurações → Perfis para importá-las primeiro.',
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
     bundle: 'Pacote do fatiador',

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

@@ -3432,6 +3432,7 @@ export default {
     previewWithProgress: '分析 {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— 此打印板未使用',
     noPresetsForSlot: '无可用预设',
+    otherPrinters: '其他打印机',
     presetsLoadFailed: '加载预设失败。请先打开设置 → 配置文件以导入。',
     allPresetsRequired: '必须选择所有预设',
     bundle: '切片器套装',

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

@@ -3432,6 +3432,7 @@ export default {
     previewWithProgress: '分析 {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     notUsedByPlate: '— 此列印板未使用',
     noPresetsForSlot: '無可用預設',
+    otherPrinters: '其他印表機',
     presetsLoadFailed: '載入預設失敗。請先開啟設定 → 設定檔以匯入。',
     allPresetsRequired: '必須選擇所有預設',
     bundle: '切片器套裝',

+ 101 - 0
frontend/src/utils/slicerPrinterMatch.ts

@@ -0,0 +1,101 @@
+// Printer-compatibility matching for the SliceModal's process / filament
+// dropdowns (#1325). Bambuddy can't filter every preset tier the same way:
+//
+//   - local presets carry the slicer's own `compatible_printers` list (a
+//     precise list of printer-preset names) — used directly when present.
+//   - cloud / standard presets carry no compatibility data, so we fall back
+//     to the "@BBL <model>" suffix Bambu Studio / OrcaSlicer embed in preset
+//     names (e.g. "0.20mm Standard @BBL X1C").
+//
+// Either way the result drives grouping, not hard hiding: a preset whose
+// compatibility can't be determined stays in the main list, and only a
+// preset that resolves to a *different* printer is pushed into an
+// "Other printers" group.
+
+// Canonical Bambu Lab model codes exactly as they appear in preset-name
+// suffixes. Order is irrelevant here — this is a membership set.
+const KNOWN_MODEL_CODES = new Set([
+  'X1C',
+  'X1E',
+  'X1',
+  'P1S',
+  'P1P',
+  'A1M',
+  'A1',
+  'H2D',
+  'H2S',
+]);
+
+// Ordered printer-preset-name patterns → model code. First match wins, so
+// more specific names ("X1 Carbon", "A1 mini") must precede their prefixes
+// ("X1", "A1").
+const PRINTER_NAME_PATTERNS: { re: RegExp; code: string }[] = [
+  { re: /x1\s*-?\s*carbon/i, code: 'X1C' },
+  { re: /\bx1c\b/i, code: 'X1C' },
+  { re: /\bx1e\b/i, code: 'X1E' },
+  { re: /\bx1\b/i, code: 'X1' },
+  { re: /\bp1s\b/i, code: 'P1S' },
+  { re: /\bp1p\b/i, code: 'P1P' },
+  { re: /a1\s*mini/i, code: 'A1M' },
+  { re: /\ba1m\b/i, code: 'A1M' },
+  { re: /\ba1\b/i, code: 'A1' },
+  { re: /\bh2d\b/i, code: 'H2D' },
+  { re: /\bh2s\b/i, code: 'H2S' },
+];
+
+/**
+ * Derive a Bambu model code from a printer-preset name
+ * (e.g. "Bambu Lab X1 Carbon 0.4 nozzle" → "X1C"). Returns null when the
+ * name matches no known model — a custom / third-party printer, for which
+ * we can't filter and so show every preset.
+ */
+export function printerPresetCode(name: string): string | null {
+  for (const { re, code } of PRINTER_NAME_PATTERNS) {
+    if (re.test(name)) return code;
+  }
+  return null;
+}
+
+/**
+ * Parse the "@BBL <model>..." suffix of a process / filament preset name into
+ * the set of model codes it targets. A preset can list several
+ * ("... @BBL X1C X1"); a generic preset ("Generic PLA @base") yields an
+ * empty set, meaning "applies everywhere / can't tell".
+ */
+export function presetModelCodes(name: string): Set<string> {
+  const at = name.lastIndexOf('@');
+  if (at < 0) return new Set();
+  const tokens = name
+    .slice(at + 1)
+    .toUpperCase()
+    .split(/[\s,]+/)
+    .filter(Boolean);
+  return new Set(tokens.filter((tok) => KNOWN_MODEL_CODES.has(tok)));
+}
+
+export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
+
+/**
+ * Classify a process / filament preset against the selected printer.
+ *
+ * - 'match'    — the preset is compatible with the selected printer.
+ * - 'mismatch' — the preset resolves to a *different* printer.
+ * - 'unknown'  — compatibility can't be determined (no data, generic preset,
+ *                or an unrecognised printer); the caller should not hide it.
+ */
+export function presetCompatibility(
+  preset: { name: string; compatible_printers?: string[] | null },
+  selectedPrinterName: string | null,
+  selectedPrinterCode: string | null,
+): PrinterCompatibility {
+  // Precise link first: the slicer's own compatible_printers list (local tier).
+  const compat = preset.compatible_printers;
+  if (compat && compat.length > 0) {
+    if (!selectedPrinterName) return 'unknown';
+    return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
+  }
+  // Heuristic fallback: the "@BBL <model>" suffix in the preset name.
+  const codes = presetModelCodes(preset.name);
+  if (codes.size === 0 || !selectedPrinterCode) return 'unknown';
+  return codes.has(selectedPrinterCode) ? 'match' : 'mismatch';
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-RSlhNz-h.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-Cv5ljbAV.js"></script>
+    <script type="module" crossorigin src="/assets/index-RSlhNz-h.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-QwBJJHPy.css">
   </head>
   <body>

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