mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Sync
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user