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).
This commit is contained in:
maziggy
2026-07-07 10:51:51 +02:00
8 changed files with 379 additions and 134 deletions
+4
View File
@@ -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: <reason>`. 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: <msg>` and the bare `[error] <msg>` 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] <msg>` 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 <token>` 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.
+35 -1
View File
@@ -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(
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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');
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>