This commit is contained in:
maziggy
2026-08-09 10:00:53 +02:00
parent 2368445378
commit c384911f7c
33 changed files with 1322 additions and 426 deletions
+120 -99
View File
@@ -1,5 +1,5 @@
import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -17,7 +17,7 @@ import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
import { useToast } from '../contexts/ToastContext';
import { useIsWideLayout } from '../hooks/useIsWideLayout';
import { PlatePickerModal } from './PlatePickerModal';
import SlicerSettingsPanel from './SlicerSettingsPanel';
import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel';
import type { DesignOverride, PlateFilament } from '../types/plates';
import type { SettingValue } from '../types/slicerSettings';
import {
@@ -189,16 +189,6 @@ function formatElapsed(seconds: number): string {
return `${h}h ${remM}m`;
}
// Render a slicer parameter value for the design-settings list. Bambu's process
// schema stores everything as strings or arrays of strings, so this only has to
// flatten arrays and keep scalars readable — no unit or type interpretation,
// which would rot against every slicer release.
function formatDesignValue(value: unknown): string {
if (Array.isArray(value)) return value.map((v) => String(v)).join(', ');
if (value == null) return '';
return String(value);
}
export function SliceModal({ source, onClose }: SliceModalProps) {
const { t } = useTranslation();
const { trackJob } = useSliceJobTracker();
@@ -256,7 +246,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// accelerations, prime-tower geometry) are listed but start unticked — those
// were tuned for the designer's printer and can be plain wrong on another.
const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
const [designExpanded, setDesignExpanded] = useState(false);
// Process settings the user edited by hand in the settings panel. Two shapes
// are kept: the panel's editing values, and the same set serialised into the
@@ -432,6 +421,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
[printerModelsQuery.data],
);
// Slot list for the settings panel's filament pickers (support base and
// interface, and the Multimaterial page's per-region options). Those store a
// plain integer, so without this the user has to map slot numbers onto their
// own AMS by hand. Falls back to the slot's material when a slot has no pick
// yet, so the list is never a column of blanks.
const filamentChoices = useMemo<FilamentChoice[]>(() => {
const data = presetsQuery.data;
return filamentSlots.map((slot, idx) => {
const ref = filamentPresets[idx] ?? null;
const preset = data && ref ? findPreset(data, ref, 'filament') : null;
return {
index: idx + 1,
label: preset?.name || slot.type || t('slice.filamentSlotUnset', 'not set'),
color: slot.color || undefined,
};
});
}, [filamentSlots, filamentPresets, presetsQuery.data, t]);
// Printer / process preset names the source 3MF was prepared with. The
// plates query resolves before the presets query (the latter is gated on
// it), so these are known by the time the pre-pick effects run.
@@ -857,68 +864,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
selectedPrinterName={selectedPrinterName}
compatIndex={compatIndex}
/>
{/* Designer's process tweaks (#2622). BambuStudio records which
keys deviate from the stock preset in the 3MF itself, so a
re-slice for another printer can carry them instead of
flattening them under --load-settings. Hidden entirely when
the source lists none, and disabled in embedded mode where
the process JSON these patch is never sent. */}
{designOverrides.length > 0 && (
<div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 p-3">
<button
type="button"
onClick={() => setDesignExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-sm text-white">
{t('slice.designSettings')}
<span className="block text-xs text-bambu-gray/70">
{t('slice.designSettingsHint', { count: designOverrides.length })}
</span>
</span>
<span className="shrink-0 text-xs text-bambu-gray">
{t('slice.designSettingsSelected', { selected: designKeys.size, total: designOverrides.length })}
</span>
</button>
{designExpanded && (
<div className="mt-3 space-y-1.5 border-t border-bambu-dark-tertiary pt-3">
{designOverrides.map((o) => (
<label
key={o.key}
className={`flex items-start gap-2 text-xs ${useEmbedded ? 'opacity-50' : 'cursor-pointer'}`}
>
<input
type="checkbox"
checked={designKeys.has(o.key)}
disabled={isEnqueuing || useEmbedded}
onChange={(e) => {
setDesignKeys((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(o.key);
else next.delete(o.key);
return next;
});
}}
className="mt-0.5 shrink-0 cursor-pointer"
/>
<span className="min-w-0 flex-1">
<span className="font-mono text-bambu-gray">{o.key}</span>
<span className="ml-1.5 break-all text-white">{formatDesignValue(o.value)}</span>
{o.printer_coupled && (
<span
className="ml-1.5 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
title={t('slice.designSettingsPrinterCoupledHint')}
>
{t('slice.designSettingsPrinterCoupled')}
</span>
)}
</span>
</label>
))}
</div>
)}
</div>
)}
{/* Bed-type override (#1337). Always visible, always enabled.
The backend patches curr_bed_type on the resolved process
@@ -1026,13 +971,17 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{/* Right column: the settings panel. It owns this column, so
there is nothing to collapse it out of the way of — the
disclosure below lg exists only because the single-column
stack cannot afford 348 options unfolded. */}
stack cannot afford 348 options unfolded.
Kept on screen in embedded mode but disabled rather than
removed: nothing here is sent on that path (the file's own
settings drive the slice), and dropping the column outright
made the dialog look like it had lost a feature whenever
the toggle was flipped. */}
<div className="mt-4 lg:mt-0 min-w-0">
{/* Process settings, mirroring OrcaSlicer's own Print Settings
tabs. Hidden entirely in embedded mode, where no process
JSON is sent for these to patch. */}
{!useEmbedded && (
<div className="rounded border border-bambu-dark-tertiary p-3">
<div
className={`rounded border border-bambu-dark-tertiary p-3 ${useEmbedded ? 'opacity-60' : ''}`}
>
<button
type="button"
onClick={() => setSettingsExpanded((v) => !v)}
@@ -1043,15 +992,25 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
<span className="text-sm text-white">
{t('slice.processSettings', 'Process settings')}
<span className="block text-xs text-bambu-gray/70">
{t('slice.processSettingsHint', "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.")}
{useEmbedded
? t(
'slice.processSettingsEmbedded',
"Not used while \"Use the file's built-in settings\" is on -- the file's own settings drive this slice.",
)
: t(
'slice.processSettingsHint',
"Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.",
)}
</span>
</span>
<span className="shrink-0 text-xs text-bambu-gray">
{Object.keys(serializedProcessOverrides).length > 0
? t('slice.processSettingsChanged', '{{count}} changed', {
count: Object.keys(serializedProcessOverrides).length,
})
: t('slice.processSettingsUnchanged', 'Preset defaults')}
{useEmbedded
? t('slice.processSettingsInactive', 'Inactive')
: Object.keys(serializedProcessOverrides).length > 0
? t('slice.processSettingsChanged', '{{count}} changed', {
count: Object.keys(serializedProcessOverrides).length,
})
: t('slice.processSettingsUnchanged', 'Preset defaults')}
</span>
</button>
{panelOpen && (
@@ -1062,12 +1021,28 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
setProcessOverrides(values);
setSerializedProcessOverrides(serialized);
}}
disabled={isEnqueuing}
disabled={isEnqueuing || useEmbedded}
// The designer's own deviations (#2622) are shown
// against the options they belong to rather than in
// a list of their own. Only the tick state lives
// here; the values still travel as design_overrides,
// so the backend keeps reading them from the file
// and keys outside the vendored schema stay faithful.
filamentChoices={filamentChoices}
sourceOverrides={designOverrides}
sourceSelected={designKeys}
onToggleSource={(key, on) =>
setDesignKeys((prev) => {
const next = new Set(prev);
if (on) next.add(key);
else next.delete(key);
return next;
})
}
/>
</div>
)}
</div>
)}
</div>
</div>
</>
@@ -1259,8 +1234,8 @@ interface PresetDropdownProps {
// configuring against the source 3MF's per-slot colour.
swatchColor?: string;
// Selected printer context (#1325). When provided for a process / filament
// slot, presets that resolve to a different printer (per compatIndex) move
// into a trailing "Other printers" group instead of the main tier list.
// slot, presets that resolve to a different printer (per compatIndex) are
// held back behind a "Show all" link instead of padding out the main list.
selectedPrinterName?: string | null;
compatIndex?: PrinterCompatibilityIndex;
}
@@ -1277,6 +1252,14 @@ function PresetDropdown({
compatIndex,
}: PresetDropdownProps) {
const { t } = useTranslation();
// Reveals the other-printer group for this slot only. Per-dropdown rather
// than modal-wide: wanting a filament from another printer's library says
// nothing about wanting its process profiles too.
const [showAll, setShowAll] = useState(false);
// Binds the label to the select now that they are siblings rather than
// nested. Filament slots render several of these, so the id must be unique
// per instance rather than derived from the slot name.
const selectId = useId();
// Tier sections (imported → cloud → standard), plus — for a process /
// filament slot with a selected printer — a trailing group of presets that
@@ -1322,12 +1305,30 @@ function PresetDropdown({
return { sections: compatSections, otherEntries: other };
}, [data, slot, t, selectedPrinterName, compatIndex]);
// Other-printer presets are held back by default so the list shows what is
// usable on the selected printer. Two things are never hidden: a preset whose
// compatibility is merely *unknown* (it never reaches otherEntries), and the
// one currently selected — a pipeline or an auto-pick can land on a
// cross-printer preset, and dropping it from the options would blank the
// select and silently discard the choice.
const selectedRefValue = toRefValue(value);
const visibleOther = useMemo(() => {
if (showAll) return otherEntries;
return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
}, [showAll, otherEntries, selectedRefValue]);
const hiddenCount = otherEntries.length - visibleOther.length;
const totalEntries =
sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;
sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length;
return (
<label className="block">
<span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
// A plain wrapper rather than a <label> around everything: the "Show all"
// control is a button, and a button inside a label that also wraps the
// select inherits the whole label as its accessible name (screen readers
// announced it as "Process profile 2 hidden 0.20mm Standard @BBL X1C") as
// well as being invalid HTML. The label is bound to the select by id.
<div className="block">
<div className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
{swatchColor && (
<span
className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
@@ -1335,9 +1336,29 @@ function PresetDropdown({
aria-hidden
/>
)}
<span>{label}</span>
</span>
<label htmlFor={selectId}>{label}</label>
{(hiddenCount > 0 || showAll) && (
<span className="ml-auto flex items-center gap-1.5 font-normal">
{hiddenCount > 0 && (
<span className="text-bambu-gray/60">
{t('slice.presetsHidden', '{{count}} hidden', { count: hiddenCount })}
</span>
)}
<button
type="button"
onClick={() => setShowAll((v) => !v)}
disabled={disabled}
className="text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline"
>
{showAll
? t('slice.showFewerPresets', 'Show fewer')
: t('slice.showAllPresets', 'Show all')}
</button>
</span>
)}
</div>
<select
id={selectId}
value={toRefValue(value)}
onChange={(e) => onChange(fromRefValue(e.target.value))}
disabled={disabled || totalEntries === 0}
@@ -1357,9 +1378,9 @@ function PresetDropdown({
))}
</optgroup>
))}
{otherEntries.length > 0 && (
{visibleOther.length > 0 && (
<optgroup label={t('slice.otherPrinters')}>
{otherEntries.map((p) => (
{visibleOther.map((p) => (
<option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
{p.name}
</option>
@@ -1367,6 +1388,6 @@ function PresetDropdown({
</optgroup>
)}
</select>
</label>
</div>
);
}
+260 -28
View File
@@ -20,11 +20,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, RotateCcw, Loader2 } from 'lucide-react';
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;
@@ -45,13 +46,73 @@ interface Props {
*/
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 }: Props) {
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');
@@ -84,6 +145,19 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
[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
@@ -106,19 +180,30 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
// Search cuts across every page; without a query we show the selected page.
const visiblePages = useMemo(() => {
if (!data) return [];
const needle = query.trim().toLowerCase();
// 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) => {
const matches = (key: string, group: string, page: string) => {
if (!needle) return true;
const opt = data.schema[key];
return key.includes(needle) || opt?.label?.toLowerCase().includes(needle) || opt?.tooltip?.toLowerCase().includes(needle);
// 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)) }))
.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);
@@ -149,7 +234,7 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex rounded overflow-hidden border border-white/10">
<div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
{MODES.map((m) => (
<button
key={m}
@@ -157,7 +242,7 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
onClick={() => setMode(m)}
disabled={disabled}
className={`px-2.5 py-1 text-xs capitalize transition-colors ${
mode === m ? 'bg-bambu-green text-black' : 'text-bambu-gray hover:text-white'
mode === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
}`}
>
{t(`slicerSettings.mode.${m}`, m)}
@@ -173,7 +258,7 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
onChange={(e) => setQuery(e.target.value)}
disabled={disabled}
placeholder={t('slicerSettings.searchPlaceholder', 'Search settings')}
className="w-full bg-black/30 border border-white/10 rounded pl-7 pr-2 py-1 text-xs text-white placeholder:text-bambu-gray/60"
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>
@@ -199,7 +284,7 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
onClick={() => setPage(p.page)}
disabled={disabled}
className={`px-2 py-1 text-xs rounded transition-colors ${
activePage?.page === p.page ? 'bg-white/10 text-white' : 'text-bambu-gray hover:text-white'
activePage?.page === p.page ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
}`}
>
{p.page}
@@ -221,7 +306,7 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
{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="text-xs font-medium text-white/80 mb-1">{g.group}</legend>
<legend className="mb-1 text-xs font-medium text-white">{g.group}</legend>
{g.options.map((key) => (
<OptionRow
key={key}
@@ -231,12 +316,48 @@ export default function SlicerSettingsPanel({ values, onChange, disabled = false
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>
@@ -251,13 +372,37 @@ interface RowProps {
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 }: RowProps) {
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);
const current = value === undefined ? defaultForDisplay(option) : String(value);
// 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}>
@@ -267,6 +412,24 @@ function OptionRow({ optionKey, option, value, onChange, disabled, disabledBySli
>
{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">
@@ -276,8 +439,24 @@ function OptionRow({ optionKey, option, value, onChange, disabled, disabledBySli
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)}
@@ -292,16 +471,63 @@ function OptionRow({ optionKey, option, value, onChange, disabled, disabledBySli
);
}
/**
* 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 }: ControlProps) {
const inputClass = 'bg-black/30 border border-white/10 rounded px-1.5 py-0.5 text-xs text-white disabled:opacity-40 w-24';
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 (
@@ -317,20 +543,26 @@ function OptionControl({ id, option, current, onChange, disabled }: ControlProps
}
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 (
<select
id={id}
value={current}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={inputClass}
>
{option.enum_values.map((v, i) => (
<option key={v} value={v}>
{option.enum_labels?.[i] ?? v}
</option>
))}
</select>
<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>
);
}