Selaa lähdekoodia

fix(slicer): surface real CLI rejections + hard-skip mismatched filaments in auto-pick (#1851)

Two compounding bugs let an H2C-bound filament land in slot 1 of an A1
slice silently. (1) `_slicer_rejection_message` discarded the actual CLI
diagnostic - `filament preset Generic PLA @BBL H2C (slot 1) is not
compatible with printer Bambu Lab A1 0.4 nozzle.` - when the sidecar's
headline error_string was Bambu Studio's catch-all
`The input preset file is invalid and can not be parsed.` placeholder.
The real reason was in the stdout `[error] run NNNN:` line, trimmed off
before reaching the SliceJob's error_detail. (2) `pickFilamentForSlot`
used a soft `-100` mismatch penalty rather than a hard skip, leaving
the "never auto-fill an incompatible preset while a compatible one
exists" contract implicit. The unused-slot substitution in
`substitute_unused_plate_filaments` then propagated whatever slot 1
held across every unused slot - one bad pick poisoned the array.

(1) Mine `[error] <msg>` (with or without `run NNNN:`) from the full
pre-trim response; substitute the placeholder, keep meaningful
headlines. (2) Partition candidates into compatible/unknown vs
mismatch; prefer compatible whenever the bucket is non-empty, fall
back to mismatch only on graceful-degrade. Picker helpers moved out
of `SliceModal.tsx` into `utils/slicePresetPicker.ts` so the modal
file stays component-only (react-refresh lint).
maziggy 2 kuukautta sitten
vanhempi
sitoutus
425a3ac404

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
CHANGELOG.md


+ 35 - 1
backend/app/api/routes/library.py

@@ -3291,6 +3291,22 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
 # evaluate the job at all.
 _SLICER_REJECTION_MARKER = "Slicing failed with error from slicer:"
 
+# The CLI writes its real diagnostic to stdout/stderr on the `[error]` level.
+# Format is `[<timestamp>] [error] run <NNNN>: <message>` (or sometimes without
+# the `run NNNN:` prefix). The bracketed timestamp is optional; the `[error]`
+# tag is what we anchor on. Used to recover the actual rejection reason for
+# the `error_string: "The input preset file is invalid and can not be parsed."`
+# case (#1851) — the CLI emits that generic placeholder for every -5 exit
+# including real preset-compat rejections, and the per-incident specifics
+# only live in the stdout dump.
+_CLI_ERROR_LINE_RE = re.compile(r"\[error\]\s*(?:run\s+\d+:\s*)?(.+?)\s*$", re.MULTILINE)
+
+# The placeholder error_string Bambu Studio writes to result.json for any
+# `--load-settings` parse / compat rejection (-5 exit). When the sidecar
+# surfaces this, the real reason lives in the stdout `[error]` line that we
+# mine via _CLI_ERROR_LINE_RE.
+_INPUT_PRESET_INVALID_PLACEHOLDER = "The input preset file is invalid and can not be parsed."
+
 
 def _slicer_rejection_message(error_text: str) -> str | None:
     """Extract the slicer's own rejection reason from a sidecar error string,
@@ -3301,16 +3317,34 @@ def _slicer_rejection_message(error_text: str) -> str | None:
     no. Retrying with the 3MF's embedded settings would then only "succeed"
     by silently reverting to the source file's original printer, masking the
     real problem; such failures must reach the user instead.
+
+    When the sidecar's `error_string` is Bambu Studio's generic
+    "The input preset file is invalid and can not be parsed." placeholder
+    (#1851) — emitted for every -5 exit, including the actual preset-compat
+    rejections whose real reason is logged to stdout as
+    `[error] run NNNN: <diagnostic>` — prefer the stdout `[error]` line so
+    the user sees which preset clashed with which printer.
     """
     if _SLICER_REJECTION_MARKER not in error_text:
         return None
     reason = error_text.split(_SLICER_REJECTION_MARKER, 1)[1]
+    # Mine the stdout/stderr dump for a more specific CLI diagnostic before
+    # we trim it off below. Done first so the lookup window covers the full
+    # response, not just the headline.
+    cli_diagnostic_match = _CLI_ERROR_LINE_RE.search(reason)
+    cli_diagnostic = cli_diagnostic_match.group(1).strip() if cli_diagnostic_match else None
     # Trim the sidecar's trailing exit-code note and any stderr/stdout dump.
     for cut in (": Slicer process failed", "\nstderr:", "\nstdout:"):
         idx = reason.find(cut)
         if idx != -1:
             reason = reason[:idx]
-    return reason.strip() or None
+    reason = reason.strip() or None
+    # When the headline is Bambu Studio's catch-all placeholder, the real
+    # reason is in the stdout `[error]` line. Substitute it. The placeholder
+    # by itself tells the user nothing about why their slice was rejected.
+    if cli_diagnostic and (reason is None or reason == _INPUT_PRESET_INVALID_PLACEHOLDER):
+        return cli_diagnostic
+    return reason
 
 
 async def _run_slicer_with_fallback(

+ 44 - 0
backend/tests/integration/test_library_slice_api.py

@@ -1288,6 +1288,50 @@ class TestSlicerRejectionMessage:
         assert _slicer_rejection_message("") is None
         assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") is None
 
+    def test_replaces_input_preset_invalid_placeholder_with_cli_error_line(self):
+        # #1851: the CLI emits its catch-all "input preset file is invalid"
+        # placeholder for every -5 exit, including real preset-vs-printer
+        # compatibility rejections. The actual diagnostic only appears in the
+        # stdout `[error] run NNNN:` line; the function must prefer that.
+        text = (
+            "Slicer CLI failed (500): Slicing failed with error from slicer: "
+            "The input preset file is invalid and can not be parsed.: "
+            "Slicer process failed (exit code 251)\n"
+            "stdout: [2026-06-29 04:12:11.952784] [trace] Initializing StaticPrintConfigs\n"
+            "[2026-06-29 04:12:12.175810] [error] run 3008: filament preset "
+            "Generic PLA @BBL H2C (slot 1) is not compatible with printer "
+            "Bambu Lab A1 0.4 nozzle.\n"
+            "run found error, return -5, exit..."
+        )
+        assert (
+            _slicer_rejection_message(text) == "filament preset Generic PLA @BBL H2C (slot 1) is not compatible with "
+            "printer Bambu Lab A1 0.4 nozzle."
+        )
+
+    def test_keeps_meaningful_reason_even_when_cli_error_line_present(self):
+        # When the headline error_string is already a useful reason (here:
+        # the bed-boundary rejection), don't override it with a generic
+        # `[error]` line that may just be the same message restated. Avoids
+        # double-text duplication in the user-facing detail.
+        text = (
+            "Slicer CLI failed (500): Slicing failed with error from slicer: "
+            "Some objects are located over the boundary of the heated bed.: "
+            "Slicer process failed (exit code 204)\n"
+            "stdout: [error] some unrelated stdout chatter"
+        )
+        assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
+
+    def test_cli_error_line_without_run_prefix(self):
+        # The CLI sometimes logs `[error] <msg>` without the `run NNNN:`
+        # prefix (different code paths). The regex must still pick it up.
+        text = (
+            "Slicer CLI failed (500): Slicing failed with error from slicer: "
+            "The input preset file is invalid and can not be parsed.: "
+            "Slicer process failed (exit code 251)\n"
+            "stdout: [2026-06-29 12:00:00.000000] [error] Configuration parse failed: missing key 'printer_settings_id'"
+        )
+        assert _slicer_rejection_message(text) == "Configuration parse failed: missing key 'printer_settings_id'"
+
 
 class TestSliceSlicerRejection:
     @pytest.mark.asyncio

+ 115 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -13,6 +13,8 @@ import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { SliceModal } from '../../components/SliceModal';
+import { pickFilamentForSlot } from '../../utils/slicePresetPicker';
+import { buildCompatibilityIndex } from '../../utils/slicerPrinterMatch';
 import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
 import { api, type UnifiedPresetsResponse } from '../../api/client';
 
@@ -1160,3 +1162,116 @@ describe('SliceModal', () => {
   });
 
 });
+
+// Pure-function tests for the filament slot picker. Pinned as a separate
+// describe so the contract is visible without needing the modal mount.
+describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
+  // Index that recognises @BBL H2C / @BBL A1 tokens via the canonical
+  // PRINTER_MODEL_MAP. Real production data comes through
+  // ``api.getSlicerPrinterModels`` — the H2C / A1 fragments are the ones
+  // the production registry ships.
+  const index = buildCompatibilityIndex({
+    'Bambu Lab A1': 'A1',
+    'Bambu Lab H2C': 'H2C',
+  });
+
+  it('prefers a printer-compatible preset over a printer-mismatched one even with better colour match', () => {
+    // The OP scenario for #1851: a Bambu Lab A1 is selected; the unused-slot
+    // requirement carries the original H2C plate's PLA colour. With the
+    // legacy soft-penalty scoring an H2C-bound preset whose colour matches
+    // exactly could still rise above the A1-compatible PLA Basic whose
+    // colour doesn't, and then the unused-slot substitution propagated the
+    // H2C-bound preset across every unused slot — the CLI rejected with
+    // ``filament preset Generic PLA @BBL H2C (slot 1) is not compatible
+    // with printer Bambu Lab A1 0.4 nozzle``. The hard-skip contract makes
+    // sure a mismatched preset is never chosen while any compatible
+    // alternative exists, irrespective of metadata-score arithmetic.
+    const presets = makeUnified({
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Generic PLA @BBL H2C',
+            name: 'Generic PLA @BBL H2C',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+          {
+            id: 'Bambu PLA Basic @BBL A1',
+            name: 'Bambu PLA Basic @BBL A1',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FFFFFF',
+          },
+        ],
+      },
+    });
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      'Bambu Lab A1 0.4 nozzle',
+      index,
+    );
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+
+  it('falls back to a mismatched preset when no compatible alternative exists', () => {
+    // Graceful degrade: when every available preset is printer-mismatched,
+    // returning ``null`` would block the slice entirely. The picker keeps
+    // its old behaviour of returning the best-scoring mismatch so the user
+    // sees a populated dropdown they can correct, not an empty one.
+    const presets = makeUnified({
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Generic PLA @BBL H2C',
+            name: 'Generic PLA @BBL H2C',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+    });
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      'Bambu Lab A1 0.4 nozzle',
+      index,
+    );
+    expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
+  });
+
+  it('treats a no-printer-context call as no-mismatch (every preset eligible)', () => {
+    // ``printerName === null`` happens transiently on first render before the
+    // printer pre-pick effect has run. ``presetCompatibility`` returns
+    // ``unknown`` for every preset in that case, so the picker should just
+    // pick by metadata score with no compatibility filter active.
+    const presets = makeUnified({
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Generic PLA @BBL H2C',
+            name: 'Generic PLA @BBL H2C',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+    });
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      null,
+      index,
+    );
+    expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
+  });
+});

+ 8 - 131
frontend/src/components/SliceModal.tsx

@@ -17,13 +17,20 @@ import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
 import type { PlateFilament } from '../types/plates';
-import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
 import {
   presetCompatibility,
   buildCompatibilityIndex,
   EMPTY_COMPATIBILITY_INDEX,
   type PrinterCompatibilityIndex,
 } from '../utils/slicerPrinterMatch';
+import {
+  findPreset,
+  findPresetByName,
+  pickDefault,
+  pickFilamentForSlot,
+  pickProcessDefault,
+  type Slot,
+} from '../utils/slicePresetPicker';
 
 export type SliceSource =
   | { kind: 'libraryFile'; id: number; filename: string }
@@ -34,136 +41,6 @@ interface SliceModalProps {
   onClose: () => void;
 }
 
-type Slot = 'printer' | 'process' | 'filament';
-
-// Lookup priority: local → orca_cloud → cloud → standard. Local imports
-// outrank everything else because the user explicitly imported them for
-// this install; Orca Cloud comes next; Bambu Cloud after that; standard
-// (bundled) is the final fallback. The backend does NOT dedup tiers —
-// every group renders its full set so the user can pick a same-named
-// preset from a lower-priority source if they want to override the
-// auto-pick.
-const SLICE_MODAL_TIER_ORDER = ['local', 'orca_cloud', 'cloud', 'standard'] as const;
-
-function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
-  for (const tier of SLICE_MODAL_TIER_ORDER) {
-    const list = by[tier][slot];
-    if (list.length > 0) {
-      return { source: list[0].source, id: list[0].id };
-    }
-  }
-  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;
-}
-
-// Find a preset by exact name across tiers (local → cloud → standard). Used
-// to honour the printer / process preset names a 3MF was prepared with.
-function findPresetByName(
-  by: UnifiedPresetsResponse,
-  slot: Slot,
-  name: string | null | undefined,
-): PresetRef | null {
-  if (!name) return null;
-  for (const tier of SLICE_MODAL_TIER_ORDER) {
-    const p = by[tier][slot].find((x) => x.name === name);
-    if (p) return { source: p.source, id: p.id };
-  }
-  return null;
-}
-
-// Process default: honour the process preset the 3MF was prepared with
-// (preferredName) when it's available and not incompatible with the selected
-// printer; otherwise the first preset compatible with the printer in tier
-// order, then the first whose compatibility is merely unknown, then plain
-// priority. Keeps the pre-pick honest with both the embedded config and the
-// printer filter instead of blindly taking list[0] (#1325).
-function pickProcessDefault(
-  by: UnifiedPresetsResponse,
-  printerName: string | null,
-  compatIndex: PrinterCompatibilityIndex,
-  preferredName?: string | null,
-): PresetRef | null {
-  const preferred = findPresetByName(by, 'process', preferredName);
-  if (preferred) {
-    const p = findPreset(by, preferred, 'process');
-    if (p && presetCompatibility(p, 'process', printerName, compatIndex) !== 'mismatch') {
-      return preferred;
-    }
-  }
-  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, 'process', printerName, compatIndex) === wanted) {
-          return { source: p.source, id: p.id };
-        }
-      }
-    }
-  }
-  return pickDefault(by, 'process');
-}
-
-const TIER_BONUS: Record<PresetSource, number> = {
-  local: 1.75,
-  orca_cloud: 1.5,
-  cloud: 1.0,
-  standard: 0.5,
-};
-
-function pickFilamentForSlot(
-  by: UnifiedPresetsResponse,
-  required: { type: string; color: string },
-  printerName: string | null,
-  compatIndex: PrinterCompatibilityIndex,
-): 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
-  // print/schedule modal: type match dominates, exact-colour-match bumps over
-  // similar-colour-match, and a small per-tier bonus breaks ties so cloud
-  // user customisations win over standard bundled fallbacks of equal merit.
-  const reqType = required.type.trim().toUpperCase();
-  const reqColor = normalizeColorForCompare(required.color);
-
-  let best: { ref: PresetRef; score: number } | null = null;
-  for (const tier of SLICE_MODAL_TIER_ORDER) {
-    for (const p of by[tier].filament) {
-      let score = 0;
-      const presetType = (p.filament_type ?? '').trim().toUpperCase();
-      const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
-      if (reqType && presetType && reqType === presetType) score += 10;
-      if (reqColor && presetColor) {
-        if (presetColor === reqColor) score += 5;
-        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, 'filament', printerName, compatIndex) === 'mismatch') {
-        score -= 100;
-      }
-      if (best == null || score > best.score) {
-        best = { ref: { source: p.source, id: p.id }, score };
-      }
-    }
-  }
-  // Fall back to plain priority pick if every preset scored 0+tier (i.e. no
-  // metadata matched). The fallback is exactly the single-color default —
-  // first preset in the highest-priority non-empty tier.
-  if (best == null) return pickDefault(by, 'filament');
-  return best.ref;
-}
-
 function toRefValue(ref: PresetRef | null): string {
   // The HTML `<select>` value space is flat strings; encode source + id so
   // the same preset name can live in multiple tiers without collision.

+ 171 - 0
frontend/src/utils/slicePresetPicker.ts

@@ -0,0 +1,171 @@
+// Pure-function helpers for the SliceModal's per-slot preset selection.
+//
+// Extracted out of `SliceModal.tsx` so they can be unit-tested directly and
+// so the modal component file only exports React components (the
+// `react-refresh/only-export-components` lint rule requires this for HMR to
+// work correctly — exporting a non-component from a component file breaks
+// fast-refresh).
+//
+// Selection rules:
+// - Tier order is local → orca_cloud → cloud → standard. Local imports
+//   outrank everything else because the user explicitly imported them
+//   for this install; standard (bundled) is the final fallback.
+// - The backend does NOT dedup tiers, so each helper walks all four
+//   and the caller relies on the order, not a single merged list.
+// - `pickProcessDefault` honours a 3MF's embedded process preset when
+//   it exists and isn't printer-incompatible; otherwise prefers a
+//   match-on-printer pick, then unknown-compat, then plain priority.
+// - `pickFilamentForSlot` partitions candidates into compatible/unknown
+//   vs mismatch buckets and only consults the mismatch bucket when
+//   the compatible bucket is empty (#1851).
+
+import type {
+  PresetRef,
+  PresetSource,
+  UnifiedPreset,
+  UnifiedPresetsResponse,
+} from '../api/client';
+import { colorsAreSimilar, normalizeColorForCompare } from './amsHelpers';
+import {
+  presetCompatibility,
+  type PrinterCompatibilityIndex,
+} from './slicerPrinterMatch';
+
+export type Slot = 'printer' | 'process' | 'filament';
+
+export const SLICE_MODAL_TIER_ORDER = ['local', 'orca_cloud', 'cloud', 'standard'] as const;
+
+const TIER_BONUS: Record<PresetSource, number> = {
+  local: 1.75,
+  orca_cloud: 1.5,
+  cloud: 1.0,
+  standard: 0.5,
+};
+
+export function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
+  for (const tier of SLICE_MODAL_TIER_ORDER) {
+    const list = by[tier][slot];
+    if (list.length > 0) {
+      return { source: list[0].source, id: list[0].id };
+    }
+  }
+  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).
+export 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;
+}
+
+// Find a preset by exact name across tiers (local → cloud → standard). Used
+// to honour the printer / process preset names a 3MF was prepared with.
+export function findPresetByName(
+  by: UnifiedPresetsResponse,
+  slot: Slot,
+  name: string | null | undefined,
+): PresetRef | null {
+  if (!name) return null;
+  for (const tier of SLICE_MODAL_TIER_ORDER) {
+    const p = by[tier][slot].find((x) => x.name === name);
+    if (p) return { source: p.source, id: p.id };
+  }
+  return null;
+}
+
+// Process default: honour the process preset the 3MF was prepared with
+// (preferredName) when it's available and not incompatible with the selected
+// printer; otherwise the first preset compatible with the printer in tier
+// order, then the first whose compatibility is merely unknown, then plain
+// priority. Keeps the pre-pick honest with both the embedded config and the
+// printer filter instead of blindly taking list[0] (#1325).
+export function pickProcessDefault(
+  by: UnifiedPresetsResponse,
+  printerName: string | null,
+  compatIndex: PrinterCompatibilityIndex,
+  preferredName?: string | null,
+): PresetRef | null {
+  const preferred = findPresetByName(by, 'process', preferredName);
+  if (preferred) {
+    const p = findPreset(by, preferred, 'process');
+    if (p && presetCompatibility(p, 'process', printerName, compatIndex) !== 'mismatch') {
+      return preferred;
+    }
+  }
+  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, 'process', printerName, compatIndex) === wanted) {
+          return { source: p.source, id: p.id };
+        }
+      }
+    }
+  }
+  return pickDefault(by, 'process');
+}
+
+export function pickFilamentForSlot(
+  by: UnifiedPresetsResponse,
+  required: { type: string; color: string },
+  printerName: string | null,
+  compatIndex: PrinterCompatibilityIndex,
+): 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
+  // print/schedule modal: type match dominates, exact-colour-match bumps over
+  // similar-colour-match, and a small per-tier bonus breaks ties so cloud
+  // user customisations win over standard bundled fallbacks of equal merit.
+  //
+  // Compatibility is a hard partition, not a soft penalty (#1851). The legacy
+  // -100 demote let a printer-mismatched preset still win when the plate's
+  // (type, colour) happened to match it better than the colour-default
+  // standard preset on the right printer — e.g. an unused slot whose embedded
+  // colour matched `Generic PLA @BBL H2C` but not the off-the-shelf
+  // `Bambu PLA Basic @BBL A1`. The propagated slot-1 then poisoned every
+  // unused slot via `substitute_unused_plate_filaments`, and the CLI rejected
+  // the slice with "filament preset Generic PLA @BBL H2C (slot 1) is not
+  // compatible with printer Bambu Lab A1 0.4 nozzle". Hard-skipping mismatches
+  // while we still have any compatible/unknown candidate eliminates that
+  // poisoning at the source; the mismatch tier is only consulted when no
+  // printer-correct alternative exists, which preserves the graceful-degrade
+  // behaviour for presets registries that genuinely have nothing for the
+  // selected printer.
+  const reqType = required.type.trim().toUpperCase();
+  const reqColor = normalizeColorForCompare(required.color);
+
+  let bestCompatible: { ref: PresetRef; score: number } | null = null;
+  let bestMismatch: { ref: PresetRef; score: number } | null = null;
+  for (const tier of SLICE_MODAL_TIER_ORDER) {
+    for (const p of by[tier].filament) {
+      let score = 0;
+      const presetType = (p.filament_type ?? '').trim().toUpperCase();
+      const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
+      if (reqType && presetType && reqType === presetType) score += 10;
+      if (reqColor && presetColor) {
+        if (presetColor === reqColor) score += 5;
+        else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
+      }
+      score += TIER_BONUS[tier];
+      const ref = { source: p.source, id: p.id };
+      if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') {
+        if (bestMismatch == null || score > bestMismatch.score) {
+          bestMismatch = { ref, score };
+        }
+      } else if (bestCompatible == null || score > bestCompatible.score) {
+        bestCompatible = { ref, score };
+      }
+    }
+  }
+  if (bestCompatible != null) return bestCompatible.ref;
+  if (bestMismatch != null) return bestMismatch.ref;
+  // Final fallback when there are no filament presets at all (empty
+  // registry) — pickDefault returns null in that case too, but keeping the
+  // call mirrors the rest of the picker logic for shape consistency.
+  return pickDefault(by, 'filament');
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-DlLdjNuD.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-CKTYjVC_.js"></script>
+    <script type="module" crossorigin src="/assets/index-DlLdjNuD.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BYzbe9TT.css">
   </head>
   <body>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä