diff --git a/src/App.tsx b/src/App.tsx index 59dcd9f..06dcc9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import PreviewActions from './components/PreviewActions'; import { useDropzone } from './hooks/useDropzone'; import { exportObjectToStlBlob } from './lib/exportStl'; import { exportObjectTo3MFBlob } from './lib/export3mf'; +import { buildMultiHeadSchedule } from './lib/multiHeadSchedule'; import { useAppHandlers, type ExportProgressStep } from './hooks/useAppHandlers'; import { useProcessingState } from './hooks/useProcessingState'; import { useBuildWarning } from './hooks/useBuildWarning'; @@ -107,6 +108,9 @@ type AutoPaintPersisted = Pick< | 'heightDithering' | 'ditherLineWidth' | 'flatPaint' + | 'multiHeadMode' + | 'multiHeadCount' + | 'multiHeadSearchDepth' >; // Schema v2: filament `td` values store frontlit hiding distances. State @@ -170,6 +174,9 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { heightDithering: parsed.heightDithering ?? false, ditherLineWidth: parsed.ditherLineWidth, flatPaint: parsed.flatPaint ?? false, + multiHeadMode: parsed.multiHeadMode ?? false, + multiHeadCount: parsed.multiHeadCount ?? 4, + multiHeadSearchDepth: parsed.multiHeadSearchDepth ?? 'balanced', }; } catch { return null; @@ -344,6 +351,9 @@ function App(): React.ReactElement | null { heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering, ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth, flatPaint: autopaintHydrated.flatPaint ?? prev.flatPaint, + multiHeadMode: autopaintHydrated.multiHeadMode ?? prev.multiHeadMode, + multiHeadCount: autopaintHydrated.multiHeadCount ?? prev.multiHeadCount, + multiHeadSearchDepth: autopaintHydrated.multiHeadSearchDepth ?? prev.multiHeadSearchDepth, })); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -365,6 +375,9 @@ function App(): React.ReactElement | null { heightDithering: threeDState.heightDithering, ditherLineWidth: threeDState.ditherLineWidth, flatPaint: threeDState.flatPaint, + multiHeadMode: threeDState.multiHeadMode, + multiHeadCount: threeDState.multiHeadCount, + multiHeadSearchDepth: threeDState.multiHeadSearchDepth, }); }, [ threeDState.filaments, @@ -379,6 +392,9 @@ function App(): React.ReactElement | null { threeDState.heightDithering, threeDState.ditherLineWidth, threeDState.flatPaint, + threeDState.multiHeadMode, + threeDState.multiHeadCount, + threeDState.multiHeadSearchDepth, ]); // No auto-build on tab switch — the user must click "Build 3D Model" / "Apply Changes". @@ -541,8 +557,28 @@ function App(): React.ReactElement | null { exportObjectTo3MFBlob(obj, { layerHeight: builtModelState.layerHeight, firstLayerHeight: builtModelState.slicerFirstLayerHeight, - layerFilamentColors: builtModelAutoPaint - ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex) + layerFilamentColors: + builtModelAutoPaint + ? (builtModelState.patchedSliceData?.swatches + ?? builtModelState.autoPaintFilamentSwatches)?.map((s) => s.hex) + : undefined, + extruderCount: builtModelState.multiHeadMode + ? builtModelState.multiHeadCount + : undefined, + // Head Schedule swap checkpoints -> pause markers at those layers. + swapLayers: builtModelState.multiHeadMode + ? buildMultiHeadSchedule({ + multiHeadWindows: builtModelState.multiHeadWindows, + nozzleAssignments: builtModelState.nozzleAssignments, + windowRunFilaments: builtModelState.windowRunFilaments, + nonWindowedRanges: builtModelState.nonWindowedRanges, + filaments: builtModelState.filaments, + }) + ?.filter((e) => e.startLayer > 0 && e.swapCount > 0) + ?.map((e) => ({ + layer: e.startLayer, + color: e.nozzles.find((n) => n.changed)?.filamentHex, + })) : undefined, onProgress, onZipProgress, @@ -793,20 +829,39 @@ function App(): React.ReactElement | null { slicerFirstLayerHeight={ builtModelState.slicerFirstLayerHeight } - colorSliceHeights={builtModelState.colorSliceHeights} - colorOrder={builtModelState.colorOrder} - swatches={builtModelState.filteredSwatches} + colorSliceHeights={ + builtModelState.patchedSliceData?.colorSliceHeights ?? + builtModelState.colorSliceHeights + } + colorOrder={ + builtModelState.patchedSliceData?.colorOrder ?? + builtModelState.colorOrder + } + swatches={ + builtModelAutoPaint && builtModelState.patchedSliceData + ? builtModelState.patchedSliceData.swatches + : builtModelState.filteredSwatches + } filamentSwatches={ - builtModelAutoPaint + builtModelAutoPaint && !builtModelState.patchedSliceData ? builtModelState.autoPaintFilamentSwatches : undefined } pixelSize={builtModelState.pixelSize} rebuildSignal={threeDBuildSignal} - autoPaintEnabled={builtModelAutoPaint} - autoPaintTotalHeight={ - builtModelState.autoPaintResult?.totalHeight + perColorLayerColors={ + builtModelAutoPaint + ? builtModelState.perColorLayerColors + : undefined } + multiHeadWindows={builtModelState.multiHeadWindows} + colorLayerFilaments={builtModelState.colorLayerFilaments} + windowRunFilaments={builtModelState.windowRunFilaments} + nozzleAssignments={builtModelState.nozzleAssignments} + nonWindowedRanges={builtModelState.nonWindowedRanges} + filamentIds={builtModelState.filaments?.map((f) => f.id)} + autoPaintEnabled={builtModelAutoPaint} + autoPaintTotalHeight={builtModelState.autoPaintResult?.totalHeight} autoPaintFilamentOrder={ builtModelState.autoPaintResult?.filamentOrder } diff --git a/src/assets/diagrams/01_window_run_diagram.svg b/src/assets/diagrams/01_window_run_diagram.svg new file mode 100644 index 0000000..deec281 --- /dev/null +++ b/src/assets/diagrams/01_window_run_diagram.svg @@ -0,0 +1,72 @@ + + + +Colour Runs and Windows +How multi-head printing groups layers into scheduling units +N = 3 — the number of nozzle slots (print heads) active per window + +print height ↑ + + +Run 1 + + +Run 2 + + +Run 3 + + +Run 4 + + +Run 5 + + +Run 6 + + +Run 7 + + +Run 8 + + + + + +Window 1 + + + + + +Window 2 + + + + +unclaimed + +How it works +Run: +A contiguous block of layers all printed with the same filament. +Its height is set by the colour's brightness in the source image. +Window (N = 3 runs): +Exactly N = 3 consecutive runs form one window. The printer +pauses at each boundary so the operator can reload any of the 3 +nozzle slots. Each window gets its own optimal filament assignment. +Claimed (inside a window): +Each slot is loaded with the filament that best matches the target +colours across those layers. All 3 runs print under coordinated +multi-head control — the algorithm chose the assignment. +Unclaimed (outside any full window): +Runs that don't complete a group of N are printed with whatever +filament was last loaded. No dedicated assignment, no head-swap. + + + + +Each horizontal stripe = one printed layer (~0.2 mm) +Kromacut · multi-head colour analysis + \ No newline at end of file diff --git a/src/assets/diagrams/02_nozzle_swap_schedule.svg b/src/assets/diagrams/02_nozzle_swap_schedule.svg new file mode 100644 index 0000000..9656e64 --- /dev/null +++ b/src/assets/diagrams/02_nozzle_swap_schedule.svg @@ -0,0 +1,117 @@ + + + +Compacted Nozzle Swap Schedule +Which filament each nozzle carries across windows — swap events highlighted +Start / Window 1 +Window 2 +Window 3 +(+ unclaimed) +Nozzle 1 +Nozzle 2 +Nozzle 3 + +#C0392B + +#2980B9 + +#27AE60 + +#8E44AD + +#16A085 + +#27AE60 + +#8E44AD + +#2980B9 + +#D35400 + +swap + +swap + + +swap + +swap + + + + +→ print progression (windows, low to high) + + + +Start / Window 1 + + + +Window 2 + + + +Window 3 (+ unclaimed) + +Red +R1 + +swap + +Blu +R2 + +swap + +Gre +R3 + +swap + +Pur +R4 + +swap + +Tea +R5 + +swap + +Gre +R6 + +swap + +Pur +R7 + +swap + +Blu +R8 + +swap + +Ora +R9 +Multi-head: + + + +3 swaps +Single-extruder: + + + + + + + + + +9 swaps +Kromacut · multi-head colour analysis + \ No newline at end of file diff --git a/src/assets/diagrams/03_beer_lambert_blending.svg b/src/assets/diagrams/03_beer_lambert_blending.svg new file mode 100644 index 0000000..782ea50 --- /dev/null +++ b/src/assets/diagrams/03_beer_lambert_blending.svg @@ -0,0 +1,51 @@ + + + +Beer-Lambert Blending — Layer Assignment Changes Colour +Same three filaments, different per-layer assignment → different perceived colour. +Side bars: cumulative perceived colour as light penetrates deeper (outer layers dominate). + +outer face — closest to viewer + + + + + + +N1 + + + +N2 + + + +N3 + + + + +N1 + + + +N2 + + + +N3 + + +opaque back wall + +layer assignment changes here + +perceived colour +Arrangement A: N1=Blue, N2=Red, N3=Green + +perceived colour +Arrangement B: N1=Red, N2=Blue, N3=Green + + +Kromacut · multi-head colour analysis + \ No newline at end of file diff --git a/src/assets/diagrams/05_combo_search_space.svg b/src/assets/diagrams/05_combo_search_space.svg new file mode 100644 index 0000000..787da3e --- /dev/null +++ b/src/assets/diagrams/05_combo_search_space.svg @@ -0,0 +1,180 @@ + + + +Combo Search Space (K^N candidates per window) +Every permutation of filament-per-slot is evaluated; the best ΔE match wins + +All K=3³ = 27 combos (N=3 slots) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +← best + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Slot 1 +Slot 2 +Slot 3 +Each cell = one filament ordering (reorder + repeat allowed) + +Combo count = K^N +K=2 +K=3 +K=4 +K=5 +K=6 +N=2 + +4 + +9 + +16 + +25 + +36 +N=3 + +8 + +27 + +64 + +125 + +216 +N=4 + +16 + +81 + +256 + +625 + +1,296 +N=5 + +32 + +243 + +1,024 + +3,125 + +7,776 + +default (K=3, N=3) +Each extra slot or filament multiplies compute time. +K=4, N=4 → 256 combos per window; K=6, N=5 → 7,776. +Kromacut · multi-head colour analysis + \ No newline at end of file diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 7f80882..dfffbe8 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -169,6 +169,14 @@ interface AutoPaintTabProps { setOptimizerSeed: (v: number | undefined) => void; regionWeightingMode: 'uniform' | 'center' | 'edge'; setRegionWeightingMode: (v: 'uniform' | 'center' | 'edge') => void; + + // Multi-head mode + multiHeadMode: boolean; + setMultiHeadMode: (v: boolean) => void; + multiHeadCount: number; + setMultiHeadCount: (v: number) => void; + multiHeadSearchDepth: 'fast' | 'balanced' | 'thorough'; + setMultiHeadSearchDepth: (v: 'fast' | 'balanced' | 'thorough') => void; } export default function AutoPaintTab({ @@ -228,6 +236,12 @@ export default function AutoPaintTab({ setOptimizerSeed, regionWeightingMode, setRegionWeightingMode, + multiHeadMode, + setMultiHeadMode, + multiHeadCount, + setMultiHeadCount, + multiHeadSearchDepth, + setMultiHeadSearchDepth, }: AutoPaintTabProps) { const { result: nextBestResult, @@ -963,6 +977,76 @@ export default function AutoPaintTab({ )} + {/* Multi-head mode */} + {filaments.length > 0 && ( +
+
+
+
+ +

+ Optimize layer order per-pixel across N heads ({filaments.length > 0 ? `${Math.min(filaments.length, 5)}^${Math.min(filaments.length, 5)} = ${Math.pow(Math.min(filaments.length, 5), Math.min(filaments.length, 5)).toLocaleString()} color combinations` : 'load filaments to see'}) +

+
+ +
+ {multiHeadMode && ( +
+
+ + { + const v = parseInt(e.target.value, 10); + if (v >= 2) setMultiHeadCount(v); + }} + /> +
+
+ + +
+
+ )} +
+ )} + {/* Auto-paint transition zones preview */} {autoPaintResult && autoPaintResult.transitionZones.length > 0 && ( <> diff --git a/src/components/PrintInstructions.tsx b/src/components/PrintInstructions.tsx index f2b55c9..c94203c 100644 --- a/src/components/PrintInstructions.tsx +++ b/src/components/PrintInstructions.tsx @@ -1,8 +1,10 @@ import { CollapsibleCard, DirtyDot } from '@/components/CollapsibleCard'; -import type { SwapEntry } from '../hooks/useSwapPlan'; +import type { SwapEntry, MultiHeadScheduleEvent } from '../hooks/useSwapPlan'; interface PrintInstructionsProps { swapPlan: SwapEntry[]; + multiHeadPlan?: MultiHeadScheduleEvent[] | null; + multiHeadMode?: boolean; layerHeight: number; slicerFirstLayerHeight: number; copied: boolean; @@ -15,6 +17,8 @@ interface PrintInstructionsProps { export default function PrintInstructions({ swapPlan, + multiHeadPlan, + multiHeadMode = false, layerHeight, slicerFirstLayerHeight, copied, @@ -39,7 +43,6 @@ export default function PrintInstructions({ onClick={onCopy} title="Copy print instructions to clipboard" aria-pressed={copied} - disabled={tooManyColors} className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 ${ copied ? 'bg-green-600 text-white' @@ -51,27 +54,24 @@ export default function PrintInstructions({ } >
+ {tooManyColors && ( +
+ This print has {colorCount} layers — swap instructions may be slow to generate above 64. +
+ )} {/* Recommended Settings */}
Recommended Settings
-
- • Wall loops: 1 -
-
- • Infill: 100% -
+
• Wall loops: 1
+
• Infill: 100%
• Layer height:{' '} - - {layerHeight.toFixed(3)} mm - + {layerHeight.toFixed(3)} mm
• First layer height:{' '} - - {slicerFirstLayerHeight.toFixed(3)} mm - + {slicerFirstLayerHeight.toFixed(3)} mm
@@ -100,18 +100,18 @@ export default function PrintInstructions({
  • After printing, flip the piece over to view the image.
  • + ) : multiHeadPlan ? ( + + ) : multiHeadMode ? ( +
    + Click Build 3D Model to generate the multi-head schedule. +
    ) : ( + /* Single-head */ <> - {/* Start Color */}
    -
    - Start with Color -
    - {tooManyColors ? ( -
    - — -
    - ) : swapPlan.length && swapPlan[0].type === 'start' ? ( +
    Start with Color
    + {swapPlan.length && swapPlan[0].type === 'start' ? ( (() => { const sw = swapPlan[0].swatch; return ( @@ -128,24 +128,12 @@ export default function PrintInstructions({ ); })() ) : ( -
    - — -
    +
    )}
    - - {/* Color Swap Plan */}
    -
    - Color Swap Plan -
    - {tooManyColors ? ( -
    - Swap instructions are disabled for very large palettes ( - {colorCount} colors). Reduce the image to 64 colors or fewer in - 2D mode first. -
    - ) : swapPlan.length <= 1 ? ( +
    Color Swap Plan
    + {swapPlan.length <= 1 ? (
    Only one color configured — no swaps needed.
    @@ -203,3 +191,74 @@ export default function PrintInstructions({ ); } + +// --------------------------------------------------------------------------- +// HeadSchedule — multi-head load / swap schedule in layer order +// --------------------------------------------------------------------------- + +function HeadSchedule({ events }: { events: MultiHeadScheduleEvent[] }) { + if (events.length === 0) { + return ( +
    + No head assignments computed yet. +
    + ); + } + + return ( +
    +
    Head Schedule
    +
    + {events.filter(evt => evt.isPrePrint || evt.swapCount > 0).map((evt, evtIdx) => { + return ( +
    + {/* Event header */} +
    + {evt.isPrePrint + ? <>Before print — load all heads + : <>Layer {evt.startLayer} — swap {evt.swapCount} head{evt.swapCount !== 1 ? 's' : ''} + } +
    + + {/* Nozzle rows — all heads shown; changed ones highlighted */} +
    + {evt.nozzles.map((n) => ( +
    + + Head {n.nozzle} + + + + {n.filamentName} + +
    + ))} +
    +
    + ); + })} +
    +
    + ); +} diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 0cd0379..81d6d8d 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { Check, RotateCcw, Loader2 } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { autoPaintToSliceHeights } from '../lib/autoPaint'; +import { patchedLayersToPlan, patchedLayersToSliceData, buildPerColorLayerColors } from '../lib/patchedLayersToPlan'; import { loadPrintSettingsFromStorage, savePrintSettingsToStorage, @@ -16,6 +17,7 @@ import { useProfileManager } from '../hooks/useProfileManager'; import { useColorSlicing } from '../hooks/useColorSlicing'; import { useSwapPlan } from '../hooks/useSwapPlan'; import { useAutoPaintWorker } from '../hooks/useAutoPaintWorker'; +import { useMultiHeadWorker } from '../hooks/useMultiHeadWorker'; import type { AutoPaintRepeatLimit, AutoPaintTransitionOpacity, @@ -150,6 +152,13 @@ export default function ThreeDControls({ persisted?.regionWeightingMode ?? 'uniform' ); + // --- Multi-head mode --- + const [multiHeadMode, setMultiHeadMode] = useState(persisted?.multiHeadMode ?? false); + const [multiHeadCount, setMultiHeadCount] = useState(persisted?.multiHeadCount ?? 4); + const [multiHeadSearchDepth, setMultiHeadSearchDepth] = useState<'fast' | 'balanced' | 'thorough'>( + persisted?.multiHeadSearchDepth ?? 'balanced' + ); + const handleEnhancedColorMatchChange = useCallback((v: boolean) => { setEnhancedColorMatch(v); if (!v) { @@ -202,6 +211,9 @@ export default function ThreeDControls({ optimizerSeed, regionWeightingMode, smoothMeshing, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -218,6 +230,9 @@ export default function ThreeDControls({ optimizerSeed, regionWeightingMode, smoothMeshing, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, ]); useEffect(() => { @@ -280,6 +295,12 @@ export default function ThreeDControls({ }); const autoPaintProgressPercent = Math.round(Math.max(0, Math.min(1, autoPaintProgress)) * 100); + // --- Multi-head analysis (runs in Web Worker to avoid blocking the UI) --- + const { + isComputing: isMultiHeadComputing, + run: runMultiHead, + } = useMultiHeadWorker(); + const autoPaintSliceData = useMemo(() => { if (!autoPaintResult) return undefined; return autoPaintToSliceHeights(autoPaintResult, layerHeight, slicerFirstLayerHeight); @@ -344,7 +365,7 @@ export default function ThreeDControls({ const isInstructionOverLimit = instructionColorCount > 64; // --- Swap Plan --- - const { swapPlan, copied, copyToClipboard } = useSwapPlan({ + const { swapPlan, multiHeadPlan, copied, copyToClipboard } = useSwapPlan({ colorOrder: instructionColorOrder, colorSliceHeights: instructionColorSliceHeights, filtered: instructionFiltered, @@ -352,14 +373,60 @@ export default function ThreeDControls({ slicerFirstLayerHeight: instructionSlicerFirstLayerHeight, paintMode: instructionPaintMode, autoPaintResult: instructionAutoPaintResult, - disabled: isInstructionOverLimit, + multiHeadWindows: persisted?.multiHeadWindows, + patchedTransitionZones: persisted?.patchedTransitionZones, + nozzleAssignments: persisted?.nozzleAssignments, + windowRunFilaments: persisted?.windowRunFilaments, + preWindowFilaments: persisted?.preWindowFilaments, + nonWindowedRanges: persisted?.nonWindowedRanges, + filaments, flatPaint: instructionFlatPaint, }); // --- Apply handler --- - const handleApply = useCallback(() => { + const handleApply = useCallback(async () => { if (!onChange) return; + // Run the appropriate multi-head optimizer based on the selected mode. + let activeResult = null; + if (multiHeadMode && paintMode === 'autopaint' && autoPaintResult) { + // `filtered` carries SwatchEntry objects at runtime (with pixel-frequency + // `count`), even though the prop is typed as the narrower Swatch. + const swatches = filtered.map((s) => ({ + hex: s.hex, + count: (s as { count?: number }).count, + })); + activeResult = await runMultiHead({ + filaments, + autoPaintResult, + imageSwatches: swatches, + layerHeight, + firstLayerHeight: slicerFirstLayerHeight, + n: multiHeadCount, + searchDepth: multiHeadSearchDepth, + }); + } + + const newMultiHeadWindows = activeResult?.windows ?? []; + const patchedTransitionZones = activeResult && activeResult.patchedLayers.length > 0 + ? patchedLayersToPlan(activeResult.patchedLayers, filaments) + : undefined; + const patchedSliceData = activeResult && activeResult.patchedLayers.length > 0 + ? patchedLayersToSliceData(activeResult.patchedLayers, filaments, slicerFirstLayerHeight) + : undefined; + const perColorLayerColors = activeResult && activeResult.patchedLayers.length > 0 + ? buildPerColorLayerColors(activeResult.patchedLayers, activeResult.colorLayerFilaments, filaments) + : undefined; + // Per-colour filament-index-per-layer map. ThreeDView needs this (together with + // the window/nozzle data below) to resolve each sub-mesh's physical nozzle; if it + // isn't persisted, nozzle tagging silently no-ops and export3mf falls back to + // colour-order extruders and all-white filament colours. + const colorLayerFilaments = activeResult?.colorLayerFilaments; + const windowRunFilaments = activeResult?.windowRunFilaments; + const nozzleAssignments = activeResult?.nozzleAssignments; + const preWindowFilaments = activeResult?.preWindowFilaments; + const nonWindowedRanges = activeResult?.nonWindowedRanges; + if (paintMode === 'autopaint' && autoPaintSliceData && autoPaintResult) { onChange({ layerHeight, @@ -385,6 +452,18 @@ export default function ThreeDControls({ autoPaintFilamentSwatches: autoPaintSliceData.filamentSwatches, calibrationLayerHeight, smoothMeshing, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, + multiHeadWindows: newMultiHeadWindows, + patchedTransitionZones, + patchedSliceData, + perColorLayerColors, + colorLayerFilaments, + windowRunFilaments, + nozzleAssignments, + preWindowFilaments, + nonWindowedRanges, }); } else { onChange({ @@ -402,6 +481,18 @@ export default function ThreeDControls({ regionWeightingMode, calibrationLayerHeight, smoothMeshing, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, + multiHeadWindows: newMultiHeadWindows, + patchedTransitionZones, + patchedSliceData, + perColorLayerColors, + colorLayerFilaments, + windowRunFilaments, + nozzleAssignments, + preWindowFilaments, + nonWindowedRanges, }); } }, [ @@ -428,6 +519,10 @@ export default function ThreeDControls({ smoothMeshing, autoPaintResult, autoPaintSliceData, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, + runMultiHead, ]); return ( @@ -437,10 +532,10 @@ export default function ThreeDControls({