/** * Process-settings editor mirroring OrcaSlicer's own Print Settings tabs. * * Structure, labels, tooltips, bounds, defaults and enable/disable rules all * come from metadata extracted from OrcaSlicer's C++ sources (see * `src/data/slicer/`), so the pages, groups and ordering match what users see * in the desktop slicer rather than a hand-picked subset. * * Option labels and tooltips are deliberately English-only for now: they are * 348 strings lifted verbatim from `PrintConfig.cpp`, and hand-translating them * into all 13 locales is not viable. The panel's own chrome — mode switch, * search, buttons, empty states — goes through i18n as usual. OrcaSlicer ships * its own translation catalogs for these strings, which is the obvious source * if they are ever picked up. * * Values are held sparsely: only options the user actually changed are tracked * and sent, so a slice with an untouched panel is byte-identical to one from * before this panel existed. */ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Search, RotateCcw, Loader2, ChevronDown } from 'lucide-react'; import { disabledKeys, type ToggleRules } from '../lib/slicerToggle'; import { baselineForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings'; import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings'; import type { DesignOverride } from '../types/plates'; import type { SlicerPresetValuesReason } from '../api/client'; interface SlicerData { schema: ProcessSchema; tree: ProcessUiTree; toggles: ToggleRules; } interface Props { values: Record; /** * Reports both the panel's editing state and the same values serialised for * the slice request. Serialising here rather than in the caller keeps the * option schema — the only thing that knows a percent needs its `%` back — * in the one component that has already loaded it. * * `serialized` carries only options that actually differ from their default, * so an untouched panel sends nothing at all. */ onChange: (values: Record, serialized: Record) => void; disabled?: boolean; /** * Process settings the source 3MF's designer moved off the stock preset * (#2622), as recorded by BambuStudio in `different_settings_to_system`. * * These are shown inline against the options they belong to rather than in a * list of their own, so there is one place to see what this slice will use. * Their *values* are not routed through this component: the backend reads * them straight out of the file, which keeps settings faithful even for keys * outside the option schema we vendor. All this panel decides is which of * them are switched on. */ sourceOverrides?: DesignOverride[]; /** Which source-override keys are currently switched on. */ sourceSelected?: Set; onToggleSource?: (key: string, on: boolean) => void; /** * The filaments picked on the slice dialog's left-hand side, in slot order. * * A handful of options select *which filament* prints a given feature — * supports, outer walls, infill. The slicer stores those as a plain integer * where 0 means "whatever filament the region already uses" and 1..N is a * slot. A bare number field makes the user count their own AMS slots, so * when this is supplied those options become a dropdown of the actual * picks instead. */ filamentChoices?: FilamentChoice[]; /** * The picked process preset's effective values, flattened by the sidecar. * Used as the baseline an untouched field shows and a revert returns to. * Empty when unavailable, in which case the panel falls back to the option * schema's compiled-in defaults and says the values are indicative. */ presetValues?: Record; /** False when the preset's values could not be fetched. */ presetValuesResolved?: boolean; /** * Why they could not be fetched, so the notice can name a fix. Left * unset while the fetch is still in flight. */ presetValuesReason?: SlicerPresetValuesReason; } export interface FilamentChoice { /** 1-based slot index, matching the integer the slicer stores. */ index: number; /** Preset name, or a fallback when the slot has no pick yet. */ label: string; /** Slot colour from the source plate, for the swatch. */ color?: string; } /** * Options whose integer value names a filament slot rather than a quantity. * All use the same encoding: 0 = "default / current filament", 1..N = slot. * Support base and interface are the pair on the Support page; the rest are * the Multimaterial page's per-region pickers, which have the same wart. */ const FILAMENT_SLOT_OPTIONS = new Set([ 'support_filament', 'support_interface_filament', 'outer_wall_filament_id', 'inner_wall_filament_id', 'top_surface_filament_id', 'bottom_surface_filament_id', 'internal_solid_filament_id', 'sparse_infill_filament_id', ]); /** Visibility tiers, in increasing order of how much they reveal. */ const MODES: OptionMode[] = ['simple', 'advanced', 'expert']; const MODE_RANK: Record = { simple: 0, advanced: 1, expert: 2, develop: 3 }; export default function SlicerSettingsPanel({ values, onChange, disabled = false, sourceOverrides = [], sourceSelected, onToggleSource, filamentChoices, presetValues, presetValuesResolved = true, presetValuesReason, }: Props) { const { t } = useTranslation(); const [data, setData] = useState(null); const [mode, setMode] = useState('simple'); const [page, setPage] = useState(null); const [query, setQuery] = useState(''); // 150 KB of extracted metadata has no business in the main bundle — it is // only needed once someone opens this panel. useEffect(() => { let cancelled = false; Promise.all([ import('../data/slicer/process-schema.json'), import('../data/slicer/process-ui-tree.json'), import('../data/slicer/process-toggle-rules.json'), ]).then(([schema, tree, toggles]) => { if (cancelled) return; setData({ schema: (schema.default ?? schema) as unknown as ProcessSchema, tree: (tree.default ?? tree) as unknown as ProcessUiTree, toggles: (toggles.default ?? toggles) as unknown as ToggleRules, }); }); return () => { cancelled = true; }; }, []); // What this slice will actually run with, in the same precedence order the // rows display: the picked preset underneath, the designer's value for each // key that is switched on, and anything typed here on top. const effectiveValues = useMemo(() => { const merged: Record = { ...(presetValues ?? {}) }; for (const o of sourceOverrides) { if (sourceSelected?.has(o.key)) merged[o.key] = o.value as SettingValue; } // An emptied field is not a value — leaving it in would read as "" and // send the config reader to the schema default, past the preset. for (const [key, value] of Object.entries(values)) { if (value !== undefined && value !== '') merged[key] = value; } return merged; }, [presetValues, sourceOverrides, sourceSelected, values]); // The slicer's own `enable_if` rules, evaluated against that rather than // against `values` alone (#2942). `values` holds only what the user typed // here, and the config reader falls back to the *schema* default for // everything else — so a preset with supports on read as // `enable_support: false` and greyed out the whole Support page, including // rows whose "from file" tick was on and whose value the slice used. A // greyed row used to grey its tick too, which left a setting that came from // the file, that the slice applied, and that nothing on screen could // switch off. const off = useMemo( () => (data ? disabledKeys(effectiveValues, data.schema, data.toggles) : new Set()), [data, effectiveValues], ); const sourceByKey = useMemo( () => new Map(sourceOverrides.map((o) => [o.key, o])), [sourceOverrides], ); // The subset a bulk "use the designer's settings" may switch on: everything // the file changed except the values tuned for the designer's own machine // and the two that *are* the picked preset. Those two classes stay a // per-key decision, which is the classification #2622 made and this does // not widen. const carryableSource = useMemo( () => sourceOverrides.filter((o) => !o.printer_coupled && !o.preset_defining), [sourceOverrides], ); const selectedSourceCount = useMemo( () => sourceOverrides.filter((o) => sourceSelected?.has(o.key)).length, [sourceOverrides, sourceSelected], ); // Source overrides for keys the vendored schema doesn't cover. They still // apply — the backend reads their values from the file — so they get a group // of their own rather than being dropped from view. const unlistedSource = useMemo(() => { if (!data) return []; return sourceOverrides.filter((o) => !data.schema[o.key]); }, [data, sourceOverrides]); const emit = (next: Record) => { if (!data) return; // Only genuine deviations are worth sending: an override that equals the // preset's own value is noise in the process JSON and makes the slice // request harder to read when something goes wrong. const changed: Record = {}; for (const [k, v] of Object.entries(next)) { if (data.schema[k] && isModified(data.schema[k], v, presetValues?.[k])) changed[k] = v; } onChange(next, serializeOverrides(changed, data.schema)); }; const setValue = (key: string, value: SettingValue | undefined) => { const next = { ...values }; if (value === undefined) delete next[key]; else next[key] = value; emit(next); }; // Search cuts across every page; without a query we show the selected page. const visiblePages = useMemo(() => { if (!data) return []; // Underscores and spaces are interchangeable so "outer wall speed" finds // `outer_wall_speed`. That matters more than it looks: several labels are // only meaningful with their group ("Outer wall" under Speed), so the key // is often the only place the full phrase appears. const flatten = (s: string) => s.toLowerCase().replace(/[_\s]+/g, ' ').trim(); const needle = flatten(query); const withinMode = (key: string) => MODE_RANK[data.schema[key]?.mode ?? 'expert'] <= MODE_RANK[mode]; const matches = (key: string, group: string, page: string) => { if (!needle) return true; const opt = data.schema[key]; // Group and page are matched too, so "speed" lists the Speed page's // options rather than only the handful with "speed" in their label. const haystack = [key, opt?.label ?? '', opt?.tooltip ?? '', group, page]; return haystack.some((h) => flatten(h).includes(needle)); }; return data.tree .map((p) => ({ ...p, groups: p.groups .map((g) => ({ ...g, options: g.options.filter((k) => withinMode(k) && matches(k, g.group, p.page)), })) .filter((g) => g.options.length > 0), })) .filter((p) => p.groups.length > 0); }, [data, mode, query]); const activePage = useMemo(() => { if (visiblePages.length === 0) return null; if (query.trim()) return null; // Searching shows every match, not one page. return visiblePages.find((p) => p.page === page) ?? visiblePages[0]; }, [visiblePages, page, query]); const modifiedCount = useMemo(() => { if (!data) return 0; return Object.keys(values).filter((k) => data.schema[k] && isModified(data.schema[k], values[k], presetValues?.[k])).length; }, [data, values, presetValues]); if (!data) { return (
{t('slicerSettings.loading', 'Loading slicer settings...')}
); } const shownPages = activePage ? [activePage] : visiblePages; return (
{MODES.map((m) => ( ))}
setQuery(e.target.value)} disabled={disabled} placeholder={t('slicerSettings.searchPlaceholder', 'Search settings')} className="w-full rounded border border-bambu-dark-tertiary bg-bambu-dark pl-7 pr-2 py-1 text-xs text-white placeholder:text-bambu-gray/60 focus:border-bambu-green focus:outline-none disabled:opacity-40" />
{modifiedCount > 0 && ( )}
{!presetValuesResolved && (

{presetValuesReason === 'sidecar_outdated' ? t( 'slicerSettings.presetValuesOutdatedSidecar', "Showing slicer defaults: your slicer sidecar is older than this feature and can't report a preset's values. Update the sidecar image to see them. Anything you don't change still uses the preset.", ) : presetValuesReason === 'not_configured' ? t( 'slicerSettings.presetValuesNotConfigured', "Showing slicer defaults: no slicer sidecar is configured, so a preset's values can't be read. Anything you don't change still uses the preset.", ) : presetValuesReason === 'sidecar_unavailable' ? t( 'slicerSettings.presetValuesSidecarUnavailable', "Showing slicer defaults: the slicer sidecar did not answer, so a preset's values can't be read. Anything you don't change still uses the preset.", ) : t( 'slicerSettings.presetValuesUnavailable', "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.", )}

)} {/* What the file brings, and the only bulk way to take it. Nothing here is pre-ticked any more (#2942), so without this line the designer's settings would be reachable only by hunting for green chips across six pages of 348 options. "Use them" ticks the keys that carry across printers; the machine-tuned ones and the two that define the picked preset stay off, as they always have. */} {sourceOverrides.length > 0 && onToggleSource && (
{t( 'slicerSettings.fromFileSummary', 'The designer changed {{count}} process settings in this file. Only the ones you tick are used.', { count: sourceOverrides.length }, )} {selectedSourceCount > 0 && ( )}
)} {!query.trim() && (
{visiblePages.map((p) => ( ))}
)} {shownPages.length === 0 ? (

{t('slicerSettings.noMatches', 'No settings match this search.')}

) : ( // Taller once the panel has a column of its own; the narrow cap keeps // it from swallowing the single-column stack on small screens.
{shownPages.map((p) => (
{query.trim() &&

{p.page}

} {p.groups.map((g) => (
{g.group} {g.options.map((key) => ( setValue(key, v)} disabled={disabled || off.has(key)} disabledBySlicer={off.has(key)} formDisabled={disabled} source={sourceByKey.get(key)} sourceOn={sourceSelected?.has(key) ?? false} onToggleSource={onToggleSource} filamentChoices={FILAMENT_SLOT_OPTIONS.has(key) ? filamentChoices : undefined} presetValue={presetValues?.[key]} /> ))}
))}
))} {/* Source-file settings the vendored schema has no entry for: they still apply (the backend reads their values from the file), so they get a plain key/value group rather than disappearing from a panel that claims to show what this slice will use. */} {unlistedSource.length > 0 && !query.trim() && (
{t('slicerSettings.otherFromFile', 'Other settings from this file')} {unlistedSource.map((o) => ( ))}
)}
)}
); } interface RowProps { optionKey: string; option: ProcessOption; value: SettingValue | undefined; onChange: (value: SettingValue | undefined) => void; disabled: boolean; /** Greyed because the slicer's own rules turn it off, not because the form is busy. */ disabledBySlicer: boolean; /** * The panel-wide disabled state, without the slicer's per-option rules. * * Gates the "from file" tick, which answers a different question from the * control beside it: not "is this option in play" but "where does its value * come from". An option the slicer has switched off can still be one the * user wants the file's value for once it comes back into play, and folding * the two together is what made a ticked source setting unclearable (#2942). */ formDisabled: boolean; /** Set when the source file's designer moved this option off the stock preset. */ source?: DesignOverride; sourceOn?: boolean; onToggleSource?: (key: string, on: boolean) => void; /** Set only for options whose integer value names a filament slot. */ filamentChoices?: FilamentChoice[]; /** The picked preset's value for this option, when known. */ presetValue?: SettingValue; } function OptionRow({ optionKey, option, value, onChange, disabled, disabledBySlicer, formDisabled, source, sourceOn = false, onToggleSource, filamentChoices, presetValue, }: RowProps) { const { t } = useTranslation(); const modified = isModified(option, value, presetValue); const unit = displaySidetext(option); // What this slice will actually use, in precedence order: a value typed here // wins, then the designer's value if it is switched on, then the preset's own // (or the schema default when the preset's values are unavailable). const current = value !== undefined ? String(value) : sourceOn && source ? formatSourceValue(source.value) : baselineForDisplay(option, presetValue); return (
{/* Label takes the slack; the control group is a fixed width anchored to the right edge. Fixed widths on the control *and* the unit are what keep that column straight — sizing either to content makes each row's input land at a different x. */}
{/* The "use the file's value" tick comes *before* the control it qualifies, as a checkbox that gates a field conventionally does — it used to sit past the unit, out at the right edge, reading as unrelated to the field. The slot is reserved on every row so rows with and without a source override keep the control column straight. */} {source && onToggleSource && ( onToggleSource(optionKey, e.target.checked)} aria-label={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", { option: option.label || optionKey, })} title={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", { option: option.label || optionKey, })} className="w-3 h-3 cursor-pointer disabled:opacity-40" /> )}
{/* Fixed width so the control column stays straight, but wide enough for the longest unit in the schema ("mm/s² or %") — a narrower cap truncated those to "mm o...". Rendered even when empty so rows without a unit keep the revert button aligned. */} {unit ?? ''}
); } /** * Render a value read out of the source file. Bambu's process config stores * everything as strings or arrays of strings, so this only has to flatten * arrays — no unit or type interpretation, which would rot against every * slicer release. */ function formatSourceValue(value: unknown): string { if (Array.isArray(value)) return value.map((v) => String(v)).join(', '); if (value == null) return ''; return String(value); } interface ControlProps { id: string; option: ProcessOption; current: string; onChange: (value: SettingValue | undefined) => void; disabled: boolean; filamentChoices?: FilamentChoice[]; } function OptionControl({ id, option, current, onChange, disabled, filamentChoices }: ControlProps) { const { t } = useTranslation(); // Theme tokens rather than raw black/white: bambu-dark and // bambu-dark-tertiary are CSS variables that follow the active theme, and // `text-white` is remapped to --text-primary in index.css. const inputClass = 'w-full rounded border border-bambu-dark-tertiary bg-bambu-dark px-1.5 py-0.5 text-xs text-white focus:border-bambu-green focus:outline-none disabled:opacity-40'; // Filament-slot pickers come before the generic branches: the value is an // integer, but offering a spinner over "1, 2, 3" makes the user map slot // numbers to their own AMS by hand. if (filamentChoices && filamentChoices.length > 0) { const selected = filamentChoices.find((c) => String(c.index) === current); return (
); } if (option.type === 'coBool') { return ( onChange(e.target.checked)} disabled={disabled} className="w-3.5 h-3.5 cursor-pointer disabled:opacity-40" /> ); } if (option.type === 'coEnum' && option.enum_values) { // Native select chrome is replaced the same way as everywhere else in // Bambuddy: appearance-none plus our own chevron, so the control matches // the app in both themes instead of whatever the browser paints. return (
); } if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') { return ( onChange(e.target.value)} disabled={disabled} className={inputClass} /> ); } // coFloatOrPercent, the vector types and coString all accept free text: they // hold values like "50%", "0.42" or a comma-separated per-extruder list, none // of which a number input can represent. return ( onChange(e.target.value)} disabled={disabled} className={inputClass} /> ); }