| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603 |
- /**
- * 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 { defaultForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
- import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
- import type { DesignOverride } from '../types/plates';
- interface SlicerData {
- schema: ProcessSchema;
- tree: ProcessUiTree;
- toggles: ToggleRules;
- }
- interface Props {
- values: Record<string, SettingValue>;
- /**
- * 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<string, SettingValue>, serialized: Record<string, string | string[]>) => 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<string>;
- 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[];
- }
- 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<string, number> = { simple: 0, advanced: 1, expert: 2, develop: 3 };
- export default function SlicerSettingsPanel({
- values,
- onChange,
- disabled = false,
- sourceOverrides = [],
- sourceSelected,
- onToggleSource,
- filamentChoices,
- }: Props) {
- const { t } = useTranslation();
- const [data, setData] = useState<SlicerData | null>(null);
- const [mode, setMode] = useState<OptionMode>('simple');
- const [page, setPage] = useState<string | null>(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;
- };
- }, []);
- const off = useMemo(
- () => (data ? disabledKeys(values, data.schema, data.toggles) : new Set<string>()),
- [data, values],
- );
- const sourceByKey = useMemo(
- () => new Map(sourceOverrides.map((o) => [o.key, o])),
- [sourceOverrides],
- );
- // 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<string, SettingValue>) => {
- 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<string, SettingValue> = {};
- for (const [k, v] of Object.entries(next)) {
- if (data.schema[k] && isModified(data.schema[k], v)) 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])).length;
- }, [data, values]);
- if (!data) {
- return (
- <div className="flex items-center justify-center gap-2 py-8 text-sm text-bambu-gray">
- <Loader2 className="w-4 h-4 animate-spin" />
- {t('slicerSettings.loading', 'Loading slicer settings...')}
- </div>
- );
- }
- const shownPages = activePage ? [activePage] : visiblePages;
- return (
- <div className="flex flex-col gap-3">
- <div className="flex flex-wrap items-center gap-2">
- <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
- {MODES.map((m) => (
- <button
- key={m}
- type="button"
- onClick={() => setMode(m)}
- disabled={disabled}
- className={`px-2.5 py-1 text-xs capitalize transition-colors ${
- mode === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
- }`}
- >
- {t(`slicerSettings.mode.${m}`, m)}
- </button>
- ))}
- </div>
- <div className="relative flex-1 min-w-[10rem]">
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray" />
- <input
- type="search"
- value={query}
- onChange={(e) => 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"
- />
- </div>
- {modifiedCount > 0 && (
- <button
- type="button"
- onClick={() => emit({})}
- disabled={disabled}
- className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white"
- >
- <RotateCcw className="w-3 h-3" />
- {t('slicerSettings.resetAll', 'Reset {{count}}', { count: modifiedCount })}
- </button>
- )}
- </div>
- {!query.trim() && (
- <div className="flex flex-wrap gap-1">
- {visiblePages.map((p) => (
- <button
- key={p.page}
- type="button"
- onClick={() => setPage(p.page)}
- disabled={disabled}
- className={`px-2 py-1 text-xs rounded transition-colors ${
- activePage?.page === p.page ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
- }`}
- >
- {p.page}
- </button>
- ))}
- </div>
- )}
- {shownPages.length === 0 ? (
- <p className="py-6 text-center text-xs text-bambu-gray">
- {t('slicerSettings.noMatches', 'No settings match this search.')}
- </p>
- ) : (
- // Taller once the panel has a column of its own; the narrow cap keeps
- // it from swallowing the single-column stack on small screens.
- <div className="flex flex-col gap-4 max-h-[22rem] lg:max-h-[58vh] overflow-y-auto pr-1">
- {shownPages.map((p) => (
- <div key={p.page} className="flex flex-col gap-3">
- {query.trim() && <p className="text-[0.7rem] uppercase tracking-wide text-bambu-gray/70">{p.page}</p>}
- {p.groups.map((g) => (
- <fieldset key={`${p.page}:${g.group}`} className="flex flex-col gap-1.5">
- <legend className="mb-1 text-xs font-medium text-white">{g.group}</legend>
- {g.options.map((key) => (
- <OptionRow
- key={key}
- optionKey={key}
- option={data.schema[key]}
- value={values[key]}
- onChange={(v) => setValue(key, v)}
- disabled={disabled || off.has(key)}
- disabledBySlicer={off.has(key)}
- source={sourceByKey.get(key)}
- sourceOn={sourceSelected?.has(key) ?? false}
- onToggleSource={onToggleSource}
- filamentChoices={FILAMENT_SLOT_OPTIONS.has(key) ? filamentChoices : undefined}
- />
- ))}
- </fieldset>
- ))}
- </div>
- ))}
- {/* 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() && (
- <fieldset className="flex flex-col gap-1.5">
- <legend className="mb-1 text-xs font-medium text-white">
- {t('slicerSettings.otherFromFile', 'Other settings from this file')}
- </legend>
- {unlistedSource.map((o) => (
- <label key={o.key} className="flex items-center gap-2 text-xs cursor-pointer">
- <input
- type="checkbox"
- checked={sourceSelected?.has(o.key) ?? false}
- disabled={disabled || !onToggleSource}
- onChange={(e) => onToggleSource?.(o.key, e.target.checked)}
- className="shrink-0 cursor-pointer disabled:opacity-40"
- />
- <span className="min-w-0 flex-1 truncate">
- <span className="font-mono text-bambu-gray">{o.key}</span>
- <span className="ml-1.5 text-white">{formatSourceValue(o.value)}</span>
- </span>
- {o.printer_coupled && (
- <span className="shrink-0 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400">
- {t('slicerSettings.fromFilePrinterCoupled', "designer's printer")}
- </span>
- )}
- </label>
- ))}
- </fieldset>
- )}
- </div>
- )}
- </div>
- );
- }
- 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;
- /** 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[];
- }
- function OptionRow({
- optionKey,
- option,
- value,
- onChange,
- disabled,
- disabledBySlicer,
- source,
- sourceOn = false,
- onToggleSource,
- filamentChoices,
- }: RowProps) {
- const { t } = useTranslation();
- const modified = isModified(option, value);
- 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.
- const current =
- value !== undefined
- ? String(value)
- : sourceOn && source
- ? formatSourceValue(source.value)
- : defaultForDisplay(option);
- return (
- <div className="flex items-center gap-2 group" title={option.tooltip}>
- <label
- htmlFor={`slicer-opt-${optionKey}`}
- className={`flex-1 text-xs truncate ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
- >
- {option.label || optionKey}
- {modified && <span className="ml-1 text-bambu-green" aria-hidden="true">•</span>}
- {source && (
- <span
- className={`ml-1.5 rounded px-1 py-0.5 text-[10px] ${
- source.printer_coupled
- ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
- : 'bg-bambu-green/15 text-bambu-green'
- }`}
- title={
- source.printer_coupled
- ? t('slicerSettings.fromFilePrinterCoupledHint', "Tuned for the printer this file was designed for -- may be wrong or out of range on yours.")
- : t('slicerSettings.fromFileHint', "The designer changed this in the source file. Its value is {{value}}.", { value: formatSourceValue(source.value) })
- }
- >
- {source.printer_coupled
- ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
- : t('slicerSettings.fromFile', 'from file')}
- </span>
- )}
- </label>
- <div className="flex items-center gap-1 shrink-0">
- <OptionControl
- id={`slicer-opt-${optionKey}`}
- option={option}
- current={current}
- onChange={onChange}
- disabled={disabled}
- filamentChoices={filamentChoices}
- />
- {unit && <span className="text-[0.65rem] text-bambu-gray/60 w-10 truncate">{unit}</span>}
- {source && onToggleSource && (
- <input
- type="checkbox"
- checked={sourceOn}
- disabled={disabled}
- onChange={(e) => 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"
- />
- )}
- <button
- type="button"
- onClick={() => onChange(undefined)}
- disabled={disabled || !modified}
- aria-label={t('slicerSettings.resetOption', 'Reset to default')}
- className={`p-0.5 transition-opacity ${modified ? 'text-bambu-gray hover:text-white' : 'opacity-0 pointer-events-none'}`}
- >
- <RotateCcw className="w-3 h-3" />
- </button>
- </div>
- </div>
- );
- }
- /**
- * 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-24 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 (
- <div className="relative w-24">
- <select
- id={id}
- value={current}
- onChange={(e) => onChange(e.target.value)}
- disabled={disabled}
- className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
- // The full name rarely fits in the control, so the hover carries it.
- title={selected?.label}
- >
- {/* 0 is the slicer's "no specific filament — use the region's own". */}
- <option value="0">{t('slicerSettings.filamentDefault', 'Default')}</option>
- {filamentChoices.map((choice) => (
- <option key={choice.index} value={String(choice.index)}>
- {choice.index}: {choice.label}
- </option>
- ))}
- </select>
- <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
- </div>
- );
- }
- if (option.type === 'coBool') {
- return (
- <input
- id={id}
- type="checkbox"
- checked={current === '1' || current === 'true'}
- onChange={(e) => 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 (
- <div className="relative w-24">
- <select
- id={id}
- value={current}
- onChange={(e) => onChange(e.target.value)}
- disabled={disabled}
- className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
- >
- {option.enum_values.map((v, i) => (
- <option key={v} value={v}>
- {option.enum_labels?.[i] ?? v}
- </option>
- ))}
- </select>
- <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
- </div>
- );
- }
- if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') {
- return (
- <input
- id={id}
- type="number"
- value={current.replace('%', '')}
- min={numericBound(option.min)}
- max={numericBound(option.max)}
- step={option.type === 'coInt' ? 1 : 'any'}
- // An empty field is kept as an empty string rather than dropped.
- // Dropping it would fall the input straight back to the default, so
- // clearing a value to retype it would silently append to the old one.
- // Empty never counts as modified, so nothing is sent for it either way;
- // the revert button is what actually removes the key.
- onChange={(e) => 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 (
- <input
- id={id}
- type="text"
- value={current}
- onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
- disabled={disabled}
- className={inputClass}
- />
- );
- }
|