diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b3d74de..60f4d3a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Slice failure UI now surfaces the slicer's real diagnostic, not Bambu Studio's `input preset file is invalid` placeholder (#1851, reporter @srausser)** — When the BambuStudio CLI rejects a slice for a content reason (preset-vs-printer compat failure, missing fields, range validation), it exits -5 and writes Bambu Studio's catch-all error_string `The input preset file is invalid and can not be parsed.` to `result.json`. The actual per-incident diagnostic — e.g. `filament preset Generic PLA @BBL H2C (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle.` — only lives in the stdout dump as `[error] run NNNN: `. The sidecar packed both into the response (`message` carried the placeholder, `details` carried the stdout dump), `_format_sidecar_error` joined them, but `_slicer_rejection_message` in `backend/app/api/routes/library.py:3295` trimmed at the first `\nstdout:` / `\nstderr:` cut point — discarding the real CLI error before it reached the SliceJob's `error_detail`. The reporter only knew an H2C-bound preset had slipped into slot 1 because they checked the container logs by hand; the UI's `AlertModal` showed the unhelpful placeholder verbatim. **Fix.** `_slicer_rejection_message` now mines the response body with `_CLI_ERROR_LINE_RE` (`\[error\]\s*(?:run\s+\d+:\s*)?(.+?)$`, MULTILINE) BEFORE the stdout/stderr trim removes it. When the headline reason matches the bundled `The input preset file is invalid and can not be parsed.` placeholder — or when the headline is empty — the mined `[error]` line is substituted in its place; when the headline carries a useful reason already (`Some objects are located over the boundary of the heated bed.`, `The temperature difference of the filaments used is too large.`, etc.) the headline is kept and the `[error]` line is ignored to avoid duplicating the same text. The regex tolerates both `[error] run NNNN: ` and the bare `[error] ` shape the CLI uses on different code paths, and matches against the FULL pre-trim response so anything in stderr is also covered. **Tests.** Three new cases in `TestSlicerRejectionMessage` (`backend/tests/integration/test_library_slice_api.py`): `test_replaces_input_preset_invalid_placeholder_with_cli_error_line` pins the exact #1851 H2C/A1 trace from the report; `test_keeps_meaningful_reason_even_when_cli_error_line_present` ensures bed-boundary and other already-specific reasons aren't clobbered by an unrelated stdout `[error]`; `test_cli_error_line_without_run_prefix` covers the bare `[error] ` shape. Existing four cases stay green. 7/7 in the class. **Scope.** Backend-only. The frontend already reads `state.error_detail` verbatim into the AlertModal (`SliceJobTrackerContext.tsx:188`), so the better message flows through with no UI change. No DB migration, no new permission, no new i18n key. + +- **Slice modal's filament auto-pick hard-skips printer-mismatched presets when any compatible alternative exists (#1851 root cause, reporter @srausser)** — `pickFilamentForSlot` in `frontend/src/components/SliceModal.tsx:123` scored every filament against the plate slot's (type, colour) requirement plus a `-100` penalty when the preset's `compatible_printers` list / `@BBL ` name resolved to a different printer than the user picked (#1325). The soft penalty was dominant in nominal-data scenarios but the contract — "never auto-fill a slot with a printer-incompatible preset while a compatible one exists" — was implicit, not enforced; any future scoring change (a higher type-match weight, an extra metadata bump, a Bambu Cloud filament shape change that erases `compatible_printers`) would silently flip the picker back into the soft regime. **The propagation amplifier:** when the picked plate doesn't use every project slot (here: H2C source plate uses slot 4 only; slots 1-3 are unused), `substitute_unused_plate_filaments` in `backend/app/services/slicer_3mf_convert.py:238` rewrites every unused slot to slot 1's content — so any printer-mismatch in slot 1 silently propagates across the whole filament array, and one bad auto-pick poisons the entire slice. **Fix.** The scorer now partitions candidates into two buckets — compatible/unknown vs mismatch — and returns the best-scoring compatible/unknown candidate whenever the compatible bucket is non-empty; the mismatch bucket is only consulted when zero compatible alternatives exist (preserves graceful-degrade for preset registries that genuinely have nothing for the selected printer). Identical to the existing `pickProcessDefault` two-pass `'match'` → `'unknown'` shape, applied to filaments. No metadata-scoring change. The four shared picker helpers (`pickFilamentForSlot`, `pickProcessDefault`, `pickDefault`, `findPreset` / `findPresetByName` plus the `SLICE_MODAL_TIER_ORDER` constant) moved out of `SliceModal.tsx` into a new `frontend/src/utils/slicePresetPicker.ts` so the contract is unit-testable and the modal file only exports React components (the `react-refresh/only-export-components` lint rule fails fast-refresh on non-component exports from a `.tsx` file). **Tests.** Three new cases in a dedicated `pickFilamentForSlot — printer-compat contract (#1851)` describe block in `frontend/src/__tests__/components/SliceModal.test.tsx`: the OP trace (`A1` printer + `Generic PLA @BBL H2C` perfect colour vs `Bambu PLA Basic @BBL A1` colour mismatch → A1 wins), the all-mismatch graceful degrade (only H2C preset present → H2C still returned, dropdown not empty), and the no-printer-context transient (`printerName === null` during first render → no compat filter, plain score-best wins). 35/35 SliceModal tests + 25/25 utils/slicerPrinterMatch tests green. **Scope.** Frontend-only, single helper, one new export for testability. No new i18n key. No backend change. The unused-slot substitution stays as-is — it's only correct when slot 1 is correct, which the picker now enforces. + - **Uncataloged-but-actionable HMS faults now render in the UI (#1840, reporter @Boa-Thomas)** — H2C printers (firmware `01.02.00.00`) emit HMS faults whose short codes aren't in the bundled `ERROR_DESCRIPTIONS` map — e.g. `0500_809C`, which pauses the print and carries `IGNORE_RESUME` / `PROBLEM_SOLVED_RESUME` actions the user needs to dispatch. `filterKnownHMSErrors` in `frontend/src/components/HMSErrorModal.tsx:900` (and the inline modal-local copy at line 929) gated visibility purely on catalog membership (`ERROR_DESCRIPTIONS[shortCode] !== undefined`), so the entire error never rendered: no problem pip, no per-card count, no errors-panel entry, no action buttons. Backend correctly captured + dispatched the fault (verified via REST and WebSocket); frontend silently dropped it. The catalog gate isn't dead code — it's also a noise filter for transient post-cancel echoes like `0C00_001B` (see `PrintersPageBucketing.test.ts`) — so deleting it would re-introduce the FAILED-after-cancel "1 problem forever" regression. **Fix.** `filterKnownHMSErrors` now keeps an error if EITHER it's in `ERROR_DESCRIPTIONS` (existing behaviour, preserves bucketing for noise) OR it carries `actions.length > 0` (actionable fault from any source — surface so the buttons can render). The modal's inline filter is replaced with a call to the shared helper so badge counts and modal contents agree by construction. For uncataloged errors, the description falls back to `t('hmsErrors.unknownCode')` ("Unknown HMS code — see the Bambu Lab wiki for details."); the existing `[XXXX-YYYY]` short-code header, severity badge, action buttons, and wiki link all work without a catalog entry. The action-dispatch path is unchanged — `full_code` already flows through correctly from #1830, so `IGNORE_RESUME` etc. land on the firmware the moment the user clicks. The reporter's secondary observation about `severity === "error"` is a false positive — that comparison lives in `SystemHealthPanel.tsx` (log-health findings, string-typed severity), not the HMS path which correctly switches on the numeric 1–4 scale. Severity `6` falls into the default `Info` branch — acceptable for an unrecognized level and out of scope here. **Tests.** New case in `PrintersPageBucketing.test.ts`: `'classifies PAUSE + uncataloged HMS WITH actions as "error"'` pins the H2C scenario (`0500_809C` + `IGNORE_RESUME`/`PROBLEM_SOLVED_RESUME` → bucket `error`). Existing case `'classifies FAILED + only unknown HMS as "finished"'` (uncataloged WITHOUT actions = noise) stays green — the gate distinguishes the two by action presence. 18/18 frontend tests in `PrintersPageBucketing.test.ts` + `HMSErrorModal.test.tsx` green. **i18n.** One new key `hmsErrors.unknownCode`, real translation in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), parity check clean. **Scope.** Frontend-only. No backend change. No DB migration. No new permission. The fix is data-shape agnostic — any future printer whose HMS dictionary diverges from the bundled catalog will now surface actionable faults without a Bambuddy release. - **First-layer notification photo no longer shows pre-print calibration state (#1837, reporter @MartinNYHC)** — On P1S (and any Bambu printer with a long pre-print calibration sequence) the "First Layer Complete" notification fired during PREPARE, not after layer 1 was actually printed — the attached photo showed a lowered bed + parked toolhead + clean plate, because the firmware ticks `layer_num` during homing / auto-bed-leveling / bed-surface scan / nozzle clean *before* the first real extrusion. Reporter's log timeline made it explicit: print start at 13:54:27, notification fired at 14:10:13 with `[SNAPSHOT] Capturing fresh frame`, `gcode_state: RUNNING` not seen until 14:44:28 — i.e. the notification went out ~30 minutes before the print actually started. The trigger in `main.py:6044` only gated on `2 <= layer_num <= 5` with no check that the printer was actually printing. **Fix.** The trigger now requires `state.state == "RUNNING"` AND `state.mc_print_sub_stage in (None, 0)` — `0` is the "Printing" stage in the canonical Bambu `STAGE_NAMES` map (`bambu_mqtt.py:376`), so the non-zero pre-print sub-stages (`1` Auto bed leveling, `9` Scanning bed surface, `10` Inspecting first layer, `13` Homing toolhead, `14` Cleaning nozzle tip, …) all skip. `None` is preserved as a no-opinion fall-through for any firmware that doesn't push `mc_print_sub_stage` so unknown-firmware installs keep their existing behaviour. `_first_layer_notified` is only set once the gate passes, so calibration-phase `layer_num` ticks are non-consuming — the next on_layer_change edge after the printer enters real printing fires the notification. The trigger window widens from `[2, 5]` to `[2, 10]` so that if calibration consumes several `layer_num` slots before RUNNING, the deferred edge still falls inside. **Tests.** Manual verification via the issue reporter's installation; no new unit tests added (the on_layer_change closure is wired inside an event-handler factory and isn't a unit-testable pure function — would require a substantial fixture rewrite for a one-condition guard that's already covered by integration of the printer-state machine). **Scope.** Backend-only, single-file change. No DB migration. No new permission. No frontend change. No new i18n key. The window widening doesn't risk firing a stale notification on prints whose `layer_num` advances past 10 during PREPARE — the RUNNING + sub-stage gate ensures the notification only fires when the printer is actually printing, regardless of how many ticks PREPARE consumed. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index ed398e2a2..069b9751d 100644 --- a/backend/app/api/routes/library.py +++ b/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 `[] [error] run : ` (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: ` — 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( diff --git a/backend/tests/integration/test_library_slice_api.py b/backend/tests/integration/test_library_slice_api.py index 5ecf82331..3c80613c0 100644 --- a/backend/tests/integration/test_library_slice_api.py +++ b/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] ` 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 diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index d1c8a1256..7ef2ad3af 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/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' }); + }); +}); diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index ac88c13b4..2f8ddda58 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/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 = { - 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 `