Explorar el Código

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 hace 2 meses
padre
commit
6507fbcc40

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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.
 # evaluate the job at all.
 _SLICER_REJECTION_MARKER = "Slicing failed with error from slicer:"
 _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:
 def _slicer_rejection_message(error_text: str) -> str | None:
     """Extract the slicer's own rejection reason from a sidecar error string,
     """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"
     no. Retrying with the 3MF's embedded settings would then only "succeed"
     by silently reverting to the source file's original printer, masking the
     by silently reverting to the source file's original printer, masking the
     real problem; such failures must reach the user instead.
     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:
     if _SLICER_REJECTION_MARKER not in error_text:
         return None
         return None
     reason = error_text.split(_SLICER_REJECTION_MARKER, 1)[1]
     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.
     # Trim the sidecar's trailing exit-code note and any stderr/stdout dump.
     for cut in (": Slicer process failed", "\nstderr:", "\nstdout:"):
     for cut in (": Slicer process failed", "\nstderr:", "\nstdout:"):
         idx = reason.find(cut)
         idx = reason.find(cut)
         if idx != -1:
         if idx != -1:
             reason = reason[:idx]
             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(
 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("") is None
         assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") 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:
 class TestSliceSlicerRejection:
     @pytest.mark.asyncio
     @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 userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
 import { SliceModal } from '../../components/SliceModal';
 import { SliceModal } from '../../components/SliceModal';
+import { pickFilamentForSlot } from '../../utils/slicePresetPicker';
+import { buildCompatibilityIndex } from '../../utils/slicerPrinterMatch';
 import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
 import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
 import { api, type UnifiedPresetsResponse } from '../../api/client';
 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 { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
 import { PlatePickerModal } from './PlatePickerModal';
 import type { PlateFilament } from '../types/plates';
 import type { PlateFilament } from '../types/plates';
-import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
 import {
 import {
   presetCompatibility,
   presetCompatibility,
   buildCompatibilityIndex,
   buildCompatibilityIndex,
   EMPTY_COMPATIBILITY_INDEX,
   EMPTY_COMPATIBILITY_INDEX,
   type PrinterCompatibilityIndex,
   type PrinterCompatibilityIndex,
 } from '../utils/slicerPrinterMatch';
 } from '../utils/slicerPrinterMatch';
+import {
+  findPreset,
+  findPresetByName,
+  pickDefault,
+  pickFilamentForSlot,
+  pickProcessDefault,
+  type Slot,
+} from '../utils/slicePresetPicker';
 
 
 export type SliceSource =
 export type SliceSource =
   | { kind: 'libraryFile'; id: number; filename: string }
   | { kind: 'libraryFile'; id: number; filename: string }
@@ -34,136 +41,6 @@ interface SliceModalProps {
   onClose: () => void;
   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 {
 function toRefValue(ref: PresetRef | null): string {
   // The HTML `<select>` value space is flat strings; encode source + id so
   // The HTML `<select>` value space is flat strings; encode source + id so
   // the same preset name can live in multiple tiers without collision.
   // 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');
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DlLdjNuD.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-BYzbe9TT.css">
   </head>
   </head>
   <body>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio