mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user