SlicerSettingsPanel.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. /**
  2. * Process-settings editor mirroring OrcaSlicer's own Print Settings tabs.
  3. *
  4. * Structure, labels, tooltips, bounds, defaults and enable/disable rules all
  5. * come from metadata extracted from OrcaSlicer's C++ sources (see
  6. * `src/data/slicer/`), so the pages, groups and ordering match what users see
  7. * in the desktop slicer rather than a hand-picked subset.
  8. *
  9. * Option labels and tooltips are deliberately English-only for now: they are
  10. * 348 strings lifted verbatim from `PrintConfig.cpp`, and hand-translating them
  11. * into all 13 locales is not viable. The panel's own chrome — mode switch,
  12. * search, buttons, empty states — goes through i18n as usual. OrcaSlicer ships
  13. * its own translation catalogs for these strings, which is the obvious source
  14. * if they are ever picked up.
  15. *
  16. * Values are held sparsely: only options the user actually changed are tracked
  17. * and sent, so a slice with an untouched panel is byte-identical to one from
  18. * before this panel existed.
  19. */
  20. import { useEffect, useMemo, useState } from 'react';
  21. import { useTranslation } from 'react-i18next';
  22. import { Search, RotateCcw, Loader2, ChevronDown } from 'lucide-react';
  23. import { disabledKeys, type ToggleRules } from '../lib/slicerToggle';
  24. import { baselineForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
  25. import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
  26. import type { DesignOverride } from '../types/plates';
  27. import type { SlicerPresetValuesReason } from '../api/client';
  28. interface SlicerData {
  29. schema: ProcessSchema;
  30. tree: ProcessUiTree;
  31. toggles: ToggleRules;
  32. }
  33. interface Props {
  34. values: Record<string, SettingValue>;
  35. /**
  36. * Reports both the panel's editing state and the same values serialised for
  37. * the slice request. Serialising here rather than in the caller keeps the
  38. * option schema — the only thing that knows a percent needs its `%` back —
  39. * in the one component that has already loaded it.
  40. *
  41. * `serialized` carries only options that actually differ from their default,
  42. * so an untouched panel sends nothing at all.
  43. */
  44. onChange: (values: Record<string, SettingValue>, serialized: Record<string, string | string[]>) => void;
  45. disabled?: boolean;
  46. /**
  47. * Process settings the source 3MF's designer moved off the stock preset
  48. * (#2622), as recorded by BambuStudio in `different_settings_to_system`.
  49. *
  50. * These are shown inline against the options they belong to rather than in a
  51. * list of their own, so there is one place to see what this slice will use.
  52. * Their *values* are not routed through this component: the backend reads
  53. * them straight out of the file, which keeps settings faithful even for keys
  54. * outside the option schema we vendor. All this panel decides is which of
  55. * them are switched on.
  56. */
  57. sourceOverrides?: DesignOverride[];
  58. /** Which source-override keys are currently switched on. */
  59. sourceSelected?: Set<string>;
  60. onToggleSource?: (key: string, on: boolean) => void;
  61. /**
  62. * The filaments picked on the slice dialog's left-hand side, in slot order.
  63. *
  64. * A handful of options select *which filament* prints a given feature —
  65. * supports, outer walls, infill. The slicer stores those as a plain integer
  66. * where 0 means "whatever filament the region already uses" and 1..N is a
  67. * slot. A bare number field makes the user count their own AMS slots, so
  68. * when this is supplied those options become a dropdown of the actual
  69. * picks instead.
  70. */
  71. filamentChoices?: FilamentChoice[];
  72. /**
  73. * The picked process preset's effective values, flattened by the sidecar.
  74. * Used as the baseline an untouched field shows and a revert returns to.
  75. * Empty when unavailable, in which case the panel falls back to the option
  76. * schema's compiled-in defaults and says the values are indicative.
  77. */
  78. presetValues?: Record<string, SettingValue>;
  79. /** False when the preset's values could not be fetched. */
  80. presetValuesResolved?: boolean;
  81. /**
  82. * Why they could not be fetched, so the notice can name a fix. Left
  83. * unset while the fetch is still in flight.
  84. */
  85. presetValuesReason?: SlicerPresetValuesReason;
  86. }
  87. export interface FilamentChoice {
  88. /** 1-based slot index, matching the integer the slicer stores. */
  89. index: number;
  90. /** Preset name, or a fallback when the slot has no pick yet. */
  91. label: string;
  92. /** Slot colour from the source plate, for the swatch. */
  93. color?: string;
  94. }
  95. /**
  96. * Options whose integer value names a filament slot rather than a quantity.
  97. * All use the same encoding: 0 = "default / current filament", 1..N = slot.
  98. * Support base and interface are the pair on the Support page; the rest are
  99. * the Multimaterial page's per-region pickers, which have the same wart.
  100. */
  101. const FILAMENT_SLOT_OPTIONS = new Set([
  102. 'support_filament',
  103. 'support_interface_filament',
  104. 'outer_wall_filament_id',
  105. 'inner_wall_filament_id',
  106. 'top_surface_filament_id',
  107. 'bottom_surface_filament_id',
  108. 'internal_solid_filament_id',
  109. 'sparse_infill_filament_id',
  110. ]);
  111. /** Visibility tiers, in increasing order of how much they reveal. */
  112. const MODES: OptionMode[] = ['simple', 'advanced', 'expert'];
  113. const MODE_RANK: Record<string, number> = { simple: 0, advanced: 1, expert: 2, develop: 3 };
  114. export default function SlicerSettingsPanel({
  115. values,
  116. onChange,
  117. disabled = false,
  118. sourceOverrides = [],
  119. sourceSelected,
  120. onToggleSource,
  121. filamentChoices,
  122. presetValues,
  123. presetValuesResolved = true,
  124. presetValuesReason,
  125. }: Props) {
  126. const { t } = useTranslation();
  127. const [data, setData] = useState<SlicerData | null>(null);
  128. const [mode, setMode] = useState<OptionMode>('simple');
  129. const [page, setPage] = useState<string | null>(null);
  130. const [query, setQuery] = useState('');
  131. // 150 KB of extracted metadata has no business in the main bundle — it is
  132. // only needed once someone opens this panel.
  133. useEffect(() => {
  134. let cancelled = false;
  135. Promise.all([
  136. import('../data/slicer/process-schema.json'),
  137. import('../data/slicer/process-ui-tree.json'),
  138. import('../data/slicer/process-toggle-rules.json'),
  139. ]).then(([schema, tree, toggles]) => {
  140. if (cancelled) return;
  141. setData({
  142. schema: (schema.default ?? schema) as unknown as ProcessSchema,
  143. tree: (tree.default ?? tree) as unknown as ProcessUiTree,
  144. toggles: (toggles.default ?? toggles) as unknown as ToggleRules,
  145. });
  146. });
  147. return () => {
  148. cancelled = true;
  149. };
  150. }, []);
  151. // What this slice will actually run with, in the same precedence order the
  152. // rows display: the picked preset underneath, the designer's value for each
  153. // key that is switched on, and anything typed here on top.
  154. const effectiveValues = useMemo(() => {
  155. const merged: Record<string, SettingValue> = { ...(presetValues ?? {}) };
  156. for (const o of sourceOverrides) {
  157. if (sourceSelected?.has(o.key)) merged[o.key] = o.value as SettingValue;
  158. }
  159. // An emptied field is not a value — leaving it in would read as "" and
  160. // send the config reader to the schema default, past the preset.
  161. for (const [key, value] of Object.entries(values)) {
  162. if (value !== undefined && value !== '') merged[key] = value;
  163. }
  164. return merged;
  165. }, [presetValues, sourceOverrides, sourceSelected, values]);
  166. // The slicer's own `enable_if` rules, evaluated against that rather than
  167. // against `values` alone (#2942). `values` holds only what the user typed
  168. // here, and the config reader falls back to the *schema* default for
  169. // everything else — so a preset with supports on read as
  170. // `enable_support: false` and greyed out the whole Support page, including
  171. // rows whose "from file" tick was on and whose value the slice used. A
  172. // greyed row used to grey its tick too, which left a setting that came from
  173. // the file, that the slice applied, and that nothing on screen could
  174. // switch off.
  175. const off = useMemo(
  176. () => (data ? disabledKeys(effectiveValues, data.schema, data.toggles) : new Set<string>()),
  177. [data, effectiveValues],
  178. );
  179. const sourceByKey = useMemo(
  180. () => new Map(sourceOverrides.map((o) => [o.key, o])),
  181. [sourceOverrides],
  182. );
  183. // The subset a bulk "use the designer's settings" may switch on: everything
  184. // the file changed except the values tuned for the designer's own machine
  185. // and the two that *are* the picked preset. Those two classes stay a
  186. // per-key decision, which is the classification #2622 made and this does
  187. // not widen.
  188. const carryableSource = useMemo(
  189. () => sourceOverrides.filter((o) => !o.printer_coupled && !o.preset_defining),
  190. [sourceOverrides],
  191. );
  192. const selectedSourceCount = useMemo(
  193. () => sourceOverrides.filter((o) => sourceSelected?.has(o.key)).length,
  194. [sourceOverrides, sourceSelected],
  195. );
  196. // Source overrides for keys the vendored schema doesn't cover. They still
  197. // apply — the backend reads their values from the file — so they get a group
  198. // of their own rather than being dropped from view.
  199. const unlistedSource = useMemo(() => {
  200. if (!data) return [];
  201. return sourceOverrides.filter((o) => !data.schema[o.key]);
  202. }, [data, sourceOverrides]);
  203. const emit = (next: Record<string, SettingValue>) => {
  204. if (!data) return;
  205. // Only genuine deviations are worth sending: an override that equals the
  206. // preset's own value is noise in the process JSON and makes the slice
  207. // request harder to read when something goes wrong.
  208. const changed: Record<string, SettingValue> = {};
  209. for (const [k, v] of Object.entries(next)) {
  210. if (data.schema[k] && isModified(data.schema[k], v, presetValues?.[k])) changed[k] = v;
  211. }
  212. onChange(next, serializeOverrides(changed, data.schema));
  213. };
  214. const setValue = (key: string, value: SettingValue | undefined) => {
  215. const next = { ...values };
  216. if (value === undefined) delete next[key];
  217. else next[key] = value;
  218. emit(next);
  219. };
  220. // Search cuts across every page; without a query we show the selected page.
  221. const visiblePages = useMemo(() => {
  222. if (!data) return [];
  223. // Underscores and spaces are interchangeable so "outer wall speed" finds
  224. // `outer_wall_speed`. That matters more than it looks: several labels are
  225. // only meaningful with their group ("Outer wall" under Speed), so the key
  226. // is often the only place the full phrase appears.
  227. const flatten = (s: string) => s.toLowerCase().replace(/[_\s]+/g, ' ').trim();
  228. const needle = flatten(query);
  229. const withinMode = (key: string) => MODE_RANK[data.schema[key]?.mode ?? 'expert'] <= MODE_RANK[mode];
  230. const matches = (key: string, group: string, page: string) => {
  231. if (!needle) return true;
  232. const opt = data.schema[key];
  233. // Group and page are matched too, so "speed" lists the Speed page's
  234. // options rather than only the handful with "speed" in their label.
  235. const haystack = [key, opt?.label ?? '', opt?.tooltip ?? '', group, page];
  236. return haystack.some((h) => flatten(h).includes(needle));
  237. };
  238. return data.tree
  239. .map((p) => ({
  240. ...p,
  241. groups: p.groups
  242. .map((g) => ({
  243. ...g,
  244. options: g.options.filter((k) => withinMode(k) && matches(k, g.group, p.page)),
  245. }))
  246. .filter((g) => g.options.length > 0),
  247. }))
  248. .filter((p) => p.groups.length > 0);
  249. }, [data, mode, query]);
  250. const activePage = useMemo(() => {
  251. if (visiblePages.length === 0) return null;
  252. if (query.trim()) return null; // Searching shows every match, not one page.
  253. return visiblePages.find((p) => p.page === page) ?? visiblePages[0];
  254. }, [visiblePages, page, query]);
  255. const modifiedCount = useMemo(() => {
  256. if (!data) return 0;
  257. return Object.keys(values).filter((k) => data.schema[k] && isModified(data.schema[k], values[k], presetValues?.[k])).length;
  258. }, [data, values, presetValues]);
  259. if (!data) {
  260. return (
  261. <div className="flex items-center justify-center gap-2 py-8 text-sm text-bambu-gray">
  262. <Loader2 className="w-4 h-4 animate-spin" />
  263. {t('slicerSettings.loading', 'Loading slicer settings...')}
  264. </div>
  265. );
  266. }
  267. const shownPages = activePage ? [activePage] : visiblePages;
  268. return (
  269. <div className="flex flex-col gap-3">
  270. <div className="flex flex-wrap items-center gap-2">
  271. <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
  272. {MODES.map((m) => (
  273. <button
  274. key={m}
  275. type="button"
  276. onClick={() => setMode(m)}
  277. disabled={disabled}
  278. className={`px-2.5 py-1 text-xs capitalize transition-colors ${
  279. mode === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
  280. }`}
  281. >
  282. {t(`slicerSettings.mode.${m}`, m)}
  283. </button>
  284. ))}
  285. </div>
  286. <div className="relative flex-1 min-w-[10rem]">
  287. <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray" />
  288. <input
  289. type="search"
  290. value={query}
  291. onChange={(e) => setQuery(e.target.value)}
  292. disabled={disabled}
  293. placeholder={t('slicerSettings.searchPlaceholder', 'Search settings')}
  294. 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"
  295. />
  296. </div>
  297. {modifiedCount > 0 && (
  298. <button
  299. type="button"
  300. onClick={() => emit({})}
  301. disabled={disabled}
  302. className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white"
  303. >
  304. <RotateCcw className="w-3 h-3" />
  305. {t('slicerSettings.resetAll', 'Reset {{count}}', { count: modifiedCount })}
  306. </button>
  307. )}
  308. </div>
  309. {!presetValuesResolved && (
  310. <p className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[0.7rem] text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
  311. {presetValuesReason === 'sidecar_outdated'
  312. ? t(
  313. 'slicerSettings.presetValuesOutdatedSidecar',
  314. "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.",
  315. )
  316. : presetValuesReason === 'not_configured'
  317. ? t(
  318. 'slicerSettings.presetValuesNotConfigured',
  319. "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.",
  320. )
  321. : presetValuesReason === 'sidecar_unavailable'
  322. ? t(
  323. 'slicerSettings.presetValuesSidecarUnavailable',
  324. "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.",
  325. )
  326. : t(
  327. 'slicerSettings.presetValuesUnavailable',
  328. "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
  329. )}
  330. </p>
  331. )}
  332. {/* What the file brings, and the only bulk way to take it. Nothing here
  333. is pre-ticked any more (#2942), so without this line the designer's
  334. settings would be reachable only by hunting for green chips across
  335. six pages of 348 options. "Use them" ticks the keys that carry
  336. across printers; the machine-tuned ones and the two that define the
  337. picked preset stay off, as they always have. */}
  338. {sourceOverrides.length > 0 && onToggleSource && (
  339. <div className="flex flex-wrap items-center gap-2 rounded border border-bambu-dark-tertiary px-2 py-1.5 text-[0.7rem] text-bambu-gray">
  340. <span className="min-w-0 flex-1">
  341. {t(
  342. 'slicerSettings.fromFileSummary',
  343. 'The designer changed {{count}} process settings in this file. Only the ones you tick are used.',
  344. { count: sourceOverrides.length },
  345. )}
  346. </span>
  347. <button
  348. type="button"
  349. disabled={disabled}
  350. onClick={() => carryableSource.forEach((o) => onToggleSource(o.key, true))}
  351. title={t(
  352. 'slicerSettings.fromFileUseAllHint',
  353. "Ticks the settings that carry across printers. The ones tuned for the designer's own printer, and the ones that define the preset you picked, stay off.",
  354. )}
  355. className="shrink-0 rounded border border-bambu-dark-tertiary px-1.5 py-0.5 hover:text-white disabled:opacity-40"
  356. >
  357. {t('slicerSettings.fromFileUseAll', "Use the designer's settings")}
  358. </button>
  359. {selectedSourceCount > 0 && (
  360. <button
  361. type="button"
  362. disabled={disabled}
  363. onClick={() => sourceOverrides.forEach((o) => onToggleSource(o.key, false))}
  364. className="shrink-0 rounded border border-bambu-dark-tertiary px-1.5 py-0.5 hover:text-white disabled:opacity-40"
  365. >
  366. {t('slicerSettings.fromFileClear', 'Clear {{count}}', { count: selectedSourceCount })}
  367. </button>
  368. )}
  369. </div>
  370. )}
  371. {!query.trim() && (
  372. <div className="flex flex-wrap gap-1">
  373. {visiblePages.map((p) => (
  374. <button
  375. key={p.page}
  376. type="button"
  377. onClick={() => setPage(p.page)}
  378. disabled={disabled}
  379. className={`px-2 py-1 text-xs rounded transition-colors ${
  380. activePage?.page === p.page ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
  381. }`}
  382. >
  383. {p.page}
  384. </button>
  385. ))}
  386. </div>
  387. )}
  388. {shownPages.length === 0 ? (
  389. <p className="py-6 text-center text-xs text-bambu-gray">
  390. {t('slicerSettings.noMatches', 'No settings match this search.')}
  391. </p>
  392. ) : (
  393. // Taller once the panel has a column of its own; the narrow cap keeps
  394. // it from swallowing the single-column stack on small screens.
  395. <div className="flex flex-col gap-4 max-h-[22rem] lg:max-h-[58vh] overflow-y-auto pr-1">
  396. {shownPages.map((p) => (
  397. <div key={p.page} className="flex flex-col gap-3">
  398. {query.trim() && <p className="text-[0.7rem] uppercase tracking-wide text-bambu-gray/70">{p.page}</p>}
  399. {p.groups.map((g) => (
  400. <fieldset key={`${p.page}:${g.group}`} className="flex flex-col gap-1.5">
  401. <legend className="mb-1 text-xs font-medium text-white">{g.group}</legend>
  402. {g.options.map((key) => (
  403. <OptionRow
  404. key={key}
  405. optionKey={key}
  406. option={data.schema[key]}
  407. value={values[key]}
  408. onChange={(v) => setValue(key, v)}
  409. disabled={disabled || off.has(key)}
  410. disabledBySlicer={off.has(key)}
  411. formDisabled={disabled}
  412. source={sourceByKey.get(key)}
  413. sourceOn={sourceSelected?.has(key) ?? false}
  414. onToggleSource={onToggleSource}
  415. filamentChoices={FILAMENT_SLOT_OPTIONS.has(key) ? filamentChoices : undefined}
  416. presetValue={presetValues?.[key]}
  417. />
  418. ))}
  419. </fieldset>
  420. ))}
  421. </div>
  422. ))}
  423. {/* Source-file settings the vendored schema has no entry for: they
  424. still apply (the backend reads their values from the file), so
  425. they get a plain key/value group rather than disappearing from a
  426. panel that claims to show what this slice will use. */}
  427. {unlistedSource.length > 0 && !query.trim() && (
  428. <fieldset className="flex flex-col gap-1.5">
  429. <legend className="mb-1 text-xs font-medium text-white">
  430. {t('slicerSettings.otherFromFile', 'Other settings from this file')}
  431. </legend>
  432. {unlistedSource.map((o) => (
  433. <label key={o.key} className="flex items-center gap-2 text-xs cursor-pointer">
  434. <input
  435. type="checkbox"
  436. checked={sourceSelected?.has(o.key) ?? false}
  437. disabled={disabled || !onToggleSource}
  438. onChange={(e) => onToggleSource?.(o.key, e.target.checked)}
  439. className="shrink-0 cursor-pointer disabled:opacity-40"
  440. />
  441. <span className="min-w-0 flex-1 truncate">
  442. <span className="font-mono text-bambu-gray">{o.key}</span>
  443. <span className="ml-1.5 text-white">{formatSourceValue(o.value)}</span>
  444. </span>
  445. {(o.printer_coupled || o.preset_defining) && (
  446. <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">
  447. {o.printer_coupled
  448. ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
  449. : t('slicerSettings.fromFileOverridesPreset', 'overrides preset')}
  450. </span>
  451. )}
  452. </label>
  453. ))}
  454. </fieldset>
  455. )}
  456. </div>
  457. )}
  458. </div>
  459. );
  460. }
  461. interface RowProps {
  462. optionKey: string;
  463. option: ProcessOption;
  464. value: SettingValue | undefined;
  465. onChange: (value: SettingValue | undefined) => void;
  466. disabled: boolean;
  467. /** Greyed because the slicer's own rules turn it off, not because the form is busy. */
  468. disabledBySlicer: boolean;
  469. /**
  470. * The panel-wide disabled state, without the slicer's per-option rules.
  471. *
  472. * Gates the "from file" tick, which answers a different question from the
  473. * control beside it: not "is this option in play" but "where does its value
  474. * come from". An option the slicer has switched off can still be one the
  475. * user wants the file's value for once it comes back into play, and folding
  476. * the two together is what made a ticked source setting unclearable (#2942).
  477. */
  478. formDisabled: boolean;
  479. /** Set when the source file's designer moved this option off the stock preset. */
  480. source?: DesignOverride;
  481. sourceOn?: boolean;
  482. onToggleSource?: (key: string, on: boolean) => void;
  483. /** Set only for options whose integer value names a filament slot. */
  484. filamentChoices?: FilamentChoice[];
  485. /** The picked preset's value for this option, when known. */
  486. presetValue?: SettingValue;
  487. }
  488. function OptionRow({
  489. optionKey,
  490. option,
  491. value,
  492. onChange,
  493. disabled,
  494. disabledBySlicer,
  495. formDisabled,
  496. source,
  497. sourceOn = false,
  498. onToggleSource,
  499. filamentChoices,
  500. presetValue,
  501. }: RowProps) {
  502. const { t } = useTranslation();
  503. const modified = isModified(option, value, presetValue);
  504. const unit = displaySidetext(option);
  505. // What this slice will actually use, in precedence order: a value typed here
  506. // wins, then the designer's value if it is switched on, then the preset's own
  507. // (or the schema default when the preset's values are unavailable).
  508. const current =
  509. value !== undefined
  510. ? String(value)
  511. : sourceOn && source
  512. ? formatSourceValue(source.value)
  513. : baselineForDisplay(option, presetValue);
  514. return (
  515. <div className="flex items-center gap-2 group" title={option.tooltip}>
  516. {/* Label takes the slack; the control group is a fixed width anchored to
  517. the right edge. Fixed widths on the control *and* the unit are what
  518. keep that column straight — sizing either to content makes each row's
  519. input land at a different x. */}
  520. <label
  521. htmlFor={`slicer-opt-${optionKey}`}
  522. className={`flex min-w-0 flex-1 items-center gap-1 text-xs ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
  523. >
  524. {/* Own title: a fixed column truncates more than the old flex-1 label
  525. did, and the row's title carries the tooltip, not the name. */}
  526. <span className="truncate" title={option.label || optionKey}>
  527. {option.label || optionKey}
  528. </span>
  529. {modified && <span className="shrink-0 text-bambu-green" aria-hidden="true">•</span>}
  530. {source && (
  531. <span
  532. className={`shrink-0 rounded px-1 py-0.5 text-[10px] ${
  533. source.printer_coupled || source.preset_defining
  534. ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
  535. : 'bg-bambu-green/15 text-bambu-green'
  536. }`}
  537. title={
  538. source.printer_coupled
  539. ? t('slicerSettings.fromFilePrinterCoupledHint', "Tuned for the printer this file was designed for -- may be wrong or out of range on yours.")
  540. : source.preset_defining
  541. ? t(
  542. 'slicerSettings.fromFileOverridesPresetHint',
  543. 'The file sets this to {{value}} where the preset you picked uses {{preset}}. Tick it only if the file should win.',
  544. { value: formatSourceValue(source.value), preset: baselineForDisplay(option, presetValue) },
  545. )
  546. : t('slicerSettings.fromFileHint', "The designer changed this in the source file. Its value is {{value}}.", { value: formatSourceValue(source.value) })
  547. }
  548. >
  549. {source.printer_coupled
  550. ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
  551. : source.preset_defining
  552. ? t('slicerSettings.fromFileOverridesPreset', 'overrides preset')
  553. : t('slicerSettings.fromFile', 'from file')}
  554. </span>
  555. )}
  556. </label>
  557. <div className="flex shrink-0 items-center gap-1.5">
  558. {/* The "use the file's value" tick comes *before* the control it
  559. qualifies, as a checkbox that gates a field conventionally does —
  560. it used to sit past the unit, out at the right edge, reading as
  561. unrelated to the field. The slot is reserved on every row so rows
  562. with and without a source override keep the control column
  563. straight. */}
  564. <span className="flex w-3 shrink-0 justify-center">
  565. {source && onToggleSource && (
  566. <input
  567. type="checkbox"
  568. checked={sourceOn}
  569. disabled={formDisabled}
  570. onChange={(e) => onToggleSource(optionKey, e.target.checked)}
  571. aria-label={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
  572. option: option.label || optionKey,
  573. })}
  574. title={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
  575. option: option.label || optionKey,
  576. })}
  577. className="w-3 h-3 cursor-pointer disabled:opacity-40"
  578. />
  579. )}
  580. </span>
  581. <div className="w-40">
  582. <OptionControl
  583. id={`slicer-opt-${optionKey}`}
  584. option={option}
  585. current={current}
  586. onChange={onChange}
  587. disabled={disabled}
  588. filamentChoices={filamentChoices}
  589. />
  590. </div>
  591. {/* Fixed width so the control column stays straight, but wide enough
  592. for the longest unit in the schema ("mm/s² or %") — a narrower cap
  593. truncated those to "mm o...". Rendered even when empty so rows
  594. without a unit keep the revert button aligned. */}
  595. <span className="w-16 shrink-0 whitespace-nowrap text-[0.65rem] text-bambu-gray/60">{unit ?? ''}</span>
  596. <button
  597. type="button"
  598. onClick={() => onChange(undefined)}
  599. disabled={disabled || !modified}
  600. aria-label={t('slicerSettings.resetOption', 'Reset to default')}
  601. className={`p-0.5 transition-opacity ${modified ? 'text-bambu-gray hover:text-white' : 'opacity-0 pointer-events-none'}`}
  602. >
  603. <RotateCcw className="w-3 h-3" />
  604. </button>
  605. </div>
  606. </div>
  607. );
  608. }
  609. /**
  610. * Render a value read out of the source file. Bambu's process config stores
  611. * everything as strings or arrays of strings, so this only has to flatten
  612. * arrays — no unit or type interpretation, which would rot against every
  613. * slicer release.
  614. */
  615. function formatSourceValue(value: unknown): string {
  616. if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
  617. if (value == null) return '';
  618. return String(value);
  619. }
  620. interface ControlProps {
  621. id: string;
  622. option: ProcessOption;
  623. current: string;
  624. onChange: (value: SettingValue | undefined) => void;
  625. disabled: boolean;
  626. filamentChoices?: FilamentChoice[];
  627. }
  628. function OptionControl({ id, option, current, onChange, disabled, filamentChoices }: ControlProps) {
  629. const { t } = useTranslation();
  630. // Theme tokens rather than raw black/white: bambu-dark and
  631. // bambu-dark-tertiary are CSS variables that follow the active theme, and
  632. // `text-white` is remapped to --text-primary in index.css.
  633. const inputClass =
  634. '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';
  635. // Filament-slot pickers come before the generic branches: the value is an
  636. // integer, but offering a spinner over "1, 2, 3" makes the user map slot
  637. // numbers to their own AMS by hand.
  638. if (filamentChoices && filamentChoices.length > 0) {
  639. const selected = filamentChoices.find((c) => String(c.index) === current);
  640. return (
  641. <div className="relative w-full">
  642. <select
  643. id={id}
  644. value={current}
  645. onChange={(e) => onChange(e.target.value)}
  646. disabled={disabled}
  647. className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
  648. // The full name rarely fits in the control, so the hover carries it.
  649. title={selected?.label}
  650. >
  651. {/* 0 is the slicer's "no specific filament — use the region's own". */}
  652. <option value="0">{t('slicerSettings.filamentDefault', 'Default')}</option>
  653. {filamentChoices.map((choice) => (
  654. <option key={choice.index} value={String(choice.index)}>
  655. {choice.index}: {choice.label}
  656. </option>
  657. ))}
  658. </select>
  659. <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
  660. </div>
  661. );
  662. }
  663. if (option.type === 'coBool') {
  664. return (
  665. <input
  666. id={id}
  667. type="checkbox"
  668. checked={current === '1' || current === 'true'}
  669. onChange={(e) => onChange(e.target.checked)}
  670. disabled={disabled}
  671. className="w-3.5 h-3.5 cursor-pointer disabled:opacity-40"
  672. />
  673. );
  674. }
  675. if (option.type === 'coEnum' && option.enum_values) {
  676. // Native select chrome is replaced the same way as everywhere else in
  677. // Bambuddy: appearance-none plus our own chevron, so the control matches
  678. // the app in both themes instead of whatever the browser paints.
  679. return (
  680. <div className="relative w-full">
  681. <select
  682. id={id}
  683. value={current}
  684. onChange={(e) => onChange(e.target.value)}
  685. disabled={disabled}
  686. className={`${inputClass} w-full cursor-pointer appearance-none pr-5`}
  687. >
  688. {option.enum_values.map((v, i) => (
  689. <option key={v} value={v}>
  690. {option.enum_labels?.[i] ?? v}
  691. </option>
  692. ))}
  693. </select>
  694. <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-bambu-gray" />
  695. </div>
  696. );
  697. }
  698. if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') {
  699. return (
  700. <input
  701. id={id}
  702. type="number"
  703. value={current.replace('%', '')}
  704. min={numericBound(option.min)}
  705. max={numericBound(option.max)}
  706. step={option.type === 'coInt' ? 1 : 'any'}
  707. // An empty field is kept as an empty string rather than dropped.
  708. // Dropping it would fall the input straight back to the default, so
  709. // clearing a value to retype it would silently append to the old one.
  710. // Empty never counts as modified, so nothing is sent for it either way;
  711. // the revert button is what actually removes the key.
  712. onChange={(e) => onChange(e.target.value)}
  713. disabled={disabled}
  714. className={inputClass}
  715. />
  716. );
  717. }
  718. // coFloatOrPercent, the vector types and coString all accept free text: they
  719. // hold values like "50%", "0.42" or a comma-separated per-extruder list, none
  720. // of which a number input can represent.
  721. return (
  722. <input
  723. id={id}
  724. type="text"
  725. value={current}
  726. onChange={(e) => onChange(e.target.value)}
  727. disabled={disabled}
  728. className={inputClass}
  729. />
  730. );
  731. }