From 7c5553d10406e0db7dd68f89670d5db78adaa4e2 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:45:35 -0500 Subject: [PATCH 01/10] Add multi-head printing support Squashed feature branch: multi-head (toolchanger) nozzle assignment and filament-swap scheduling, plus supporting work. - Multi-head layer analysis (windows, nozzle assignments, color-first and spatial-variance optimization modes) and structured result plumbing - Per-nozzle mesh tagging in the 3D preview and a head-swap schedule - 3MF export for multi-extruder printers: identity filament_map (Manual mode), full per-extruder config arrays, flush matrix, filament_diameter, and custom_gcode_per_layer pauses at swap layers - Misc: ortho/perspective camera toggle, mixed-colour layer Z-fighting fix Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 703 +++++++-------- src/components/AutoPaintTab.tsx | 118 +++ src/components/PrintInstructions.tsx | 128 ++- src/components/ThreeDControls.tsx | 126 ++- src/components/ThreeDView.tsx | 736 ++++++---------- src/hooks/useAutoPaintWorker.ts | 8 + src/hooks/useSwapPlan.ts | 113 ++- src/lib/autoPaint.ts | 6 +- src/lib/calibration.ts | 2 +- src/lib/export3mf.ts | 222 ++++- src/lib/meshing.ts | 25 +- src/lib/multiHeadAnalysis.ts | 440 ++++++++++ src/lib/multiHeadAnalysisColorFirst.ts | 996 ++++++++++++++++++++++ src/lib/multiHeadSchedule.ts | 119 +++ src/lib/multiHeadSpatialVariance.ts | 357 ++++++++ src/lib/patchedLayersToPlan.ts | 155 ++++ src/types/index.ts | 60 +- src/workers/autoPaint.worker.ts | 2 + tests/multiHeadAnalysis.test.ts | 391 +++++++++ tests/multiHeadAnalysisColorFirst.test.ts | 424 +++++++++ tests/multiHeadSpatialVariance.test.ts | 452 ++++++++++ tests/patchedLayersToPlan.test.ts | 312 +++++++ tmp_implementation_plan.txt | 472 ++++++++++ 23 files changed, 5427 insertions(+), 940 deletions(-) create mode 100644 src/lib/multiHeadAnalysis.ts create mode 100644 src/lib/multiHeadAnalysisColorFirst.ts create mode 100644 src/lib/multiHeadSchedule.ts create mode 100644 src/lib/multiHeadSpatialVariance.ts create mode 100644 src/lib/patchedLayersToPlan.ts create mode 100644 tests/multiHeadAnalysis.test.ts create mode 100644 tests/multiHeadAnalysisColorFirst.test.ts create mode 100644 tests/multiHeadSpatialVariance.test.ts create mode 100644 tests/patchedLayersToPlan.test.ts create mode 100644 tmp_implementation_plan.txt diff --git a/src/App.tsx b/src/App.tsx index ba06283..754a64c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,9 +9,7 @@ import type { CanvasPreviewHandle } from './components/CanvasPreview'; import { SwatchesPanel } from './components/SwatchesPanel'; import AdjustmentsPanel from './components/AdjustmentsPanel'; import DeditherPanel from './components/DeditherPanel'; -import ImageResizePanel from './components/ImageResizePanel'; import { ADJUSTMENT_DEFAULTS } from './lib/applyAdjustments'; -import { calculateImageResizeDimensions } from './lib/imageResize'; import SLIDER_DEFS from './components/sliderDefs'; import { useSwatches } from './hooks/useSwatches'; import type { SwatchEntry } from './hooks/useSwatches'; @@ -23,6 +21,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'; @@ -32,10 +31,6 @@ import { ControlsPanel } from './components/ControlsPanel'; import { usePaletteManager } from './hooks/usePaletteManager'; import { UpdateChecker } from './components/UpdateChecker'; import ProgressOverlay from './components/ProgressOverlay'; -import DocsPage from './components/docs/DocsPage'; -import { defaultDocSlug } from './docs'; -import { buildDocsPath, parseDocsLocation } from './lib/docs/navigation'; -import { applyHomeSeo } from './lib/seo'; import { AlertDialog, AlertDialogContent, @@ -79,7 +74,6 @@ type AutoPaintPersisted = Pick< | 'allowRepeatedSwaps' | 'heightDithering' | 'ditherLineWidth' - | 'flatPaint' >; const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { @@ -106,7 +100,6 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { allowRepeatedSwaps: parsed.allowRepeatedSwaps ?? false, heightDithering: parsed.heightDithering ?? false, ditherLineWidth: parsed.ditherLineWidth, - flatPaint: parsed.flatPaint ?? false, }; } catch { return null; @@ -146,8 +139,7 @@ function App(): React.ReactElement | null { logo, undefined ); - const { swatches, swatchesLoading, imageDimensions, invalidate, immediateOverride } = - useSwatches(imageSrc); + const { swatches, swatchesLoading, imageDimensions, invalidate, immediateOverride } = useSwatches(imageSrc); // adjustments managed locally inside AdjustmentsPanel now // initial selectedPalette derived from initial weight above const inputRef = useRef(null); @@ -200,7 +192,6 @@ function App(): React.ReactElement | null { const [adjustmentsEpoch, setAdjustmentsEpoch] = useState(0); // UI mode toggles (2D / 3D) - UI only for now const [mode, setMode] = useState<'2d' | '3d'>('2d'); - const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null); const [isOrtho, setIsOrtho] = useState(false); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 @@ -215,15 +206,11 @@ function App(): React.ReactElement | null { threeDState, setThreeDState, threeDBuildSignal, - builtThreeDState, - builtFlatPaint, buildWarning, handleThreeDStateChange, confirmBuild, cancelBuild, } = useBuildWarning({ imageSrc }); - const builtModelState = builtThreeDState ?? threeDState; - const builtModelAutoPaint = builtModelState.paintMode === 'autopaint'; // Hydrate threeDState once with persisted autopaint data const [autopaintHydrated] = useState(() => { @@ -238,13 +225,11 @@ function App(): React.ReactElement | null { paintMode: autopaintHydrated.paintMode ?? prev.paintMode, optimizerAlgorithm: autopaintHydrated.optimizerAlgorithm ?? prev.optimizerAlgorithm, optimizerSeed: autopaintHydrated.optimizerSeed ?? prev.optimizerSeed, - regionWeightingMode: - autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, + regionWeightingMode: autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, enhancedColorMatch: autopaintHydrated.enhancedColorMatch ?? prev.enhancedColorMatch, allowRepeatedSwaps: autopaintHydrated.allowRepeatedSwaps ?? prev.allowRepeatedSwaps, heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering, ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth, - flatPaint: autopaintHydrated.flatPaint ?? prev.flatPaint, })); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -263,7 +248,6 @@ function App(): React.ReactElement | null { allowRepeatedSwaps: threeDState.allowRepeatedSwaps, heightDithering: threeDState.heightDithering, ditherLineWidth: threeDState.ditherLineWidth, - flatPaint: threeDState.flatPaint, }); }, [ threeDState.filaments, @@ -275,7 +259,6 @@ function App(): React.ReactElement | null { threeDState.allowRepeatedSwaps, threeDState.heightDithering, threeDState.ditherLineWidth, - threeDState.flatPaint, ]); // No auto-build on tab switch — the user must click "Build 3D Model" / "Apply Changes". @@ -289,44 +272,6 @@ function App(): React.ReactElement | null { canvasPreviewRef.current?.redraw(); }, [imageSrc]); - useEffect(() => { - const syncDocsLocation = () => { - const target = parseDocsLocation(window.location); - setDocsOpen(target !== null); - }; - window.addEventListener('hashchange', syncDocsLocation); - window.addEventListener('popstate', syncDocsLocation); - return () => { - window.removeEventListener('hashchange', syncDocsLocation); - window.removeEventListener('popstate', syncDocsLocation); - }; - }, []); - - useEffect(() => { - if (!docsOpen) { - applyHomeSeo(); - } - }, [docsOpen]); - - const backToApp = () => { - setDocsOpen(false); - if (parseDocsLocation(window.location)) { - window.history.pushState(null, '', '/'); - } - }; - - const toggleDocs = () => { - if (docsOpen) { - backToApp(); - return; - } - - setDocsOpen(true); - if (!parseDocsLocation(window.location)) { - window.history.pushState(null, '', buildDocsPath(defaultDocSlug)); - } - }; - const handleFiles = (file?: File) => { if (!file) return; if (!file.type.startsWith('image/')) { @@ -345,59 +290,6 @@ function App(): React.ReactElement | null { if (inputRef.current) inputRef.current.value = ''; }; - const resizeCurrentImage = async (percent: number) => { - if (!canvasPreviewRef.current || !imageSrc) return; - - let sourceUrl: string | null = null; - try { - const sourceBlob = await canvasPreviewRef.current.exportImageBlob(); - if (!sourceBlob) return; - - sourceUrl = URL.createObjectURL(sourceBlob); - const imageUrl = sourceUrl; - const img = await new Promise((resolve) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => resolve(null); - image.src = imageUrl; - }); - - if (!img) return; - - const target = calculateImageResizeDimensions( - img.naturalWidth, - img.naturalHeight, - percent - ); - - if (target.width >= img.naturalWidth && target.height >= img.naturalHeight) return; - - const canvas = document.createElement('canvas'); - canvas.width = target.width; - canvas.height = target.height; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - ctx.drawImage(img, 0, 0, target.width, target.height); - - const resizedBlob = await new Promise((resolve) => - canvas.toBlob((blob) => resolve(blob), 'image/png') - ); - if (!resizedBlob) return; - - const url = URL.createObjectURL(resizedBlob); - invalidate(); - setImage(url, true); - } catch (error) { - console.warn('Image resize failed', error); - alert('Image resize failed. See console for details.'); - } finally { - if (sourceUrl) URL.revokeObjectURL(sourceUrl); - } - }; - // splitter & layout management preserved below // wheel/pan handled in CanvasPreview @@ -420,12 +312,29 @@ function App(): React.ReactElement | null { exportObjectToStlBlob, exportObjectTo3MFBlob: (obj, onProgress, onZipProgress) => exportObjectTo3MFBlob(obj, { - layerHeight: builtModelState.layerHeight, - firstLayerHeight: builtModelState.slicerFirstLayerHeight, + layerHeight: threeDState.layerHeight, + firstLayerHeight: threeDState.slicerFirstLayerHeight, layerFilamentColors: - builtModelAutoPaint - ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex) + threeDState.paintMode === 'autopaint' + ? (threeDState.patchedSliceData?.swatches + ?? threeDState.autoPaintFilamentSwatches)?.map((s) => s.hex) : undefined, + extruderCount: threeDState.multiHeadMode ? threeDState.multiHeadCount : undefined, + // Head Schedule swap checkpoints → pause markers at those layers. + swapLayers: threeDState.multiHeadMode + ? buildMultiHeadSchedule({ + multiHeadWindows: threeDState.multiHeadWindows, + nozzleAssignments: threeDState.nozzleAssignments, + windowRunFilaments: threeDState.windowRunFilaments, + nonWindowedRanges: threeDState.nonWindowedRanges, + filaments: threeDState.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, }), @@ -438,306 +347,306 @@ function App(): React.ReactElement | null { return (
{ invalidate(); setImage(tdTestImg, true); - setMode('2d'); - if (parseDocsLocation(window.location)) { - window.history.pushState(null, '', '/'); - } - setDocsOpen(false); }} /> - {docsOpen && ( -
- -
- )} -
- - +
+
+ {mode === '2d' ? ( + <> + + {processingActive && ( + + )} + + ) : ( + <> + f.id)} + autoPaintEnabled={threeDState.paintMode === 'autopaint'} + autoPaintTotalHeight={ + (threeDState.multiHeadMode && + threeDState.multiHeadOptimizationMode === 'spatial-variance' && + threeDState.spatialVarianceTotalHeight) + ? threeDState.spatialVarianceTotalHeight + : threeDState.autoPaintResult?.totalHeight + } + autoPaintFilamentOrder={ + threeDState.autoPaintResult?.filamentOrder + } + enhancedColorMatch={threeDState.enhancedColorMatch} + heightDithering={threeDState.heightDithering} + ditherLineWidth={threeDState.ditherLineWidth} + smoothMeshing={threeDState.smoothMeshing} + isOrtho={isOrtho} + /> + {exportingSTL && ( + + )} + + )} + imageSrc && setIsCropMode(true)} + onSaveCrop={async () => { + if (!canvasPreviewRef.current) return; + const blob = + await canvasPreviewRef.current.exportCroppedImage(); + if (!blob) return; + const url = URL.createObjectURL(blob); + invalidate(); + setImage(url, true); + setIsCropMode(false); + }} + onCancelCrop={() => setIsCropMode(false)} + onToggleCheckerboard={() => setShowCheckerboard((s) => !s)} + onPickFile={() => inputRef.current?.click()} + onClear={clear} + onExportImage={onExportImage} + onExportStl={onExportStl} + onExport3MF={onExport3MF} + isOrtho={isOrtho} + onToggleCamera={() => setIsOrtho((v) => !v)} + /> +
+
+
{/* Build warning dialog */} diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index ed7350d..30dbe8a 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -103,6 +103,16 @@ 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; + multiHeadOptimizationMode: 'color-accuracy' | 'spatial-variance'; + setMultiHeadOptimizationMode: (v: 'color-accuracy' | 'spatial-variance') => void; } export default function AutoPaintTab({ @@ -154,6 +164,14 @@ export default function AutoPaintTab({ setOptimizerSeed, regionWeightingMode, setRegionWeightingMode, + multiHeadMode, + setMultiHeadMode, + multiHeadCount, + setMultiHeadCount, + multiHeadSearchDepth, + setMultiHeadSearchDepth, + multiHeadOptimizationMode, + setMultiHeadOptimizationMode, }: AutoPaintTabProps) { const [localDitherLineWidth, setLocalDitherLineWidth] = React.useState( ditherLineWidth.toString() @@ -754,6 +772,106 @@ 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 && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ )} + {/* Auto-paint transition zones preview */} {autoPaintResult && autoPaintResult.transitionZones.length > 0 && ( <> diff --git a/src/components/PrintInstructions.tsx b/src/components/PrintInstructions.tsx index 477ea7b..26fd357 100644 --- a/src/components/PrintInstructions.tsx +++ b/src/components/PrintInstructions.tsx @@ -1,8 +1,10 @@ import { Card } from '@/components/ui/card'; -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, @@ -55,23 +59,15 @@ export default function PrintInstructions({
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 +96,23 @@ export default function PrintInstructions({
  • After printing, flip the piece over to view the image.
  • + ) : tooManyColors ? ( +
    + Swap instructions are disabled for very large palettes ({colorCount} colors). + Reduce the image to 256 colors or fewer in 2D mode first. +
    + ) : 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 +129,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 +192,68 @@ 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.filamentHex} +
    + ))} +
    +
    + ); + })} +
    +
    + ); +} diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index ad4debc..cbb5789 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -6,6 +6,10 @@ 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 { runMultiHeadLayerAnalysisColorFirst } from '../lib/multiHeadAnalysisColorFirst'; +import { runMultiHeadSpatialVarianceOptimization, type SpatialVarianceResult } from '../lib/multiHeadSpatialVariance'; +import { patchedLayersToPlan, patchedLayersToSliceData, buildPerColorLayerColors } from '../lib/patchedLayersToPlan'; +import type { WindowResult } from '../lib/multiHeadAnalysis'; import { loadPrintSettingsFromStorage, savePrintSettingsToStorage, @@ -127,6 +131,17 @@ 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 [multiHeadOptimizationMode, setMultiHeadOptimizationMode] = useState<'color-accuracy' | 'spatial-variance'>( + persisted?.multiHeadOptimizationMode ?? 'color-accuracy' + ); + const [multiHeadWindows, setMultiHeadWindows] = useState([]); + useEffect(() => { if (optimizerAlgorithm === 'exhaustive' && filaments.length > 8) { setOptimizerAlgorithm('auto'); @@ -169,9 +184,13 @@ export default function ThreeDControls({ optimizerSeed, regionWeightingMode, smoothMeshing, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, + multiHeadOptimizationMode, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); + }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing, multiHeadMode, multiHeadCount, multiHeadSearchDepth, multiHeadOptimizationMode]); useEffect(() => { savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); @@ -223,8 +242,15 @@ export default function ThreeDControls({ optimizerSeed, regionWeightingMode, imageDimensions, + multiHeadMode, + multiHeadCount, }); + // Reset multi-head windows whenever a new autopaint result arrives + useEffect(() => { + setMultiHeadWindows([]); + }, [autoPaintResult]); + const autoPaintSliceData = useMemo(() => { if (!autoPaintResult) return undefined; return autoPaintToSliceHeights(autoPaintResult, layerHeight, slicerFirstLayerHeight); @@ -289,7 +315,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, @@ -297,6 +323,13 @@ export default function ThreeDControls({ slicerFirstLayerHeight: instructionSlicerFirstLayerHeight, paintMode: instructionPaintMode, autoPaintResult: instructionAutoPaintResult, + multiHeadWindows, + patchedTransitionZones: persisted?.patchedTransitionZones, + nozzleAssignments: persisted?.nozzleAssignments, + windowRunFilaments: persisted?.windowRunFilaments, + preWindowFilaments: persisted?.preWindowFilaments, + nonWindowedRanges: persisted?.nonWindowedRanges, + filaments, disabled: isInstructionOverLimit, flatPaint: instructionFlatPaint, }); @@ -305,6 +338,48 @@ export default function ThreeDControls({ const handleApply = useCallback(() => { if (!onChange) return; + // Run the appropriate multi-head optimizer based on the selected mode. + const activeResult = (() => { + if (!multiHeadMode || paintMode !== 'autopaint' || !autoPaintResult) return null; + const swatches = filtered.map((s) => ({ hex: s.hex, count: s.count })); + if (multiHeadOptimizationMode === 'spatial-variance') { + return runMultiHeadSpatialVarianceOptimization( + filaments, autoPaintResult, swatches, + layerHeight, slicerFirstLayerHeight, multiHeadCount + ); + } + return runMultiHeadLayerAnalysisColorFirst( + filaments, autoPaintResult, swatches, + layerHeight, slicerFirstLayerHeight, multiHeadCount + ); + })(); + + const svResult = (multiHeadOptimizationMode === 'spatial-variance' + ? (activeResult as SpatialVarianceResult | null) + : null); + const spatialVarianceTotalHeight = svResult?.spatialVarianceTotalHeight; + + 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; + setMultiHeadWindows(newMultiHeadWindows); + if (paintMode === 'autopaint' && autoPaintSliceData && autoPaintResult) { onChange({ layerHeight, @@ -328,6 +403,20 @@ export default function ThreeDControls({ autoPaintFilamentSwatches: autoPaintSliceData.filamentSwatches, calibrationLayerHeight, smoothMeshing, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, + multiHeadOptimizationMode, + spatialVarianceTotalHeight, + multiHeadWindows: newMultiHeadWindows, + patchedTransitionZones, + patchedSliceData, + perColorLayerColors, + colorLayerFilaments, + windowRunFilaments, + nozzleAssignments, + preWindowFilaments, + nonWindowedRanges, }); } else { onChange({ @@ -345,6 +434,20 @@ export default function ThreeDControls({ regionWeightingMode, calibrationLayerHeight, smoothMeshing, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, + multiHeadOptimizationMode, + spatialVarianceTotalHeight, + multiHeadWindows: newMultiHeadWindows, + patchedTransitionZones, + patchedSliceData, + perColorLayerColors, + colorLayerFilaments, + windowRunFilaments, + nozzleAssignments, + preWindowFilaments, + nonWindowedRanges, }); } }, [ @@ -369,6 +472,12 @@ export default function ThreeDControls({ smoothMeshing, autoPaintResult, autoPaintSliceData, + multiHeadMode, + multiHeadCount, + multiHeadSearchDepth, + multiHeadOptimizationMode, + filtered, + // spatialVarianceTotalHeight is derived inside handleApply; not a dep ]); return ( @@ -479,6 +588,14 @@ export default function ThreeDControls({ setOptimizerSeed={setOptimizerSeed} regionWeightingMode={regionWeightingMode} setRegionWeightingMode={setRegionWeightingMode} + multiHeadMode={multiHeadMode} + setMultiHeadMode={setMultiHeadMode} + multiHeadCount={multiHeadCount} + setMultiHeadCount={setMultiHeadCount} + multiHeadSearchDepth={multiHeadSearchDepth} + setMultiHeadSearchDepth={setMultiHeadSearchDepth} + multiHeadOptimizationMode={multiHeadOptimizationMode} + setMultiHeadOptimizationMode={setMultiHeadOptimizationMode} /> {/* Manual Tab */} @@ -516,11 +633,11 @@ export default function ThreeDControls({ orientation="vertical">
    - {displayOrder.length > 64 ? ( + {displayOrder.length > 256 ? (

    Too many colors ({displayOrder.length})

    - The image has more than 64 unique colors. Please reduce + The image has more than 256 unique colors. Please reduce the image to fewer colors in 2D mode using the quantization tools before switching to 3D mode.

    @@ -559,6 +676,7 @@ export default function ThreeDControls({ {/* Print Instructions */} blended colour per layer. + // When present (auto-paint mode), each layer band is split per pixel colour so + // different pixels at the same height can show different filament colours. + perColorLayerColors?: Map; + // Multi-head nozzle assignment props — used to tag each sub-mesh with the physical + // nozzle that prints it so export3mf can set the correct extruder attribute. + colorLayerFilaments?: Map; + nozzleAssignments?: number[][]; + windowRunFilaments?: string[][]; + multiHeadWindows?: WindowResult[]; + nonWindowedRanges?: MultiHeadRangeAssignment[]; + filamentIds?: string[]; isOrtho?: boolean; - flatPaint?: boolean; // Build a flat face-down slab (Flat Paint style, auto-paint only) } // Convert hex color to RGB tuple @@ -61,6 +65,56 @@ function getLuminance(r: number, g: number, b: number): number { return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; } +/** Map a filament index + layer index to a 1-based nozzle number using the DP result. */ +function resolveNozzleIndex( + filamentIdx: number, + layerIdx: number, + filamentIds: string[], + windows: WindowResult[], + windowRunFilaments: string[][], + nozzleAssignments: number[][], + debugMisses?: Map>, + nonWindowedRanges?: MultiHeadRangeAssignment[] +): number { + if (filamentIdx < 0 || filamentIdx >= filamentIds.length) return 1; + const fid = filamentIds[filamentIdx]; + + // Fast path: layer is inside a real window — look up directly. + for (let w = 0; w < windows.length; w++) { + const win = windows[w]; + if (layerIdx < win.windowStart || layerIdx > win.windowEnd) continue; + const runs = windowRunFilaments[w] ?? []; + const assgn = nozzleAssignments[w] ?? []; + for (let k = 0; k < assgn.length; k++) { + if (assgn[k] !== -1 && runs[assgn[k]] === fid) return k + 1; + } + break; // layer is in this window but filament not assigned — fall through + } + + // Check non-windowed ranges (pre-window, gaps, post-window). + // nozzleFilaments[k] is the realized filament on head k+1 for this range. + if (nonWindowedRanges) { + for (const range of nonWindowedRanges) { + if (layerIdx < range.rangeStart || layerIdx > range.rangeEnd) continue; + const idx = range.nozzleFilaments.indexOf(fid); + if (idx >= 0) return idx + 1; + // Filament not in this range's head state — genuine miss. + if (debugMisses) { + if (!debugMisses.has(layerIdx)) debugMisses.set(layerIdx, new Set()); + debugMisses.get(layerIdx)!.add(fid); + } + return 1; + } + } + + // Layer not covered by any window or range — fall back. + if (debugMisses) { + if (!debugMisses.has(layerIdx)) debugMisses.set(layerIdx, new Set()); + debugMisses.get(layerIdx)!.add(fid); + } + return 1; +} + // Nearest-color match with small cache to avoid exact equality issues function buildNearestSwatchFinder(swatches: { hex: string; a: number }[]) { const rgb = swatches.map((s) => hexToRGB(s.hex)); @@ -94,7 +148,6 @@ interface KromacutExportLayerData { height: number; pixelSize: number; topZ: number; - compactHeightfield?: boolean; } interface LayerPreviewSegment { @@ -137,7 +190,10 @@ function sliderSpanPercentCss(startPercent: number, endPercent: number) { } function normalizeHexColor(hex: string | undefined) { - return normalizeHexColorValue(hex, '#3b82f6'); + const fallback = '#3b82f6'; + if (!hex) return fallback; + const value = hex.startsWith('#') ? hex : `#${hex}`; + return /^#[0-9a-f]{6}$/i.test(value) ? value.toUpperCase() : fallback; } function layerNumberForTransition( @@ -202,33 +258,6 @@ function createFlatShadedGeometry( return geom; } -function remapMeshZRange(mesh: MeshData, baseZ: number, topZ: number, heightScale: number): MeshData { - const positions = new Float32Array(mesh.positions.length); - let minZ = Infinity; - let maxZ = -Infinity; - - for (let i = 2; i < mesh.positions.length; i += 3) { - minZ = Math.min(minZ, mesh.positions[i]); - maxZ = Math.max(maxZ, mesh.positions[i]); - } - - const sourceSpan = maxZ - minZ || 1; - const targetBase = baseZ * heightScale; - const targetSpan = (topZ - baseZ) * heightScale; - - for (let i = 0; i < mesh.positions.length; i += 3) { - positions[i] = mesh.positions[i]; - positions[i + 1] = mesh.positions[i + 1]; - positions[i + 2] = targetBase + ((mesh.positions[i + 2] - minZ) / sourceSpan) * targetSpan; - } - - return { - positions, - indices: mesh.indices, - metrics: mesh.metrics, - }; -} - interface E2EBuildMetrics { status: 'building' | 'complete'; startedAt?: number; @@ -242,7 +271,6 @@ interface E2EBuildMetrics { visibleMeshCount?: number; vertexCount?: number; triangleCount?: number; - layerMetrics?: E2ELayerBuildMetrics[]; dimensions?: { width: number; height: number; @@ -256,21 +284,9 @@ interface E2EBuildMetrics { autoPaintEnabled: boolean; enhancedColorMatch: boolean; heightDithering: boolean; - flatPaint?: boolean; }; } -type BuildOverlayStep = ReturnType; - -interface E2ELayerBuildMetrics { - layerIndex: number; - swatchIndex: number; - activePixelCount: number; - vertexCount: number; - triangleCount: number; - metrics?: MeshMetrics; -} - declare global { interface Window { __KROMACUT_E2E?: { @@ -292,12 +308,6 @@ function updateE2EBuild(metrics: E2EBuildMetrics) { } } -function clearLastMeshRef() { - if (typeof window === 'undefined') return; - (window as unknown as { __KROMACUT_LAST_MESH?: THREE.Object3D }).__KROMACUT_LAST_MESH = - undefined; -} - function collectMeshStats(root: THREE.Object3D) { let meshCount = 0; let visibleMeshCount = 0; @@ -341,14 +351,18 @@ export default function ThreeDView({ heightDithering = false, ditherLineWidth = 0.42, smoothMeshing = false, + perColorLayerColors, + colorLayerFilaments, + nozzleAssignments, + windowRunFilaments, + multiHeadWindows, + nonWindowedRanges, + filamentIds, isOrtho = false, - flatPaint = false, }: ThreeDViewProps) { const mountRef = useRef(null); const [isBuilding, setIsBuilding] = useState(false); - const [activeBuildSmoothMeshing, setActiveBuildSmoothMeshing] = useState(smoothMeshing); const [buildProgress, setBuildProgress] = useState(0); - const [buildOverlayStep, setBuildOverlayStep] = useState(null); const [modelDimensions, setModelDimensions] = useState<{ width: number; height: number; @@ -372,7 +386,6 @@ export default function ThreeDView({ const progressRef = useRef(0); const progressLastUpdateRef = useRef(0); - const buildOverlayLastUpdateRef = useRef(0); const pushProgress = (value: number) => { const nextValue = clampProgress(value); progressRef.current = nextValue; @@ -382,18 +395,6 @@ export default function ThreeDView({ setBuildProgress(nextValue); } }; - const pushBuildOverlayStep = (value: BuildOverlayStep) => { - const now = performance.now(); - const stepProgress = clampProgress(value.stepProgress ?? 0); - - if (stepProgress <= 0 || stepProgress >= 1 || now - buildOverlayLastUpdateRef.current > 60) { - buildOverlayLastUpdateRef.current = now; - setBuildOverlayStep({ - ...value, - stepProgress, - }); - } - }; useEffect(() => { if (controlsRef.current) { @@ -441,9 +442,6 @@ export default function ThreeDView({ const layerPreviewSegments = useMemo(() => { if (maxModelHeight <= 0 || colorOrder.length === 0) return []; - // Flat Paint: printed layers contain several filaments side by side, so a - // single global swap sequence does not exist — show a plain track. - if (flatPaint) return []; const segments: LayerPreviewSegment[] = []; let running = 0; @@ -493,7 +491,6 @@ export default function ThreeDView({ filamentSwatches, swatches, layerHeight, - flatPaint, ]); const updateHoveredSegment = ( @@ -519,84 +516,30 @@ export default function ThreeDView({ setPreviewHeight(Math.max(low, high)); }; - // 2. Rebuild mesh geometry only when the parent sends an explicit build signal. + // 2. Rebuild mesh geometry whenever inputs change (debounced, progressive, adaptive resolution) const buildTokenRef = useRef(0); const debounceTimerRef = useRef(null); const lastParamsKeyRef = useRef(null); const lastRebuildRef = useRef(rebuildSignal); - const lastImageSrcRef = useRef(imageSrc); - - useEffect(() => { - return () => { - if (debounceTimerRef.current !== null) { - window.clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - }; - }, []); useEffect(() => { const modelGroup = modelGroupRef.current; - if (!modelGroup) return; + if (!modelGroup || !imageSrc) return; - const imageChanged = imageSrc !== lastImageSrcRef.current; - lastImageSrcRef.current = imageSrc; - - if (!imageSrc) { - buildTokenRef.current++; - if (debounceTimerRef.current !== null) { - window.clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - modelGroup.clear(); - clearLastMeshRef(); - setIsBuilding(false); - setModelDimensions(null); - setMaxModelHeight(0); - setPreviewMinHeight(0); - setPreviewHeight(null); - requestRender(); - return; - } - - if (imageChanged) { - buildTokenRef.current++; + // If parent requested a rebuild via the rebuildSignal, clear the last params key + // to force the effect to proceed even if params otherwise match. + if (rebuildSignal !== lastRebuildRef.current) { lastParamsKeyRef.current = null; - if (debounceTimerRef.current !== null) { - window.clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - modelGroup.clear(); - clearLastMeshRef(); - setIsBuilding(false); - setModelDimensions(null); - setMaxModelHeight(0); - setPreviewMinHeight(0); - setPreviewHeight(null); - requestRender(); + lastRebuildRef.current = rebuildSignal; } - const rebuildRequested = rebuildSignal !== lastRebuildRef.current; - if (!rebuildRequested) return; - - lastParamsKeyRef.current = null; - lastRebuildRef.current = rebuildSignal; - // Don't build if there are no layers configured if (!colorOrder || colorOrder.length === 0 || !swatches || swatches.length === 0) { - buildTokenRef.current++; - if (debounceTimerRef.current !== null) { - window.clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } modelGroup.clear(); - clearLastMeshRef(); setIsBuilding(false); return; } - const buildSmoothMeshing = smoothMeshing && !flatPaint; - // Stable key of inputs to avoid duplicate builds when references unchanged const paramsKey = JSON.stringify({ imageSrc, @@ -606,8 +549,6 @@ export default function ThreeDView({ colorSliceHeights, colorOrder, swatches: swatches.map((s) => s.hex), - // Filament colors shape Flat Paint geometry (zone merging + export groups) - filamentSwatches: filamentSwatches?.map((s) => s.hex), pixelSize, heightScale, stepped, @@ -619,22 +560,23 @@ export default function ThreeDView({ heightDithering, ditherLineWidth, smoothMeshing, - flatPaint, + // Signature for the per-pixel colour map (keys + first colour array) + // so a new multi-head result forces a rebuild. + perColorLayerColors: perColorLayerColors + ? `${perColorLayerColors.size}:${[...perColorLayerColors.values()][0]?.join('') ?? ''}` + : null, }); if (paramsKey === lastParamsKeyRef.current) return; // nothing changed logically lastParamsKeyRef.current = paramsKey; // Debounce rapid changes (e.g., dragging slider) - if (debounceTimerRef.current !== null) window.clearTimeout(debounceTimerRef.current); - const token = ++buildTokenRef.current; - setActiveBuildSmoothMeshing(buildSmoothMeshing); + if (debounceTimerRef.current) window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = window.setTimeout(() => { - debounceTimerRef.current = null; + const token = ++buildTokenRef.current; const buildStartedAt = performance.now(); // mark that a build is in progress for the overlay setIsBuilding(true); pushProgress(0); - setBuildOverlayStep(null); updateE2EBuild({ status: 'building', startedAt: buildStartedAt, @@ -642,11 +584,10 @@ export default function ThreeDView({ pixelSize, layerHeight, slicerFirstLayerHeight, - smoothMeshing: buildSmoothMeshing, + smoothMeshing, autoPaintEnabled, enhancedColorMatch, heightDithering, - flatPaint, }, }); @@ -691,47 +632,9 @@ export default function ThreeDView({ // Clear current model modelGroup.clear(); - clearLastMeshRef(); const YIELD_MS = 12; let lastYield = performance.now(); - const meshBuildMetrics: E2ELayerBuildMetrics[] = []; - const buildStepCount = Math.max(1, colorOrder.length + 1); - const pushScanDetail = (label: string, progress: number) => { - pushBuildOverlayStep({ - stepLabel: label, - stepIndex: 1, - stepCount: buildStepCount, - stepProgress: progress, - }); - }; - const pushLayerDetail = ( - buildLayerIndex: number, - label: string, - progress: number - ) => { - const stepProgress = clampProgress(progress); - pushBuildOverlayStep({ - stepLabel: `Layer ${buildLayerIndex + 1} of ${colorOrder.length}: ${label}`, - stepIndex: Math.min(buildStepCount, buildLayerIndex + 2), - stepCount: buildStepCount, - stepProgress, - }); - pushProgress( - progressInSpan( - (buildLayerIndex + 1) / buildStepCount, - 1 / buildStepCount, - stepProgress - ) - ); - }; - const meshProgressReporter = (buildLayerIndex: number) => (progress: MeshProgress) => { - pushLayerDetail( - buildLayerIndex, - progress.label, - progressInSpan(0.35, 0.55, progress.progress) - ); - }; if (autoPaintEnabled && autoPaintTotalHeight && autoPaintTotalHeight > 0) { // === AUTO-PAINT MODE === @@ -992,10 +895,6 @@ export default function ThreeDView({ pixelHeightMap[mapIdx] = targetHeight; colorHeightCache.set(cacheKey, targetHeight); } - pushScanDetail( - 'Mapping image colors to printable heights', - (py - minY + 1) / boxH - ); pushProgress( layeredBuildScanProgress(py - minY, boxH, colorOrder.length) ); @@ -1228,167 +1127,103 @@ export default function ThreeDView({ pixelHeightMap[mapIdx] = pixelHeight; } - pushScanDetail( - 'Mapping image luminance to printable heights', - (py - minY + 1) / boxH - ); pushProgress( layeredBuildScanProgress(py - minY, boxH, colorOrder.length) ); } } - if (flatPaint) { - // === FLAT_PAINT: uniform face-down slab === - // Reverse each pixel column so the visible blend layer - // touches the plate (mirrored in X so the artwork reads - // correctly once the finished print is flipped over), - // backfill behind the columns with the foundation - // filament, and add a transparent carrier first layer. - const orientedCounts = new Uint16Array(boxW * boxH); - { - const rawCounts = heightMapToFlatPaintLayerCounts( - pixelHeightMap, - cumulativeHeights, - layerHeight - ); - for (let y = 0; y < boxH; y++) { - const srcRow = y * boxW; - const dstRow = (boxH - 1 - y) * boxW; - for (let x = 0; x < boxW; x++) { - orientedCounts[dstRow + (boxW - 1 - x)] = - rawCounts[srcRow + x]; + // Multi-head per-pixel colour: classify each pixel to its nearest + // image-palette colour once, so layer bands can be split by the + // per-colour blended colour at that layer. + let pixelColorSeq: (string[] | null)[] | null = null; + // pixelPaletteIdx stores the palette index for each pixel so the + // group-building loop can resolve filament→nozzle assignments. + let pixelPaletteIdx: Int16Array | null = null; + let perColorPaletteHexes: string[] | null = null; + if (perColorLayerColors && perColorLayerColors.size > 0) { + const paletteHexes = [...perColorLayerColors.keys()]; + perColorPaletteHexes = paletteHexes; + const paletteRGB = paletteHexes.map(hexToRGB); + const paletteSeqs = paletteHexes.map((h) => perColorLayerColors.get(h)!); + pixelColorSeq = new Array(boxW * boxH).fill(null); + pixelPaletteIdx = new Int16Array(boxW * boxH).fill(-1); + for (let y = 0; y < boxH; y++) { + for (let x = 0; x < boxW; x++) { + const idx = ((minY + y) * fullW + (minX + x)) * 4; + if (data[idx + 3] === 0) continue; + let best = 0; + let bestD = Infinity; + for (let p = 0; p < paletteRGB.length; p++) { + const dr = data[idx] - paletteRGB[p][0]; + const dg = data[idx + 1] - paletteRGB[p][1]; + const db = data[idx + 2] - paletteRGB[p][2]; + const d = dr * dr + dg * dg + db * db; + if (d < bestD) { bestD = d; best = p; } } + pixelColorSeq[(boxH - 1 - y) * boxW + x] = paletteSeqs[best]; + pixelPaletteIdx[(boxH - 1 - y) * boxW + x] = best; } } + } - const layout = buildFlatPaintLayout({ - layerCounts: orientedCounts, - width: boxW, - height: boxH, - layerCount: colorOrder.length, - layerHeight, - carrierThickness: Math.max(slicerFirstLayerHeight, layerHeight), - layerVirtualHexes: colorOrder.map( - (swatchIdx) => swatches[swatchIdx]?.hex ?? '#888888' - ), - layerFilamentHexes: colorOrder.map( - (swatchIdx) => - (filamentSwatches?.[swatchIdx] ?? swatches[swatchIdx])?.hex ?? - '#888888' - ), - }); + // Build each layer once; smooth meshing does not run overhang repair passes. + const layerBuildOrder = Array.from( + { length: colorOrder.length }, + (_, layerIndex) => layerIndex + ); + const builtLayerMeshes: THREE.Mesh[] = []; + // Tracks layers outside all windows where resolveNozzleIndex falls back + // to nozzle 1. key = layerIdx, value = set of filament IDs that couldn't + // be resolved to a specific head. + const nozzleResolveMisses = new Map>(); - const partCount = Math.max(1, layout.parts.length); - const scanSpanEnd = 1 / (colorOrder.length + 1); - const pushPartDetail = ( - partIndex: number, - label: string, - progress: number - ) => { - const stepProgress = clampProgress(progress); - pushBuildOverlayStep({ - stepLabel: `Flat Paint part ${partIndex + 1} of ${partCount}: ${label}`, - stepIndex: Math.min(partCount + 1, partIndex + 2), - stepCount: partCount + 1, - stepProgress, - }); - pushProgress( - progressInSpan( - scanSpanEnd, - 1 - scanSpanEnd, - (partIndex + stepProgress) / partCount - ) - ); - }; + for ( + let buildLayerIndex = 0; + buildLayerIndex < layerBuildOrder.length; + buildLayerIndex++ + ) { + const i = layerBuildOrder[buildLayerIndex]; + if (token !== buildTokenRef.current) return; - const flatMeshCache = new WeakMap>(); - const partIdxForProgress = (part: (typeof layout.parts)[number]) => - layout.parts.indexOf(part); - const getFlatMaskMesh = (part: (typeof layout.parts)[number]) => { - const cached = flatMeshCache.get(part.mask); - if (cached) return cached; - - const promise = generateGreedyMesh( - part.mask, - boxW, - boxH, - 1, - 0, - pixelSize, - 1, - { - yieldIntervalMs: 8, - onProgress: (progress: MeshProgress) => { - pushPartDetail( - partIdxForProgress(part), - progress.label, - progressInSpan(0, 0.9, progress.progress) - ); - }, - } - ); - flatMeshCache.set(part.mask, promise); - return promise; - }; + const swatchIdx = colorOrder[i]; + if (!swatches[swatchIdx]) continue; + const colorHex = swatches[swatchIdx].hex; + const thickness = + i === 0 + ? Math.max( + colorSliceHeights[swatchIdx] || 0, + slicerFirstLayerHeight + ) + : colorSliceHeights[swatchIdx] || 0; + if (thickness <= 0.0001) continue; - for (let partIdx = 0; partIdx < layout.parts.length; partIdx++) { - const part = layout.parts[partIdx]; - if (token !== buildTokenRef.current) return; - if (part.activeCount === 0) continue; - - // Flat Paint always uses the greedy mesher: smoothed - // boundaries would open gaps between side-by-side - // color regions inside the slab. - const generatedMesh = remapMeshZRange( - await getFlatMaskMesh(part), - part.baseZ, - part.topZ, - heightScale - ); - meshBuildMetrics.push({ - layerIndex: partIdx, - swatchIndex: part.classIndex, - activePixelCount: part.activeCount, - vertexCount: generatedMesh.positions.length / 3, - triangleCount: generatedMesh.indices.length / 3, - metrics: generatedMesh.metrics, - }); + const topZ = i === 0 ? cumulativeHeights[0] : cumulativeHeights[i]; + const baseZ = i === 0 ? 0 : cumulativeHeights[i - 1]; - const geom = createFlatShadedGeometry( - generatedMesh.positions, - generatedMesh.indices, - { - activePixels: part.mask, - width: boxW, - height: boxH, - pixelSize, - topZ: part.topZ * heightScale, - compactHeightfield: true, + // Identify active pixels for this layer using precomputed height map + const activePixels = new Uint8Array(boxW * boxH); + let activeCount = 0; + + for (let y = 0; y < boxH; y++) { + for (let x = 0; x < boxW; x++) { + const mapIdx = y * boxW + x; + const pixelHeight = pixelHeightMap[mapIdx]; + + if (pixelHeight > 0 && pixelHeight >= topZ - 0.001) { + activePixels[(boxH - 1 - y) * boxW + x] = 1; + activeCount++; } - ); - const isCarrier = part.kind === 'carrier'; - const mat = new THREE.MeshStandardMaterial({ - color: part.previewHex, - side: THREE.FrontSide, - metalness: 0, - roughness: isCarrier ? 0.3 : 0.7, - flatShading: true, - transparent: isCarrier, - opacity: isCarrier ? 0.3 : 1, - }); + } - const mesh = new THREE.Mesh(geom, mat); - // Store slab Z range for the preview slider - mesh.userData.baseZ = part.baseZ; - mesh.userData.topZ = part.topZ; - // Export metadata: one 3MF object per physical filament - mesh.userData.kromacutExportGroup = part.exportGroup; - mesh.userData.kromacutFilamentHex = part.filamentHex; - mesh.userData.kromacutMaterialKey = part.exportGroup; - mesh.userData.kromacutPartName = part.partName; - modelGroup.add(mesh); - pushPartDetail(partIdx, 'Part mesh complete', 1); + pushProgress( + layeredBuildLayerProgress( + buildLayerIndex, + y, + boxH, + colorOrder.length + ) + ); if (performance.now() - lastYield > YIELD_MS) { await new Promise((r) => requestAnimationFrame(r)); @@ -1396,103 +1231,76 @@ export default function ThreeDView({ lastYield = performance.now(); } } - } else { - // Build each layer once; smooth meshing does not run overhang repair passes. - const layerBuildOrder = Array.from( - { length: colorOrder.length }, - (_, layerIndex) => layerIndex - ); - const builtLayerMeshes: Array = new Array( - colorOrder.length - ); - - for ( - let buildLayerIndex = 0; - buildLayerIndex < layerBuildOrder.length; - buildLayerIndex++ - ) { - const i = layerBuildOrder[buildLayerIndex]; - if (token !== buildTokenRef.current) return; - - const swatchIdx = colorOrder[i]; - if (!swatches[swatchIdx]) continue; - const colorHex = swatches[swatchIdx].hex; - const thickness = - i === 0 - ? Math.max( - colorSliceHeights[swatchIdx] || 0, - slicerFirstLayerHeight - ) - : colorSliceHeights[swatchIdx] || 0; - if (thickness <= 0.0001) continue; - - const topZ = i === 0 ? cumulativeHeights[0] : cumulativeHeights[i]; - const baseZ = i === 0 ? 0 : cumulativeHeights[i - 1]; - - // Identify active pixels for this layer using precomputed height map - const activePixels = new Uint8Array(boxW * boxH); - let activeCount = 0; - for (let y = 0; y < boxH; y++) { - for (let x = 0; x < boxW; x++) { - const mapIdx = y * boxW + x; - const pixelHeight = pixelHeightMap[mapIdx]; + if (activeCount === 0) continue; - if ( - pixelHeight > 0 && - pixelHeight >= topZ - LAYER_ACTIVATION_EPSILON - ) { - activePixels[(boxH - 1 - y) * boxW + x] = 1; - activeCount++; + // Partition this layer's active pixels by the colour each + // pixel shows at layer i. In multi-head per-pixel mode that + // splits the band into several colour groups; otherwise it is + // a single group with the band's blended colour. + const groups = new Map(); + // Parallel map: groupHex -> nozzle index (1-based). Populated + // from the first pixel of each group using the DP result. + const groupNozzle = new Map(); + const canResolveNozzle = + pixelPaletteIdx != null && + perColorPaletteHexes != null && + colorLayerFilaments != null && + multiHeadWindows?.length && + windowRunFilaments?.length && + nozzleAssignments?.length && + filamentIds?.length; + if (pixelColorSeq) { + for (let mi = 0; mi < activePixels.length; mi++) { + if (!activePixels[mi]) continue; + const seq = pixelColorSeq[mi]; + const hex = (seq && seq[i]) || colorHex; + let mask = groups.get(hex); + if (!mask) { + mask = new Uint8Array(boxW * boxH); + groups.set(hex, mask); + // Resolve nozzle from the first pixel of this group. + if (canResolveNozzle) { + const palIdx = pixelPaletteIdx![mi]; + const palHex = palIdx >= 0 ? perColorPaletteHexes![palIdx] : null; + const filIdx = palHex != null + ? (colorLayerFilaments!.get(palHex)?.[i] ?? -1) + : -1; + groupNozzle.set(hex, resolveNozzleIndex( + filIdx, i, filamentIds!, + multiHeadWindows!, windowRunFilaments!, nozzleAssignments!, + nozzleResolveMisses, nonWindowedRanges + )); } } - - pushLayerDetail( - buildLayerIndex, - 'Selecting active pixels', - progressInSpan(0, 0.35, (y + 1) / boxH) - ); - - if (performance.now() - lastYield > YIELD_MS) { - await new Promise((r) => requestAnimationFrame(r)); - if (token !== buildTokenRef.current) return; - lastYield = performance.now(); - } + mask[mi] = 1; } + } else { + groups.set(colorHex, activePixels); + } - if (activeCount === 0) continue; - - // Generate mesh for this layer + for (const [groupHex, groupMask] of groups) { const generatedMesh = await ( - buildSmoothMeshing ? generateSmoothMesh : generateGreedyMesh - )(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, { + smoothMeshing ? generateSmoothMesh : generateGreedyMesh + )(groupMask, boxW, boxH, thickness, baseZ, pixelSize, heightScale, { yieldIntervalMs: 8, - onProgress: meshProgressReporter(buildLayerIndex), - }); - meshBuildMetrics.push({ - layerIndex: i, - swatchIndex: swatchIdx, - activePixelCount: activeCount, - vertexCount: generatedMesh.positions.length / 3, - triangleCount: generatedMesh.indices.length / 3, - metrics: generatedMesh.metrics, + skipBottomCap: i > 0, + skipRepair: groups.size > 1, }); const geom = createFlatShadedGeometry( generatedMesh.positions, generatedMesh.indices, { - activePixels, + activePixels: groupMask, width: boxW, height: boxH, pixelSize, topZ: (baseZ + thickness) * heightScale, - compactHeightfield: !buildSmoothMeshing, } ); - pushLayerDetail(buildLayerIndex, 'Preparing preview geometry', 0.96); const mat = new THREE.MeshStandardMaterial({ - color: colorHex, + color: groupHex, side: THREE.FrontSide, metalness: 0, roughness: 0.7, @@ -1503,21 +1311,39 @@ export default function ThreeDView({ // Store layer Z range for preview slider mesh.userData.baseZ = baseZ; mesh.userData.topZ = topZ; - builtLayerMeshes[i] = mesh; - pushLayerDetail(buildLayerIndex, 'Layer mesh complete', 1); - - if (performance.now() - lastYield > YIELD_MS) { - await new Promise((r) => requestAnimationFrame(r)); - if (token !== buildTokenRef.current) return; - lastYield = performance.now(); + // Store nozzle index (1-based) for 3MF extruder assignment. + const resolvedNozzle = groupNozzle.get(groupHex); + if (resolvedNozzle !== undefined) { + mesh.userData.nozzleIndex = resolvedNozzle; } + builtLayerMeshes.push(mesh); } - for (const mesh of builtLayerMeshes) { - if (mesh) { - modelGroup.add(mesh); - } + if (performance.now() - lastYield > YIELD_MS) { + await new Promise((r) => requestAnimationFrame(r)); + if (token !== buildTokenRef.current) return; + lastYield = performance.now(); + } + } + + for (const mesh of builtLayerMeshes) { + modelGroup.add(mesh); + } + + if (nozzleResolveMisses.size > 0 && multiHeadWindows?.length) { + const windowRanges = (multiHeadWindows ?? []) + .map(w => `[${w.windowStart}–${w.windowEnd}]`) + .join(', '); + console.group( + `[ThreeDView] ⚠ ${nozzleResolveMisses.size} non-windowed layer(s) have unresolved nozzle assignments (falling back to head 1)` + ); + console.log(` Windows covered: ${windowRanges}`); + const sortedLayers = [...nozzleResolveMisses.entries()] + .sort(([a], [b]) => a - b); + for (const [layerIdx, fids] of sortedLayers) { + console.log(` layer ${layerIdx}: unresolved filaments [${[...fids].join(', ')}]`); } + console.groupEnd(); } } else { // === STANDARD MODE === @@ -1566,7 +1392,6 @@ export default function ThreeDView({ pixelLayerPos[flippedRowOffset + x] = layerPos; } - pushScanDetail('Reading image color layers', (y + 1) / boxH); pushProgress(layeredBuildScanProgress(y, boxH, colorOrder.length)); if (performance.now() - lastYield > YIELD_MS) { await new Promise((r) => requestAnimationFrame(r)); @@ -1621,10 +1446,13 @@ export default function ThreeDView({ activeCount++; } } - pushLayerDetail( - buildLayerIndex, - 'Selecting active pixels', - progressInSpan(0, 0.35, (y + 1) / boxH) + pushProgress( + layeredBuildLayerProgress( + buildLayerIndex, + y, + boxH, + colorOrder.length + ) ); if (performance.now() - lastYield > YIELD_MS) { await new Promise((r) => requestAnimationFrame(r)); @@ -1637,18 +1465,10 @@ export default function ThreeDView({ // Generate Optimized Greedy Mesh const generatedMesh = await ( - buildSmoothMeshing ? generateSmoothMesh : generateGreedyMesh + smoothMeshing ? generateSmoothMesh : generateGreedyMesh )(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, { yieldIntervalMs: 8, - onProgress: meshProgressReporter(buildLayerIndex), - }); - meshBuildMetrics.push({ - layerIndex: i, - swatchIndex: swatchIdx, - activePixelCount: activeCount, - vertexCount: generatedMesh.positions.length / 3, - triangleCount: generatedMesh.indices.length / 3, - metrics: generatedMesh.metrics, + skipBottomCap: i > 0, }); const geom = createFlatShadedGeometry( @@ -1660,10 +1480,8 @@ export default function ThreeDView({ height: boxH, pixelSize, topZ: (baseZ + thickness) * heightScale, - compactHeightfield: !buildSmoothMeshing, } ); - pushLayerDetail(buildLayerIndex, 'Preparing preview geometry', 0.96); const mat = new THREE.MeshStandardMaterial({ color: colorHex, side: THREE.FrontSide, @@ -1679,7 +1497,6 @@ export default function ThreeDView({ mesh.userData.baseZ = baseZ; mesh.userData.topZ = topZ; builtLayerMeshes[i] = mesh; - pushLayerDetail(buildLayerIndex, 'Layer mesh complete', 1); if (performance.now() - lastYield > YIELD_MS) { await new Promise((r) => requestAnimationFrame(r)); @@ -1731,17 +1548,15 @@ export default function ThreeDView({ cropWidth: finalW, cropHeight: finalH, ...collectMeshStats(modelGroup), - layerMetrics: meshBuildMetrics, dimensions: nextModelDimensions, settings: { pixelSize, layerHeight, slicerFirstLayerHeight, - smoothMeshing: buildSmoothMeshing, + smoothMeshing, autoPaintEnabled, enhancedColorMatch, heightDithering, - flatPaint, }, }); @@ -1851,6 +1666,10 @@ export default function ThreeDView({ } })(); }, 120); + + return () => { + if (debounceTimerRef.current) window.clearTimeout(debounceTimerRef.current); + }; }, [ imageSrc, baseSliceHeight, @@ -1859,7 +1678,6 @@ export default function ThreeDView({ colorSliceHeights, colorOrder, swatches, - filamentSwatches, pixelSize, heightScale, stepped, @@ -1872,7 +1690,12 @@ export default function ThreeDView({ heightDithering, ditherLineWidth, smoothMeshing, - flatPaint, + perColorLayerColors, + colorLayerFilaments, + nozzleAssignments, + windowRunFilaments, + multiHeadWindows, + filamentIds, cameraRef, controlsRef, materialRef, @@ -1880,8 +1703,11 @@ export default function ThreeDView({ requestRender, ]); - const currentBuildOverlayStep = - buildOverlayStep ?? getBuildOverlayStep(buildProgress, colorOrder.length, autoPaintEnabled); + const buildOverlayStep = getBuildOverlayStep( + buildProgress, + colorOrder.length, + autoPaintEnabled + ); const previewHeightLabel = previewHeight !== null && previewMinHeight > 0.0001 ? `${previewMinHeight.toFixed(2)} - ${previewHeight.toFixed(2)} mm` @@ -1898,11 +1724,11 @@ export default function ThreeDView({
    {isBuilding && ( )} diff --git a/src/hooks/useAutoPaintWorker.ts b/src/hooks/useAutoPaintWorker.ts index 9562473..e23a9d9 100644 --- a/src/hooks/useAutoPaintWorker.ts +++ b/src/hooks/useAutoPaintWorker.ts @@ -28,6 +28,8 @@ export interface UseAutoPaintWorkerOptions { optimizerSeed?: number; regionWeightingMode: 'uniform' | 'center' | 'edge'; imageDimensions?: { width: number; height: number } | null; + multiHeadMode?: boolean; + multiHeadCount?: number; } export interface UseAutoPaintWorkerResult { @@ -60,6 +62,8 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain optimizerSeed, regionWeightingMode, imageDimensions, + multiHeadMode, + multiHeadCount, } = opts; const [autoPaintResult, setAutoPaintResult] = useState(undefined); @@ -206,6 +210,8 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain }, regionWeightingMode, imageDimensions: imageDimensions ?? undefined, + multiHeadMode, + multiHeadCount, }; worker.postMessage(request); @@ -239,6 +245,8 @@ export function useAutoPaintWorker(opts: UseAutoPaintWorkerOptions): UseAutoPain optimizerSeed, regionWeightingMode, imageDimensions, + multiHeadMode, + multiHeadCount, getWorker, stableFilaments, stableImageSwatches, diff --git a/src/hooks/useSwapPlan.ts b/src/hooks/useSwapPlan.ts index 2546f72..c7ad753 100644 --- a/src/hooks/useSwapPlan.ts +++ b/src/hooks/useSwapPlan.ts @@ -1,6 +1,14 @@ import { useMemo, useRef, useState } from 'react'; -import type { Swatch } from '../types'; -import type { AutoPaintResult } from '../lib/autoPaint'; +import type { Swatch, Filament, MultiHeadRangeAssignment } from '../types'; +import type { AutoPaintResult, TransitionZone } from '../lib/autoPaint'; +import type { WindowResult } from '../lib/multiHeadAnalysis'; +import { buildMultiHeadSchedule } from '../lib/multiHeadSchedule'; +import type { + MultiHeadNozzleEntry, + MultiHeadScheduleEvent, +} from '../lib/multiHeadSchedule'; + +export type { MultiHeadNozzleEntry, MultiHeadScheduleEvent }; export type SwapEntry = | { type: 'start'; swatch: Swatch } @@ -14,6 +22,15 @@ export interface UseSwapPlanOptions { slicerFirstLayerHeight: number; paintMode: 'manual' | 'autopaint'; autoPaintResult?: AutoPaintResult; + multiHeadWindows?: WindowResult[]; + /** When set, used in place of autoPaintResult.layers for the swap plan. */ + patchedTransitionZones?: TransitionZone[]; + // Multi-head nozzle assignment data (from optimizeNozzleAssignments). + nozzleAssignments?: number[][]; + windowRunFilaments?: string[][]; + preWindowFilaments?: string[]; + nonWindowedRanges?: MultiHeadRangeAssignment[]; + filaments?: Filament[]; disabled?: boolean; /** Flat Paint prints have no manual swap sequence (multi-material per layer) */ flatPaint?: boolean; @@ -27,6 +44,13 @@ export function useSwapPlan({ slicerFirstLayerHeight, paintMode, autoPaintResult, + multiHeadWindows = [], + patchedTransitionZones, + nozzleAssignments, + windowRunFilaments, + preWindowFilaments, + nonWindowedRanges, + filaments, disabled = false, flatPaint = false, }: UseSwapPlanOptions) { @@ -35,12 +59,16 @@ export function useSwapPlan({ return [] as SwapEntry[]; } - // When auto-paint is active and we have computed layers, use those - if (paintMode === 'autopaint' && autoPaintResult && autoPaintResult.layers.length > 0) { + // When auto-paint is active, build the swap plan from the effective + // layer sequence. patchedTransitionZones (from the multi-head analysis) + // takes priority over the original autoPaintResult.layers so that any + // reordered windows appear as additional swaps in the instructions. + const effectiveLayers = patchedTransitionZones ?? autoPaintResult?.layers; + if (paintMode === 'autopaint' && effectiveLayers && effectiveLayers.length > 0) { const plan: SwapEntry[] = []; - autoPaintResult.layers.forEach( + effectiveLayers.forEach( ( - layer: { filamentColor: string; startHeight: number; endHeight: number }, + layer: { filamentColor: string; startHeight: number }, idx: number ) => { const sw: Swatch = { hex: layer.filamentColor, a: 255 }; @@ -112,10 +140,25 @@ export function useSwapPlan({ slicerFirstLayerHeight, paintMode, autoPaintResult, + multiHeadWindows, + patchedTransitionZones, disabled, flatPaint, ]); + // Per-checkpoint head schedule for multi-head mode, ordered by layer number. + const multiHeadPlan = useMemo( + () => + buildMultiHeadSchedule({ + multiHeadWindows, + nozzleAssignments, + windowRunFilaments, + nonWindowedRanges, + filaments, + }), + [multiHeadWindows, nozzleAssignments, windowRunFilaments, nonWindowedRanges, filaments] + ); + // Build a plain-text representation of the instructions for copying const buildInstructionsText = () => { const lines: string[] = []; @@ -151,28 +194,45 @@ export function useSwapPlan({ return lines.join('\n'); } - if (swapPlan.length) { - const first = swapPlan[0]; - if (first.type === 'start') lines.push(`Start with color: ${first.swatch.hex}`); - } - - lines.push(''); - lines.push('Color swap plan:'); - if (swapPlan.length <= 1) { - lines.push('- No swaps — only one color configured.'); + if (multiHeadPlan) { + lines.push('Head load schedule:'); + for (const evt of multiHeadPlan.filter(e => e.isPrePrint || e.swapCount > 0)) { + const label = evt.isPrePrint + ? 'Before print — load all heads:' + : evt.swapCount === 0 + ? `Layer ${evt.startLayer} — no changes needed:` + : `Layer ${evt.startLayer} — swap ${evt.swapCount} head${evt.swapCount !== 1 ? 's' : ''}:`; + lines.push(label); + for (const n of evt.nozzles) { + if (evt.isPrePrint || n.changed) { + lines.push(` Head ${n.nozzle}: ${n.filamentHex}${n.changed ? ' ← swap' : ''}`); + } + } + lines.push(''); + } } else { - let idx = 1; - for (const entry of swapPlan) { - if (entry.type === 'start') { - lines.push(`${idx}. Start with ${entry.swatch.hex}`); - } else { - lines.push( - `${idx}. Swap to ${entry.swatch.hex} at layer ${ - entry.layer - } (~${entry.height.toFixed(3)} mm)` - ); + if (swapPlan.length) { + const first = swapPlan[0]; + if (first.type === 'start') lines.push(`Start with color: ${first.swatch.hex}`); + } + lines.push(''); + lines.push('Color swap plan:'); + if (swapPlan.length <= 1) { + lines.push('- No swaps — only one color configured.'); + } else { + let idx = 1; + for (const entry of swapPlan) { + if (entry.type === 'start') { + lines.push(`${idx}. Start with ${entry.swatch.hex}`); + } else { + lines.push( + `${idx}. Swap to ${entry.swatch.hex} at layer ${ + entry.layer + } (~${entry.height.toFixed(3)} mm)` + ); + } + idx++; } - idx++; } } appendFooter(); @@ -208,6 +268,7 @@ export function useSwapPlan({ return { swapPlan, + multiHeadPlan, copied, copyToClipboard, }; diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 56f98d8..cf6377a 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -22,9 +22,9 @@ import { type OptimizerOptions, type OptimizerResult, type ScoringContext, -} from './optimizer'; -import { generateCenterWeightedMapSimple, generateEdgeWeightedMapSimple } from './regionWeighting'; -import { computeProfileConfidence } from './calibration'; +} from './optimizer.ts'; +import { generateCenterWeightedMapSimple, generateEdgeWeightedMapSimple } from './regionWeighting.ts'; +import { computeProfileConfidence } from './calibration.ts'; export { LAYER_ACTIVATION_EPSILON } from './layerActivation'; diff --git a/src/lib/calibration.ts b/src/lib/calibration.ts index bd74984..7ed8cf7 100644 --- a/src/lib/calibration.ts +++ b/src/lib/calibration.ts @@ -1,4 +1,4 @@ -import { estimateTDFromColor } from './colorUtils'; +import { estimateTDFromColor } from './colorUtils.ts'; /** * Filament Calibration System diff --git a/src/lib/export3mf.ts b/src/lib/export3mf.ts index a14185f..d34875a 100644 --- a/src/lib/export3mf.ts +++ b/src/lib/export3mf.ts @@ -8,6 +8,23 @@ export interface Export3MFOptions { layerHeight?: number; firstLayerHeight?: number; layerFilamentColors?: string[]; // Optional per-layer filament colors (hex) for export + /** + * Number of physical nozzles on the target printer (e.g. 3 for a 3-head U1). + * When set, the 3MF declares exactly N nozzle_diameter entries and part extruder + * values are clamped to [1, N]. Orca must have a matching N-nozzle printer profile + * selected (e.g. Snapmaker U1) or it will crash on import. + * When omitted, falls back to AMS-style: single nozzle_diameter, K filament slots. + */ + extruderCount?: number; + /** + * Printer layers (1-based) where the print must pause so the operator can swap the + * filament loaded on the heads — i.e. the multi-head "Head Schedule" swap checkpoints. + * Each becomes a PausePrint marker in Metadata/custom_gcode_per_layer.xml at the + * layer's print_z, so OrcaSlicer inserts a pause (machine_pause_gcode / M600) at the + * start of that layer. Without these, Orca treats every head as one fixed filament for + * the whole print and silently drops the mid-print swaps. + */ + swapLayers?: { layer: number; color?: string }[]; onProgress?: (progress: number) => void; onZipProgress?: (progress: { percent: number; currentFile?: string | null }) => void; } @@ -71,6 +88,7 @@ export async function exportObjectTo3MFBlob( + `; zip.file('[Content_Types].xml', contentTypes); @@ -166,9 +184,29 @@ export async function exportObjectTo3MFBlob( return colorMap.get(mapKey)!; }; - // Pre-calculate all materials so we can write the header correctly + // Pre-calculate all materials so we can write the header correctly. + // For multi-head mode also collect one representative color per nozzle so + // Orca's filament panel shows something meaningful (cosmetic only — basematerials + // drives actual rendering; each nozzle's true color changes at phase boundaries + // per the Kromacut filament-swap instructions). + const nozzleRepColor = new Map(); // nozzle (1-based) -> RRGGBB for (const group of groups) { getMaterialIndex(group.meshes[0].material, group.overrideHex, group.materialKey); + if (options?.extruderCount) { + const ni = typeof group.meshes[0].userData?.nozzleIndex === 'number' + ? group.meshes[0].userData.nozzleIndex : null; + if (ni !== null && !nozzleRepColor.has(ni)) { + const overrideHex = group.overrideHex; + const mat = Array.isArray(group.meshes[0].material) + ? group.meshes[0].material[0] + : group.meshes[0].material; + let hex = normalizeHex(overrideHex) || 'FFFFFF'; + if (!overrideHex && 'color' in mat && (mat as THREE.MeshStandardMaterial).color) { + hex = (mat as THREE.MeshStandardMaterial).color.getHexString().toUpperCase(); + } + nozzleRepColor.set(ni, hex); + } + } } // Prepare Project Settings (Minimal) @@ -189,16 +227,136 @@ export async function exportObjectTo3MFBlob( // Helper to expand arrays to match color count const expand = (val: string, count: number) => Array(count).fill(val); - projectSettings.filament_colour = exportColors.map((c) => '#' + c); - - projectSettings.filament_type = expand('PLA', exportColors.length); - - projectSettings.filament_settings_id = expand( - 'Generic PLA @Kromacut 0.4 nozzle', - exportColors.length - ); - - projectSettings.filament_vendor = expand('Generic', exportColors.length); + const N = options?.extruderCount ?? 0; + if (N >= 2) { + // Multi-head (true multi-nozzle, e.g. Snapmaker U1): exactly N slots. + // Orca requires nozzle_diameter.length == extruder count in the loaded printer + // profile — the user must select a matching N-nozzle profile before importing. + projectSettings.filament_colour = Array.from({ length: N }, (_, k) => + '#' + (nozzleRepColor.get(k + 1) ?? 'FFFFFF') + ); + projectSettings.filament_type = expand('PLA', N); + projectSettings.filament_settings_id = expand('Generic PLA @Kromacut 0.4 nozzle', N); + projectSettings.filament_vendor = expand('Generic', N); + projectSettings.nozzle_diameter = expand('0.4', N); + // Declare N filaments' diameter. OrcaSlicer derives the *filament count* from + // filament_diameter.length (PresetBundle::validate_presets / project load), NOT + // from filament_colour. Our filament presets ("Generic PLA @Kromacut…") don't + // resolve to a system preset, so any per-filament array we omit defaults to a + // single element. If filament_diameter is length 1 while filament_colour/ + // filament_map are length N, Orca builds one filament slot but multi-extruder + // slicing indexes per-filament vectors by filament id 1..N-1 → an + // out-of-bounds std::vector::operator[] assertion that aborts the slice. Emitting + // it at length N makes Orca build N slots and expand the other arrays to match. + (projectSettings as Record).filament_diameter = expand('1.75', N); + // Declare every per-*extruder* setting at length N for the same reason. + // + // Our printer preset ("Kromacut 0.4 nozzle") also doesn't resolve to a system + // preset, so Orca builds a self-defined N-extruder printer from these project + // settings. nozzle_diameter (length N) makes Orca treat it as an N-extruder + // printer, but every other per-extruder array we omit stays at its length-1 + // default — and both project load (Tab::switch_excluder → extruder_type[k]) and + // slicing index those by extruder id 1..N-1, hitting a std::vector::operator[] + // out-of-bounds abort. We emit the full per-extruder key set + // (PrintConfigDef::m_extruder_option_keys + nozzle_volume_type) at length N. + // Values are generic 0.4 mm direct-drive defaults — the print itself is governed + // by the user's selected printer profile; these only need to be present and the + // right length. Serialization matches Orca's profile JSON: enums as labels, bools + // as "1"/"0", points as "0x0". + const perExtruderDefaults: Record = { + // floats / percents + min_layer_height: '0.08', + max_layer_height: '0.3', + extruder_printable_height: projectSettings.printable_height ?? '300', + nozzle_volume: '0', + retraction_length: '0.8', + z_hop: '0.4', + travel_slope: '3', + retract_lift_above: '0', + retract_lift_below: '0', + retraction_speed: '30', + deretraction_speed: '30', + retract_before_wipe: '0%', + retract_restart_extra: '0', + retraction_minimum_travel: '1', + wipe_distance: '1', + retract_length_toolchange: '2', + retract_restart_extra_toolchange: '0', + retraction_distances_when_cut: '18', + // enums (label form). extruder_type[k] / nozzle_volume_type[k] are what the + // GUI's switch_excluder() indexes at load time, so these are essential. + extruder_type: 'Direct Drive', + default_nozzle_volume_type: 'Standard', + nozzle_volume_type: 'Standard', + z_hop_types: 'Auto Lift', + retract_lift_enforce: 'All Surfaces', + nozzle_type: 'undefine', + // ints + nozzle_flush_dataset: '0', + // bools + wipe: '1', + retract_when_changing_layer: '1', + long_retractions_when_cut: '0', + // points / strings + extruder_offset: '0x0', + extruder_colour: '#FCE94F', + default_filament_profile: '', + }; + for (const [key, value] of Object.entries(perExtruderDefaults)) { + (projectSettings as Record)[key] = expand(value, N); + } + // Flush matrix between filaments, indexed per nozzle as + // flush_matrix[old_filament * filamentCount + new_filament] in GCode::set_extruder + // at every tool change. Orca expects (nozzleCount × filamentCount²) entries; if + // unset it defaults too small and the first tool change reads out of bounds. A + // toolchanger never cross-purges between heads, so zeros are correct here. + (projectSettings as Record).flush_volumes_matrix = expand('0', N * N * N); + (projectSettings as Record).flush_multiplier = expand('1', N); + // The self-defined printer has no pause G-code, so PausePrint markers (below) + // would expand to nothing. Provide one so the head-swap pauses actually emit. + (projectSettings as Record).machine_pause_gcode = 'M600'; + // Pin each logical filament to its matching physical nozzle/tool. + // + // On a toolchanger like the Snapmaker U1, a part's "extruder" value is a + // *logical* filament index. The physical tool that actually prints it is + // filament_map[extruder] (see OrcaSlicer get_extruder_index). With the + // default filament_map_mode ("Auto For Flush") Orca *recomputes* that map on + // slice to minimise flushing, which scrambles Kromacut's nozzle assignments — + // the imported part extruders no longer match the heads we chose. + // + // We assign extruder k to physical nozzle k, so the map must be the identity + // [1..N], and the mode must be "Manual" so Orca keeps it instead of + // re-deriving it (Print.cpp only honours a supplied map when mode >= fmmManual). + (projectSettings as Record).filament_map = Array.from( + { length: N }, + (_, k) => (k + 1).toString() + ); + (projectSettings as Record).filament_map_mode = 'Manual'; + // Toolchanger/multi-head printers use relative extrusion (M83). Orca requires a + // "G92 E0" extruder-position reset at each layer to avoid floating-point drift, + // and rejects the slice otherwise ("Relative extruder addressing requires + // resetting the extruder position at each layer ... Add 'G92 E0' to layer_gcode"). + // + // The validator (Print.cpp validate()) only inspects before_layer_change_gcode + // and layer_change_gcode — "layer_gcode" is a PrusaSlicer key name that doesn't + // exist in Orca at all, so the value we used to write here was silently dropped + // and never satisfied the check. Write the real Orca key instead. + (projectSettings as Record).before_layer_change_gcode = + ';BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n'; + } else { + // Single-head / AMS-style fallback: one nozzle_diameter, K colour slots. + projectSettings.filament_colour = exportColors.map((c) => '#' + c); + projectSettings.filament_type = expand('PLA', exportColors.length); + projectSettings.filament_settings_id = expand('Generic PLA @Kromacut 0.4 nozzle', exportColors.length); + projectSettings.filament_vendor = expand('Generic', exportColors.length); + // Keep filament_diameter length in step with the colour slots so Orca's + // filament-count derivation (filament_diameter.length) matches; see the + // multi-head branch above for why a short array crashes the slicer. + (projectSettings as Record).filament_diameter = expand( + '1.75', + exportColors.length + ); + } // Build object resources using a chunked writer to avoid OOM with massive arrays const xmlParts: string[] = []; @@ -298,11 +456,19 @@ export async function exportObjectTo3MFBlob( hex = (firstMat as THREE.MeshStandardMaterial).color.getHexString().toUpperCase(); } const objectName = group.partName ?? `Layer ${i + 1} (#${hex})`; - // Use 1-based index for color/extruder + // Use nozzle index from userData when present (multi-head: set by ThreeDView + // from the DP nozzle-assignment result). Fall back to color-order index for + // single-head and spatial-variance paths. + const rawNozzle = typeof group.meshes[0].userData?.nozzleIndex === 'number' + ? group.meshes[0].userData.nozzleIndex + : matIdx + 1; + // In multi-head mode clamp to [1, N] — a part referencing nozzle > N would + // cause an out-of-bounds vector access in OrcaSlicer. + const nozzleIdx = N >= 2 ? Math.max(1, Math.min(rawNozzle, N)) : rawNozzle; componentMeta.push({ id: objectId, name: objectName, - colorIdx: matIdx + 1, + colorIdx: nozzleIdx, }); const writeMeshGroupObject = async ( @@ -683,6 +849,36 @@ export async function exportObjectTo3MFBlob( JSON.stringify(projectSettings, null, 4) ); + // Manual head-swap pauses (multi-head Head Schedule). Each swap layer becomes a + // PausePrint (type=1) entry at the layer's print_z; OrcaSlicer inserts a pause + // (machine_pause_gcode, e.g. M600) at the start of that layer. print_z matches the + // slicer's layer Z: firstLayerHeight for layer 1, then +layerHeight per layer. The + // gcode attribute is informational — Orca re-derives the real pause gcode from the + // type at slice time. + const swapLayers = (options?.swapLayers ?? []).filter((s) => s.layer >= 2); + if (swapLayers.length > 0) { + const lhVal = Number(projectSettings.layer_height) || 0.2; + const flVal = Number(projectSettings.initial_layer_print_height) || lhVal; + const layerLines = swapLayers + .map((s) => { + const topZ = Number((flVal + (s.layer - 1) * lhVal).toFixed(5)); + const hex = normalizeHex(s.color); + const color = hex ? '#' + hex : '#888888'; + return ``; + }) + .join('\n'); + const customGcodeXml = ` + + + +${layerLines} + + + +`; + zip.folder('Metadata')?.file('custom_gcode_per_layer.xml', customGcodeXml); + } + reportProgress(exportZipProgress(0)); const blob = await zip.generateAsync( diff --git a/src/lib/meshing.ts b/src/lib/meshing.ts index 2490a01..cef8d7d 100644 --- a/src/lib/meshing.ts +++ b/src/lib/meshing.ts @@ -31,6 +31,18 @@ interface MeshYieldOptions { yieldIntervalMs?: number; onYield?: () => Promise; onProgress?: (progress: MeshProgress) => void; + /** + * Skip the downward-facing bottom cap. Used by the multi-head / per-colour-group + * meshing path when stacking sub-meshes on top of a lower layer so interior faces + * don't z-fight the layer beneath. + */ + skipBottomCap?: boolean; + /** + * Skip the binary corner-contact repair pass. Used when a layer is split into + * multiple colour groups so adjacent groups aren't independently "repaired" into + * overlapping geometry. + */ + skipRepair?: boolean; } interface GridMeshOptions extends MeshYieldOptions { @@ -259,7 +271,10 @@ async function generateGridMesh( options: GridMeshOptions ): Promise { const startedAt = performance.now(); - const meshingPixels = repairBinaryCornerContacts(activePixels, width, height); + const skipBottomCap = options.skipBottomCap ?? false; + const meshingPixels = options.skipRepair + ? activePixels + : repairBinaryCornerContacts(activePixels, width, height); const positions: number[] = []; const indices: number[] = []; let vertCount = 0; @@ -501,7 +516,9 @@ async function generateGridMesh( for (let i = 0; i < boundary.length; i++) { const next = (i + 1) % boundary.length; indices.push(topCenter, topLoop[i], topLoop[next]); - indices.push(bottomCenter, bottomLoop[next], bottomLoop[i]); + if (!skipBottomCap) { + indices.push(bottomCenter, bottomLoop[next], bottomLoop[i]); + } } } else { const facePoints = boundary.map(([vx, vy]) => new Vector2(vx, vy)); @@ -509,7 +526,9 @@ async function generateGridMesh( for (const [a, b, c] of faces) { indices.push(topLoop[a], topLoop[b], topLoop[c]); - indices.push(bottomLoop[a], bottomLoop[c], bottomLoop[b]); + if (!skipBottomCap) { + indices.push(bottomLoop[a], bottomLoop[c], bottomLoop[b]); + } } } diff --git a/src/lib/multiHeadAnalysis.ts b/src/lib/multiHeadAnalysis.ts new file mode 100644 index 0000000..e5d4135 --- /dev/null +++ b/src/lib/multiHeadAnalysis.ts @@ -0,0 +1,440 @@ +/** + * Multi-head layer selection analysis. + * + * Slides a window of N consecutive printer layers across the stack and, for each + * window, builds a LUT of all K^N filament-to-position assignments (K = unique + * filaments in that window). For every image swatch the per-pixel optimal LUT + * entry is found via full Beer-Lambert simulation. The errorFactor for a window + * is the sum of per-pixel improvements achievable by reordering its layers. + * + * Primary lookup chain for renderers / 3MF builders: + * filamentIds[ lut[ pixelOptimalLUTIdx[p] ][n] ] + * → the ID of the filament that gives pixel p its best color at window layer n. + */ + +import type { AutoPaintResult } from './autoPaint.ts'; +import type { Filament } from '../types'; +import { + hexToRgb, + blendColors, + deltaE, + getLuminance, + luminanceToHeight, + type RGB, +} from './autoPaint.ts'; + +const FRONTLIT_TD_SCALE = 0.1; + +export interface PrinterLayer { + /** Index into the original filaments array */ + filamentIdx: number; + filamentRgb: RGB; + /** TD already multiplied by FRONTLIT_TD_SCALE */ + td: number; + thickness: number; + startZ: number; +} + +export interface WindowFilament { + rgb: RGB; + /** TD already multiplied by FRONTLIT_TD_SCALE */ + td: number; +} + +export interface PixelData { + targetRgb: RGB; + /** Index of the printer layer this swatch maps to. */ + layerIdx: number; + /** ΔE between target and actual stack color at layerIdx. */ + actualErr: number; +} + +/** Per-window output from the sliding-window analysis. */ +export interface WindowResult { + /** Index of the first layer in the window (1-based; layer 0 is the opaque foundation). */ + windowStart: number; + /** Index of the last layer in the window (inclusive). */ + windowEnd: number; + windowBottomZ: number; + windowTopZ: number; + /** + * Display names of the K unique filaments in this window, in LUT index order. + * `currentFilaments[lut[entry][n]]` gives the name of the filament at position n + * for a given LUT entry. + */ + currentFilaments: string[]; + /** + * IDs of the K unique filaments in this window, in LUT index order. + * Full renderer lookup: `filamentIds[lut[pixelOptimalLUTIdx[p]][n]]` + * gives the filament ID that minimises error for pixel p at window layer n. + */ + filamentIds: string[]; + /** Number of palette swatches whose mapped height reaches this window. */ + affectedSwatches: number; + /** Sum of (actualΔE − minPossibleΔE) across affected swatches. Always ≥ 0. */ + errorFactor: number; + /** All K^N filament assignments for this window (K = unique filaments in window). */ + lut: number[][]; + /** + * Per-swatch best LUT entry index (length = imageSwatches.length passed to analysis). + * -1 for swatches whose mapped height falls below this window. + * Use as: lut[pixelOptimalLUTIdx[p]][n] → index into filamentIds / currentFilaments. + */ + pixelOptimalLUTIdx: number[]; +} + +/** Expand transition zones into individual printer-layer entries. */ +export function expandZonesToPrinterLayers( + result: AutoPaintResult, + filaments: Filament[], + layerHeight: number, + firstLayerHeight: number +): PrinterLayer[] { + const layers: PrinterLayer[] = []; + const { transitionZones: zones, totalHeight } = result; + if (zones.length === 0 || totalHeight <= 0) return layers; + + let currentZ = 0; + let layerIndex = 0; + + while (currentZ < totalHeight) { + const thickness = + layerIndex === 0 ? Math.max(firstLayerHeight, layerHeight) : layerHeight; + + let activeZoneIdx = 0; + for (let zi = 0; zi < zones.length; zi++) { + if (currentZ >= zones[zi].startHeight) activeZoneIdx = zi; + } + + const zone = zones[activeZoneIdx]; + const filamentIdx = filaments.findIndex((f) => f.id === zone.filamentId); + const filament = filamentIdx >= 0 ? filaments[filamentIdx] : filaments[0]; + + layers.push({ + filamentIdx: Math.max(0, filamentIdx), + filamentRgb: hexToRgb(zone.filamentColor), + td: filament.td * FRONTLIT_TD_SCALE, + thickness, + startZ: currentZ, + }); + + currentZ += thickness; + layerIndex++; + if (layerIndex > 500) break; + } + + return layers; +} + +/** + * Generate all filamentCount^windowSize filament-index assignments. + * Each entry assigns one of `filamentCount` filaments to each of + * `windowSize` window positions, reading the digits of i in base filamentCount. + * + * The returned LUT is deterministic and can be reconstructed from the same inputs, + * so callers may store only the index rather than the full entry when space matters. + */ +export function buildLUT(windowSize: number, filamentCount: number): number[][] { + const total = Math.pow(filamentCount, windowSize); + const lut: number[][] = new Array(total); + for (let i = 0; i < total; i++) { + const entry = new Array(windowSize); + let v = i; + for (let j = 0; j < windowSize; j++) { + entry[j] = v % filamentCount; + v = Math.floor(v / filamentCount); + } + lut[i] = entry; + } + return lut; +} + +/** Return the index of the last printer layer whose startZ ≤ h. */ +export function findLayerIdxAtHeight(layers: PrinterLayer[], h: number): number { + let idx = 0; + for (let i = 0; i < layers.length; i++) { + if (layers[i].startZ <= h) idx = i; + else break; + } + return idx; +} + +/** + * Accumulate the Beer-Lambert stack color at each printer layer. + * Layer 0 is the opaque foundation (raw filament color); each subsequent + * layer blends its filament on top of the running total. + */ +export function buildColorStack(layers: PrinterLayer[]): RGB[] { + const colorAtLayer: RGB[] = new Array(layers.length); + colorAtLayer[0] = { ...layers[0].filamentRgb }; + for (let i = 1; i < layers.length; i++) { + colorAtLayer[i] = blendColors( + colorAtLayer[i - 1], + layers[i].filamentRgb, + layers[i].td, + layers[i].thickness + ); + } + return colorAtLayer; +} + +/** + * Map each image swatch to its target printer layer and precompute actual ΔE. + * Luminance drives height via the Beer-Lambert inverse; the layer at that height + * gives the current stack color for comparison. + */ +export function buildPixelData( + imageSwatches: Array<{ hex: string }>, + layers: PrinterLayer[], + colorAtLayer: RGB[], + transitionZones: AutoPaintResult['transitionZones'], + totalHeight: number, + firstLayerHeight: number +): PixelData[] { + return imageSwatches.map((s) => { + const rgb = hexToRgb(s.hex); + const lum = getLuminance(rgb) / 255; + const h = luminanceToHeight(lum, transitionZones, totalHeight, firstLayerHeight); + const layerIdx = findLayerIdxAtHeight(layers, h); + return { targetRgb: rgb, layerIdx, actualErr: deltaE(rgb, colorAtLayer[layerIdx]) }; + }); +} + +/** + * Run the LUT simulation for a single window and return per-pixel optimal assignments. + * + * For each affected pixel (layerIdx ≥ wStart), every LUT entry is simulated: + * - Layers below the window are captured in `baseColor` (colorAtLayer[wStart-1]). + * - Window layers are applied in LUT order (up to the pixel's own layerIdx). + * - Layers above the window continue in their original order. + * The entry producing the lowest ΔE is recorded in `pixelOptimalLUTIdx`. + * + * @param wStart First layer index in the window (1-based). + * @param N Window size (number of layers). + * @param layers Full printer-layer stack. + * @param baseColor Accumulated stack color just below the window (colorAtLayer[wStart-1]). + * @param windowFilaments Unique filaments available in this window, in LUT index order. + * @param lut All K^N assignments from buildLUT(N, windowFilaments.length). + * @param pixels Swatch pixel data from buildPixelData. + */ +export function analyzeWindowLUT( + wStart: number, + N: number, + layers: PrinterLayer[], + baseColor: RGB, + windowFilaments: WindowFilament[], + lut: number[][], + pixels: PixelData[] +): { errorFactor: number; affectedSwatches: number; pixelOptimalLUTIdx: number[] } { + const wEnd = wStart + N - 1; + let totalActualError = 0; + let totalMinError = 0; + let affectedSwatches = 0; + const pixelOptimalLUTIdx: number[] = new Array(pixels.length).fill(-1); + + for (let pxIdx = 0; pxIdx < pixels.length; pxIdx++) { + const px = pixels[pxIdx]; + if (px.layerIdx < wStart) continue; + affectedSwatches++; + totalActualError += px.actualErr; + + let minErr = Infinity; + let bestLUTIdx = 0; + const applyCount = Math.min(N, px.layerIdx - wStart + 1); + + for (let li = 0; li < lut.length; li++) { + const entry = lut[li]; + let c: RGB = { ...baseColor }; + for (let j = 0; j < applyCount; j++) { + const fi = entry[j]; + c = blendColors(c, windowFilaments[fi].rgb, windowFilaments[fi].td, layers[wStart + j].thickness); + } + for (let i = wEnd + 1; i <= px.layerIdx && i < layers.length; i++) { + c = blendColors(c, layers[i].filamentRgb, layers[i].td, layers[i].thickness); + } + const err = deltaE(px.targetRgb, c); + if (err < minErr) { minErr = err; bestLUTIdx = li; } + } + + pixelOptimalLUTIdx[pxIdx] = bestLUTIdx; + totalMinError += minErr; + } + + return { errorFactor: totalActualError - totalMinError, affectedSwatches, pixelOptimalLUTIdx }; +} + +/** + * Core sliding-window computation — returns one `WindowResult` per window + * without any side effects. Use `runMultiHeadLayerAnalysis` for console output + * and the selected non-overlapping subset. + */ +export function analyzeMultiHeadWindows( + filaments: Filament[], + result: AutoPaintResult, + imageSwatches: Array<{ hex: string }>, + layerHeight: number, + firstLayerHeight: number, + n: number +): WindowResult[] { + const N = Math.min(n, filaments.length); + if (N < 2 || result.transitionZones.length === 0 || imageSwatches.length === 0) return []; + + const layers = expandZonesToPrinterLayers(result, filaments, layerHeight, firstLayerHeight); + if (layers.length < N + 1) return []; + + const colorAtLayer = buildColorStack(layers); + const pixels = buildPixelData( + imageSwatches, layers, colorAtLayer, + result.transitionZones, result.totalHeight, firstLayerHeight + ); + + const windows: WindowResult[] = []; + + for (let wStart = 1; wStart + N <= layers.length; wStart++) { + const wEnd = wStart + N - 1; + + const uniqueIndices = [...new Set( + Array.from({ length: N }, (_, j) => layers[wStart + j].filamentIdx) + )]; + const windowFilaments: WindowFilament[] = uniqueIndices.map((fi) => ({ + rgb: hexToRgb(filaments[fi]?.color ?? '#000000'), + td: (filaments[fi]?.td ?? 0.5) * FRONTLIT_TD_SCALE, + })); + const lut = buildLUT(N, windowFilaments.length); + + const { errorFactor, affectedSwatches, pixelOptimalLUTIdx } = analyzeWindowLUT( + wStart, N, layers, colorAtLayer[wStart - 1], windowFilaments, lut, pixels + ); + + windows.push({ + windowStart: wStart, + windowEnd: wEnd, + windowBottomZ: layers[wStart].startZ, + windowTopZ: layers[wEnd].startZ + layers[wEnd].thickness, + currentFilaments: uniqueIndices.map((fi) => + filaments[fi]?.name ?? filaments[fi]?.color ?? `f${fi}` + ), + filamentIds: uniqueIndices.map((fi) => filaments[fi]?.id ?? `f${fi}`), + affectedSwatches, + errorFactor, + lut, + pixelOptimalLUTIdx, + }); + } + + return windows; +} + +/** + * Select the non-overlapping subset of windows that maximises the total errorFactor. + * + * Windows overlap when their layer ranges share any index. Because every window + * is width N and adjacent windows differ by one layer, the latest non-overlapping + * predecessor for window i is always exactly N steps back — so the DP recurrence + * is O(n) with no binary search needed. + * + * dp[i] = max total errorFactor using windows[0..i-1] + * dp[i] = max(dp[i-1], windows[i-1].errorFactor + dp[max(0, i-N)]) + */ +export function selectBestWindows(windows: WindowResult[], windowSize: number): WindowResult[] { + const n = windows.length; + if (n === 0) return []; + + const dp = new Float64Array(n + 1); // dp[0] = 0 + + for (let i = 1; i <= n; i++) { + const predDp = i - windowSize >= 0 ? dp[i - windowSize] : 0; + const withWindow = windows[i - 1].errorFactor + predDp; + dp[i] = withWindow > dp[i - 1] ? withWindow : dp[i - 1]; + } + + // Traceback: at each step, check whether window i-1 was chosen + const selected: WindowResult[] = []; + let i = n; + while (i > 0) { + const predDp = i - windowSize >= 0 ? dp[i - windowSize] : 0; + const withWindow = windows[i - 1].errorFactor + predDp; + if (withWindow > dp[i - 1] + 1e-9) { + selected.unshift(windows[i - 1]); + i = Math.max(0, i - windowSize); + } else { + i -= 1; + } + } + + return selected; +} + +/** + * Run the full multi-head layer analysis, log results to the console, and return + * the selected non-overlapping windows with the highest combined errorFactor. + * + * Each returned `WindowResult` carries the LUT and per-pixel optimal indices + * needed by the renderer: + * filamentIds[ lut[ pixelOptimalLUTIdx[p] ][n] ] → filament ID for pixel p at layer n + */ +export function runMultiHeadLayerAnalysis( + filaments: Filament[], + result: AutoPaintResult, + imageSwatches: Array<{ hex: string }>, + layerHeight: number, + firstLayerHeight: number, + n: number +): WindowResult[] { + const N = Math.min(n, filaments.length); + + if (N < 2 || result.transitionZones.length === 0 || imageSwatches.length === 0) { + console.log('[MultiHead] Insufficient data (need ≥2 filaments and image swatches).'); + return []; + } + + const windows = analyzeMultiHeadWindows( + filaments, result, imageSwatches, layerHeight, firstLayerHeight, n + ); + + if (windows.length === 0) { + console.log(`[MultiHead] Not enough printer layers for window size N=${N}.`); + return []; + } + + const heads = filaments.slice(0, N) + .map((f, i) => `[${i}] ${f.name ?? f.color}`) + .join(' '); + + console.group( + `[MultiHead] N=${N} heads | LUT per window: up to ${Math.pow(N, N)} entries (${N}^${N}) | ` + + `${windows.length + N} printer layers | ${imageSwatches.length} swatches` + ); + console.log(` Heads: ${heads}`); + + for (const w of windows) { + console.log( + ` W[${String(w.windowStart).padStart(3)}–${String(w.windowEnd).padStart(3)}]` + + ` Z: ${w.windowBottomZ.toFixed(3)}–${w.windowTopZ.toFixed(3)} mm` + + ` | [${w.currentFilaments.join(' → ')}]` + + ` | swatches: ${w.affectedSwatches}/${imageSwatches.length}` + + ` | errorFactor: ${w.errorFactor.toFixed(4)}` + ); + } + + const best = selectBestWindows(windows, N); + const bestTotal = best.reduce((s, w) => s + w.errorFactor, 0); + + console.log(''); + console.log( + ` ── Best non-overlapping selection (${best.length} windows, ` + + `total errorFactor: ${bestTotal.toFixed(4)}) ──` + ); + for (const w of best) { + console.log( + ` ★ W[${String(w.windowStart).padStart(3)}–${String(w.windowEnd).padStart(3)}]` + + ` Z: ${w.windowBottomZ.toFixed(3)}–${w.windowTopZ.toFixed(3)} mm` + + ` | [${w.currentFilaments.join(' → ')}]` + + ` | errorFactor: ${w.errorFactor.toFixed(4)}` + ); + } + + console.groupEnd(); + + return best; +} diff --git a/src/lib/multiHeadAnalysisColorFirst.ts b/src/lib/multiHeadAnalysisColorFirst.ts new file mode 100644 index 0000000..b286d35 --- /dev/null +++ b/src/lib/multiHeadAnalysisColorFirst.ts @@ -0,0 +1,996 @@ +/** + * Color-first multi-head analysis pipeline. + * + * Before running the sliding-window LUT simulation, swatches that map to the + * same printer layer are merged into a single frequency-weighted entry. This + * trades a small amount of per-swatch fidelity for a proportional reduction in + * the inner-loop work of analyzeWindowLUT. + * + * Use analyzeMultiHeadWindowsColorFirst as a drop-in alongside + * analyzeMultiHeadWindows to compare errorFactor rankings side-by-side. + */ + +import type { AutoPaintResult } from './autoPaint.ts'; +import type { Filament, MultiHeadRangeAssignment } from '../types'; +import { + hexToRgb, + blendColors, + deltaE, + getLuminance, + luminanceToHeight, + type RGB, +} from './autoPaint.ts'; +import { + expandZonesToPrinterLayers, + findLayerIdxAtHeight, + buildColorStack, + type PrinterLayer, + type WindowFilament, + type WindowResult, +} from './multiHeadAnalysis.ts'; + + +const FRONTLIT_TD_SCALE = 0.1; + +/** + * A swatch entry deduplicated by printer layer. + * All swatches that share the same layerIdx are merged: targetRgb is their + * frequency-weighted centroid and count is the sum of their pixel counts. + */ +export interface ColorFirstPixel { + targetRgb: RGB; + layerIdx: number; + /** ΔE between the centroid color and the actual stack color at layerIdx. */ + actualErr: number; + /** Total pixel count of all swatches merged into this entry. */ + count: number; +} + +/** + * Collapse imageSwatches into one ColorFirstPixel per unique printer layer. + * + * Swatches that land on the same layer are merged: + * - targetRgb → frequency-weighted RGB centroid + * - actualErr → ΔE between the centroid and colorAtLayer[layerIdx] + * - count → sum of constituent swatch counts (or 1 each when absent) + */ +export function buildPixelDataColorFirst( + imageSwatches: Array<{ hex: string; count?: number }>, + layers: PrinterLayer[], + colorAtLayer: RGB[], + transitionZones: AutoPaintResult['transitionZones'], + totalHeight: number, + firstLayerHeight: number +): ColorFirstPixel[] { + const byLayer = new Map(); + + for (const s of imageSwatches) { + const rgb = hexToRgb(s.hex); + const lum = getLuminance(rgb) / 255; + const h = luminanceToHeight(lum, transitionZones, totalHeight, firstLayerHeight); + const layerIdx = findLayerIdxAtHeight(layers, h); + const cnt = s.count ?? 1; + + const existing = byLayer.get(layerIdx); + if (existing) { + const total = existing.count + cnt; + existing.r = (existing.r * existing.count + rgb.r * cnt) / total; + existing.g = (existing.g * existing.count + rgb.g * cnt) / total; + existing.b = (existing.b * existing.count + rgb.b * cnt) / total; + existing.count = total; + } else { + byLayer.set(layerIdx, { r: rgb.r, g: rgb.g, b: rgb.b, count: cnt }); + } + } + + return Array.from(byLayer.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([layerIdx, { r, g, b, count }]) => { + const targetRgb: RGB = { r, g, b }; + return { + targetRgb, + layerIdx, + actualErr: deltaE(targetRgb, colorAtLayer[layerIdx]), + count, + }; + }); +} + +/** + * A contiguous run of printer layers that all share the same filament. + * Windows are defined in terms of runs, not individual layers. + */ +export interface ColorRun { + filamentIdx: number; + /** First printer-layer index in this run (inclusive). */ + startLayerIdx: number; + /** Last printer-layer index in this run (inclusive). */ + endLayerIdx: number; +} + +/** + * Group consecutive same-filament printer layers into runs. + * A run boundary occurs wherever the filamentIdx changes. + */ +export function buildColorRuns(layers: PrinterLayer[]): ColorRun[] { + if (layers.length === 0) return []; + const runs: ColorRun[] = []; + let runStart = 0; + for (let i = 1; i <= layers.length; i++) { + if (i === layers.length || layers[i].filamentIdx !== layers[runStart].filamentIdx) { + runs.push({ filamentIdx: layers[runStart].filamentIdx, startLayerIdx: runStart, endLayerIdx: i - 1 }); + runStart = i; + } + } + return runs; +} + +/** + * Simulate all K^N filament combinations for a run-based window, finding the + * optimal slot assignment for every color in a single pass per combination. + * + * Loop order: K^N combinations (outer) × colors (inner, updated at their layer). + * Each combination is simulated once, advancing layer-by-layer through the window + * and above. Every color's running minimum is updated the moment the simulation + * reaches its target layerIdx — no per-color restart from baseColor. + * + * Returns per-color optimal slot assignments (direct number[] arrays, no LUT + * indirection) alongside the frequency-weighted errorFactor. + */ +export function computeColorOptimalAssignments( + windowRuns: ColorRun[], + wEnd: number, + layers: PrinterLayer[], + baseColor: RGB, + windowFilaments: WindowFilament[], + pixels: ColorFirstPixel[] +): { errorFactor: number; affectedCount: number; assignments: (number[] | null)[] } { + const K = windowFilaments.length; + const N = windowRuns.length; + const wStart = windowRuns[0].startLayerIdx; + + // Index pixels by layerIdx for O(1) lookup during the simulation sweep. + const pixelsAtLayer = new Map(); + const minErr = new Float64Array(pixels.length).fill(Infinity); + const assignments: (number[] | null)[] = new Array(pixels.length).fill(null); + let totalActualError = 0; + let affectedCount = 0; + let maxAffectedLayer = wStart - 1; + + for (let pxIdx = 0; pxIdx < pixels.length; pxIdx++) { + const px = pixels[pxIdx]; + if (px.layerIdx < wStart) continue; + affectedCount += px.count; + totalActualError += px.actualErr * px.count; + if (px.layerIdx > maxAffectedLayer) maxAffectedLayer = px.layerIdx; + const bucket = pixelsAtLayer.get(px.layerIdx); + if (bucket) bucket.push(pxIdx); + else pixelsAtLayer.set(px.layerIdx, [pxIdx]); + } + + const total = K ** N; + + for (let combo = 0; combo < total; combo++) { + // Decode combo to slot assignments (base-K digits). + const entry: number[] = new Array(N); + let v = combo; + for (let j = 0; j < N; j++) { entry[j] = v % K; v = Math.floor(v / K); } + + // Single incremental simulation through the window runs. + let c: RGB = { ...baseColor }; + for (let r = 0; r < N; r++) { + const run = windowRuns[r]; + const filament = windowFilaments[entry[r]]; + for (let i = run.startLayerIdx; i <= run.endLayerIdx; i++) { + c = blendColors(c, filament.rgb, filament.td, layers[i].thickness); + const bucket = pixelsAtLayer.get(i); + if (bucket) { + for (const pxIdx of bucket) { + const err = deltaE(pixels[pxIdx].targetRgb, c); + if (err < minErr[pxIdx]) { minErr[pxIdx] = err; assignments[pxIdx] = entry.slice(); } + } + } + } + } + + // Continue above the window for colors whose layerIdx > wEnd. + for (let i = wEnd + 1; i <= maxAffectedLayer; i++) { + c = blendColors(c, layers[i].filamentRgb, layers[i].td, layers[i].thickness); + const bucket = pixelsAtLayer.get(i); + if (bucket) { + for (const pxIdx of bucket) { + const err = deltaE(pixels[pxIdx].targetRgb, c); + if (err < minErr[pxIdx]) { minErr[pxIdx] = err; assignments[pxIdx] = entry.slice(); } + } + } + } + } + + let totalMinError = 0; + for (let pxIdx = 0; pxIdx < pixels.length; pxIdx++) { + if (minErr[pxIdx] < Infinity) totalMinError += minErr[pxIdx] * pixels[pxIdx].count; + } + + return { errorFactor: totalActualError - totalMinError, affectedCount, assignments }; +} + +/** + * Drop-in parallel to analyzeMultiHeadWindows that uses the color-first pixel + * pipeline. The returned WindowResult is structurally identical to the base + * pipeline's output and carries errorFactor for comparison purposes. + * + * Note: affectedSwatches in the returned WindowResult is the total weighted + * pixel count, not the number of unique color groups. + */ +export function analyzeMultiHeadWindowsColorFirst( + filaments: Filament[], + result: AutoPaintResult, + imageSwatches: Array<{ hex: string; count?: number }>, + layerHeight: number, + firstLayerHeight: number, + n: number +): WindowResult[] { + const N = Math.min(n, filaments.length); + if (N < 2 || result.transitionZones.length === 0 || imageSwatches.length === 0) return []; + + const layers = expandZonesToPrinterLayers(result, filaments, layerHeight, firstLayerHeight); + if (layers.length < N + 1) return []; + + const colorAtLayer = buildColorStack(layers); + const runs = buildColorRuns(layers); + const pixels = buildPixelDataColorFirst( + imageSwatches, layers, colorAtLayer, + result.transitionZones, result.totalHeight, firstLayerHeight + ); + + const windows: WindowResult[] = []; + + // Slide a window of N consecutive color runs (not N individual layers). + // Each run covers all printer layers that share the same filament, so the + // window always spans exactly N color zones regardless of how many layers + // each zone occupies. + for (let rStart = 0; rStart + N <= runs.length; rStart++) { + const windowRuns = runs.slice(rStart, rStart + N); + const wStart = windowRuns[0].startLayerIdx; + const wEnd = windowRuns[N - 1].endLayerIdx; + + // Skip the foundation run (layer 0 is the opaque base). + if (wStart === 0) continue; + + const uniqueIndices = [...new Set(windowRuns.map((r) => r.filamentIdx))]; + const windowFilaments: WindowFilament[] = uniqueIndices.map((fi) => ({ + rgb: hexToRgb(filaments[fi]?.color ?? '#000000'), + td: (filaments[fi]?.td ?? 0.5) * FRONTLIT_TD_SCALE, + })); + + const { errorFactor, affectedCount } = computeColorOptimalAssignments( + windowRuns, wEnd, layers, colorAtLayer[wStart - 1], windowFilaments, pixels + ); + + windows.push({ + windowStart: wStart, + windowEnd: wEnd, + windowBottomZ: layers[wStart].startZ, + windowTopZ: layers[wEnd].startZ + layers[wEnd].thickness, + currentFilaments: uniqueIndices.map((fi) => + filaments[fi]?.name ?? filaments[fi]?.color ?? `f${fi}` + ), + filamentIds: uniqueIndices.map((fi) => filaments[fi]?.id ?? `f${fi}`), + affectedSwatches: affectedCount, + errorFactor, + lut: [], + pixelOptimalLUTIdx: [], + }); + } + + return windows; +} + +// --------------------------------------------------------------------------- +// Consensus combo + layer patching (iterative pipeline helpers) +// --------------------------------------------------------------------------- + +/** + * Find the single K^N filament combo that minimises the aggregate weighted + * ΔE across all affected color groups for this window. + * + * Unlike computeColorOptimalAssignments (which gives each color its own best + * combo), this returns one consensus ordering that the printer can actually + * use — every pixel at the same height sees the same head assignment. + * + * Returns the winning entry, the improvement over the current stack, and the + * total affected pixel count. + */ +function findConsensusCombo( + windowRuns: ColorRun[], + wEnd: number, + layers: PrinterLayer[], + baseColor: RGB, + windowFilaments: WindowFilament[], + pixels: ColorFirstPixel[] +): { entry: number[]; errorFactor: number; affectedCount: number } { + const K = windowFilaments.length; + const N = windowRuns.length; + const wStart = windowRuns[0].startLayerIdx; + + const pixelsAtLayer = new Map(); + let totalActualError = 0; + let affectedCount = 0; + let maxAffectedLayer = wStart - 1; + + for (let pxIdx = 0; pxIdx < pixels.length; pxIdx++) { + const px = pixels[pxIdx]; + if (px.layerIdx < wStart) continue; + affectedCount += px.count; + totalActualError += px.actualErr * px.count; + if (px.layerIdx > maxAffectedLayer) maxAffectedLayer = px.layerIdx; + const bucket = pixelsAtLayer.get(px.layerIdx); + if (bucket) bucket.push(pxIdx); + else pixelsAtLayer.set(px.layerIdx, [pxIdx]); + } + + const total = K ** N; + let bestEntry: number[] = Array.from({ length: N }, () => 0); + let bestError = Infinity; + + for (let combo = 0; combo < total; combo++) { + const entry: number[] = new Array(N); + let v = combo; + for (let j = 0; j < N; j++) { entry[j] = v % K; v = Math.floor(v / K); } + + let comboError = 0; + let c: RGB = { ...baseColor }; + + for (let r = 0; r < N; r++) { + const run = windowRuns[r]; + const filament = windowFilaments[entry[r]]; + for (let i = run.startLayerIdx; i <= run.endLayerIdx; i++) { + c = blendColors(c, filament.rgb, filament.td, layers[i].thickness); + const bucket = pixelsAtLayer.get(i); + if (bucket) for (const pxIdx of bucket) + comboError += deltaE(pixels[pxIdx].targetRgb, c) * pixels[pxIdx].count; + } + } + for (let i = wEnd + 1; i <= maxAffectedLayer; i++) { + c = blendColors(c, layers[i].filamentRgb, layers[i].td, layers[i].thickness); + const bucket = pixelsAtLayer.get(i); + if (bucket) for (const pxIdx of bucket) + comboError += deltaE(pixels[pxIdx].targetRgb, c) * pixels[pxIdx].count; + } + + if (comboError < bestError) { bestError = comboError; bestEntry = entry; } + } + + return { + entry: bestEntry, + errorFactor: Math.max(0, totalActualError - bestError), + affectedCount, + }; +} + +/** + * Patch the mutable layer stack so that every printer layer within each run + * slot carries the filament chosen by `entry`. Updates filamentIdx, + * filamentRgb, and td so subsequent buildColorStack calls reflect the new + * ordering. + */ +function applyComboToLayers( + layers: PrinterLayer[], + windowRuns: ColorRun[], + entry: number[], + uniqueIndices: number[], + filaments: Filament[] +): void { + for (let r = 0; r < windowRuns.length; r++) { + const run = windowRuns[r]; + const fi = uniqueIndices[entry[r]]; + const f = filaments[fi]; + const rgb = hexToRgb(f.color); + const td = f.td * FRONTLIT_TD_SCALE; + for (let i = run.startLayerIdx; i <= run.endLayerIdx; i++) { + layers[i] = { ...layers[i], filamentIdx: fi, filamentRgb: rgb, td }; + } + } +} + +// --------------------------------------------------------------------------- +// Full pipeline +// --------------------------------------------------------------------------- + +/** + * Result of the color-first full pipeline. + * + * `colorAssignments[i]` maps every input swatch hex directly to its optimal + * N-slot filament assignment for `windows[i]`. No LUT indirection needed. + * + * Renderer lookup: + * windows[i].filamentIds[ colorAssignments[i].get(hex)![slotIndex] ] + * → filament ID for a pixel of color `hex` at run slot `slotIndex` in window i. + * + * Colors absent from the map fall below that window's start layer. + */ +export interface ColorFirstResult { + /** Windows selected by the iterative consensus loop, in application order. */ + windows: WindowResult[]; + /** + * One map per selected window. Key: swatch hex string. + * Value: number[] of length N — the optimal filament index (into + * windows[i].filamentIds) for each run slot. + */ + colorAssignments: Map[]; + /** Number of unique printer layers the input swatches collapsed to. */ + uniqueLayerCount: number; + /** + * The full printer-layer stack after all window reorderings have been + * applied. Each entry's filamentIdx, filamentRgb, and td reflect the + * consensus-optimal assignment chosen by the iterative loop. + * + * Use this as the source of truth for the swap plan and 3MF export. + * Empty when no windows were applied. + */ + patchedLayers: PrinterLayer[]; + /** + * Per image-colour filament-per-layer sequence (length = patchedLayers.length). + * colorLayerFilaments.get(hex)[layerIdx] is the palette filament index a pixel + * of colour `hex` uses at that printer layer. Outside selected windows every + * colour shares the global (consensus) stack; inside a window each colour uses + * its own optimal assignment. This is what lets the renderer mix filaments + * across pixels within a single height band. Empty when no windows applied. + */ + colorLayerFilaments: Map; + /** + * Consensus filament ID per run slot per window (length = windows.length). + * windowRunFilaments[w][r] is the filament ID that run slot r carries in + * window w, derived from the consensus LUT entry selected during analysis. + * Used by optimizeNozzleAssignments to decide which nozzle loads which filament. + */ + windowRunFilaments: string[][]; + /** + * Optimal nozzle-to-run-slot permutation per window, minimising total nozzle + * filament swaps across all window transitions. Idle nozzles (between windows) + * keep their previous filament at zero swap cost. + * + * nozzleAssignments[w][k] = run-slot index that nozzle (k+1) handles in window w. + * Empty when fewer than two windows are selected. + */ + nozzleAssignments: number[][]; + /** + * Unique filament IDs needed in non-windowed layers before the first window. + * @deprecated Use nonWindowedRanges instead. + */ + preWindowFilaments: string[]; + /** + * Realized nozzle assignments for every non-windowed layer range (pre-window, + * gaps between windows, post-window), in print order. + * Each entry covers a contiguous range of 0-indexed layers and stores the + * actual filament loaded on each head (carry-forward already applied). + */ + nonWindowedRanges: MultiHeadRangeAssignment[]; +} + +/** + * Find the permutation of run-slot-to-nozzle assignment for each window that + * minimises the total number of nozzle filament swaps across all transitions. + * + * A swap is counted whenever a nozzle changes its loaded filament between + * consecutive windows. Nozzles that are idle between windows keep their + * filament at zero swap cost — the DP state tracks all N nozzles so that a + * filament parked on an idle nozzle is reused for free if the same filament + * is needed again in a later window. + * + * @param windowRunFilaments [w][r] = filament ID for run slot r in window w + * @param N Number of physical nozzles + * @returns nozzleAssignments[w][k] = run-slot index for nozzle (k+1) in window w + */ +export function optimizeNozzleAssignments( + windowRunFilaments: string[][], + N: number +): number[][] { + const W = windowRunFilaments.length; + if (W === 0) return []; + + // nozzleAssignments[w][k] = run-slot index (0-based) assigned to nozzle k+1 in window w, + // or IDLE (-1) when the nozzle is not needed and keeps its previous filament. + const IDLE = -1; + + // Generate all K-permutations (injections) of K run slots into N nozzle positions. + // result[k] = run-slot assigned to nozzle k, or IDLE. + // For each of the K slots, exactly one nozzle is chosen; the rest are IDLE. + function generateInjections(K: number): number[][] { + const result: number[][] = []; + const assignment = new Array(N).fill(IDLE); + const used = new Array(N).fill(false); + function bt(slot: number) { + if (slot === K) { result.push(assignment.slice()); return; } + for (let k = 0; k < N; k++) { + if (!used[k]) { + used[k] = true; + assignment[k] = slot; + bt(slot + 1); + used[k] = false; + assignment[k] = IDLE; + } + } + } + bt(0); + return result; + } + + // Precompute injections per window (keyed by K = number of required filaments). + const injectionCache = new Map(); + const windowInjections: number[][][] = windowRunFilaments.map((runs) => { + const K = runs.length; + if (!injectionCache.has(K)) injectionCache.set(K, generateInjections(K)); + return injectionCache.get(K)!; + }); + + // DP state: N-tuple of loaded filament IDs (EMPTY sentinel for never-loaded nozzles). + const EMPTY = '\x01'; + const encodeState = (s: string[]) => s.join('\x00'); + + interface DPEntry { cost: number; injIdx: number; prevKey: string } + + // Window 0: idle nozzles start empty (no filament loaded yet). + let dp = new Map(); + const w0Inj = windowInjections[0]; + const w0Runs = windowRunFilaments[0]; + for (let ii = 0; ii < w0Inj.length; ii++) { + const state = w0Inj[ii].map((r) => r === IDLE ? EMPTY : w0Runs[r]); + const key = encodeState(state); + // Every nozzle that starts non-empty counts as one initial load (cost 0 — first window + // always requires loading; we only optimise swaps *between* windows). + if (!dp.has(key)) dp.set(key, { cost: 0, injIdx: ii, prevKey: '' }); + } + + const history: Map[] = [new Map(dp)]; + + for (let w = 1; w < W; w++) { + const next = new Map(); + const injs = windowInjections[w]; + const runs = windowRunFilaments[w]; + + for (const [prevKey, prevEntry] of dp) { + const prevState = prevKey.split('\x00'); + + for (let ii = 0; ii < injs.length; ii++) { + const inj = injs[ii]; + // Active nozzles take their new filament; idle nozzles keep the previous one. + const newState = inj.map((r, k) => r === IDLE ? prevState[k] : runs[r]); + let swaps = 0; + for (let k = 0; k < N; k++) { + if (newState[k] !== prevState[k]) swaps++; + } + const cost = prevEntry.cost + swaps; + const key = encodeState(newState); + const existing = next.get(key); + if (!existing || cost < existing.cost) { + next.set(key, { cost, injIdx: ii, prevKey }); + } + } + } + + dp = next; + history.push(new Map(dp)); + } + + // Find minimum-cost final state. + let bestKey = ''; + let bestCost = Infinity; + for (const [key, entry] of dp) { + if (entry.cost < bestCost) { bestCost = entry.cost; bestKey = key; } + } + + // Backtrack to recover injection sequence. + const result: number[][] = new Array(W); + let key = bestKey; + for (let w = W - 1; w >= 0; w--) { + const entry = history[w].get(key)!; + result[w] = windowInjections[w][entry.injIdx]; + key = entry.prevKey; + } + + console.group(`[optimizeNozzleAssignments] ${W} windows × ${N} nozzles → min swaps: ${bestCost}`); + for (let w = 0; w < W; w++) { + const runs = windowRunFilaments[w]; + const assgn = result[w]; + console.log( + ` w${w}: ` + + assgn.map((r, k) => + `N${k+1}→${r === -1 ? 'idle' : (runs[r] ?? '?')}` + ).join(' ') + + ` (${assgn.filter(r => r !== -1).length} active)` + ); + } + // Show what each nozzle carries at each window (including idle carry-forward). + let state = new Array(N).fill('\x01'); + console.log(' Nozzle state trace:'); + for (let w = 0; w < W; w++) { + const runs = windowRunFilaments[w]; + const assgn = result[w]; + const newState = assgn.map((r, k) => r === -1 ? state[k] : (runs[r] ?? '?')); + const changes = newState.filter((fid, k) => fid !== state[k]).length; + console.log(` w${w}: [${newState.join(' | ')}] (+${changes} swaps)`); + state = newState; + } + console.groupEnd(); + return result; +} + +/** + * Full color-first analysis pipeline — analogue of runMultiHeadLayerAnalysis. + * + * Instead of a per-swatch pixelOptimalLUTIdx array, the result carries one + * Map per selected window so any downstream renderer can resolve + * a pixel color directly to the optimal filament sequence without needing to + * maintain a swatch-index mapping. + */ +export function runMultiHeadLayerAnalysisColorFirst( + filaments: Filament[], + result: AutoPaintResult, + imageSwatches: Array<{ hex: string; count?: number }>, + layerHeight: number, + firstLayerHeight: number, + n: number +): ColorFirstResult { + const N = Math.min(n, filaments.length); + const empty: ColorFirstResult = { windows: [], colorAssignments: [], uniqueLayerCount: 0, patchedLayers: [], colorLayerFilaments: new Map(), windowRunFilaments: [], nozzleAssignments: [], preWindowFilaments: [], nonWindowedRanges: [] }; + + if (N < 2 || result.transitionZones.length === 0 || imageSwatches.length === 0) { + console.log('[MultiHead ColorFirst] Insufficient data (need ≥2 filaments and image swatches).'); + return empty; + } + + // Mutable layer stack — patched in-place each iteration as windows are applied. + const layers = expandZonesToPrinterLayers(result, filaments, layerHeight, firstLayerHeight); + if (layers.length < N + 1) { + console.log(`[MultiHead ColorFirst] Not enough printer layers for window size N=${N}.`); + return empty; + } + + // Run boundaries are fixed for the life of the analysis; only filament + // assignments within runs change as windows are applied. + const runs = buildColorRuns(layers); + + // Color groups are fixed (derived from image content, not the stack). + const initialColorAtLayer = buildColorStack(layers); + const pixels = buildPixelDataColorFirst( + imageSwatches, layers, initialColorAtLayer, + result.transitionZones, result.totalHeight, firstLayerHeight + ); + + const layerIdxToGroupIdx = new Map(pixels.map((p, i) => [p.layerIdx, i])); + const hexToGroupIdx = new Map(); + for (const s of imageSwatches) { + if (hexToGroupIdx.has(s.hex)) continue; + const rgb = hexToRgb(s.hex); + const lum = getLuminance(rgb) / 255; + const h = luminanceToHeight(lum, result.transitionZones, result.totalHeight, firstLayerHeight); + const layerIdx = findLayerIdxAtHeight(layers, h); + const groupIdx = layerIdxToGroupIdx.get(layerIdx); + if (groupIdx !== undefined) hexToGroupIdx.set(s.hex, groupIdx); + } + + const selectedWindows: WindowResult[] = []; + const selectedAssignments: Map[] = []; + // Per selected window: the run decomposition + unique filament indices, used + // after the loop to build the per-colour filament-per-layer map. + const windowRunInfo: { runs: ColorRun[]; uniqueIndices: number[] }[] = []; + // Consensus LUT entry per selected window (bestEntry[r] = index into uniqueIndices). + const windowBestEntries: number[][] = []; + // Layers already claimed by a selected window — windows must not overlap so + // that each layer has exactly one per-colour assignment source. + const layerUsed: boolean[] = new Array(layers.length).fill(false); + const heads = filaments.slice(0, N).map((f, i) => `[${i}] ${f.name ?? f.color}`).join(' '); + const MIN_IMPROVEMENT = 1e-4; + // Upper bound: at most floor(runs/N) non-overlapping windows. + const maxIter = Math.floor(runs.length / N); + + console.group( + `[MultiHead ColorFirst] N=${N} heads | ${pixels.length} color groups` + + ` (from ${imageSwatches.length} swatches) | iterative` + ); + console.log(` Heads: ${heads}`); + + for (let iter = 0; iter < maxIter; iter++) { + // Rebuild the blended color stack from the (possibly patched) layers. + const colorAtLayer = buildColorStack(layers); + + // Refresh each pixel's actual error against the current stack. + for (let pxIdx = 0; pxIdx < pixels.length; pxIdx++) { + pixels[pxIdx].actualErr = deltaE(pixels[pxIdx].targetRgb, colorAtLayer[pixels[pxIdx].layerIdx]); + } + + // Scan every candidate N-run window and find the one whose consensus + // optimal ordering yields the greatest aggregate improvement. + let bestWindowRuns: ColorRun[] | null = null; + let bestUniqueIndices: number[] | null = null; + let bestEntry: number[] | null = null; + let bestErrorFactor = MIN_IMPROVEMENT; + let bestAffectedCount = 0; + + for (let rStart = 0; rStart + N <= runs.length; rStart++) { + const windowRuns = runs.slice(rStart, rStart + N); + const wStart = windowRuns[0].startLayerIdx; + if (wStart === 0) continue; // skip foundation + + const wEnd = windowRuns[N - 1].endLayerIdx; + + // Skip windows overlapping a layer already claimed by a prior window. + let overlaps = false; + for (let i = wStart; i <= wEnd; i++) { + if (layerUsed[i]) { overlaps = true; break; } + } + if (overlaps) continue; + + // Read current filament assignment from the (possibly patched) layers. + const uniqueIndices = [...new Set( + windowRuns.map((r) => layers[r.startLayerIdx].filamentIdx) + )]; + const windowFilaments: WindowFilament[] = uniqueIndices.map((fi) => ({ + rgb: hexToRgb(filaments[fi]?.color ?? '#000000'), + td: (filaments[fi]?.td ?? 0.5) * FRONTLIT_TD_SCALE, + })); + + const { entry, errorFactor, affectedCount } = findConsensusCombo( + windowRuns, wEnd, layers, colorAtLayer[wStart - 1], windowFilaments, pixels + ); + + if (errorFactor > bestErrorFactor) { + bestWindowRuns = windowRuns; + bestUniqueIndices = uniqueIndices; + bestEntry = entry; + bestErrorFactor = errorFactor; + bestAffectedCount = affectedCount; + } + } + + if (!bestWindowRuns || !bestEntry || !bestUniqueIndices) break; + + const wStart = bestWindowRuns[0].startLayerIdx; + const wEnd = bestWindowRuns[N - 1].endLayerIdx; + + const w: WindowResult = { + windowStart: wStart, + windowEnd: wEnd, + windowBottomZ: layers[wStart].startZ, + windowTopZ: layers[wEnd].startZ + layers[wEnd].thickness, + currentFilaments: bestUniqueIndices.map( + (fi) => filaments[fi]?.name ?? filaments[fi]?.color ?? `f${fi}` + ), + filamentIds: bestUniqueIndices.map((fi) => filaments[fi]?.id ?? `f${fi}`), + affectedSwatches: bestAffectedCount, + errorFactor: bestErrorFactor, + lut: [], + pixelOptimalLUTIdx: [], + }; + + // Per-color optimal assignments for this window, computed against the + // current stack (below this window already reflects prior iterations). + // Window *selection* used the consensus combo, but each colour now gets + // its OWN optimal filament-per-run-slot so the render can mix filaments + // across pixels within the same height band. + const bestWindowFilaments: WindowFilament[] = bestUniqueIndices.map((fi) => ({ + rgb: hexToRgb(filaments[fi]?.color ?? '#000000'), + td: (filaments[fi]?.td ?? 0.5) * FRONTLIT_TD_SCALE, + })); + const { assignments } = computeColorOptimalAssignments( + bestWindowRuns, wEnd, layers, colorAtLayer[wStart - 1], bestWindowFilaments, pixels + ); + + const colorMap = new Map(); + for (const [hex, groupIdx] of hexToGroupIdx) { + const assignment = assignments[groupIdx]; + if (assignment) colorMap.set(hex, assignment); + } + + // Patch the layer stack with the consensus ordering so the next + // iteration's base (and any non-window layers) stay a single realisable + // stack for window selection. + applyComboToLayers(layers, bestWindowRuns, bestEntry, bestUniqueIndices, filaments); + + // Claim this window's layers so later iterations cannot overlap them. + for (let i = wStart; i <= wEnd; i++) layerUsed[i] = true; + + selectedWindows.push(w); + selectedAssignments.push(colorMap); + windowRunInfo.push({ runs: bestWindowRuns, uniqueIndices: bestUniqueIndices }); + windowBestEntries.push(bestEntry); + + console.log( + ` ★ iter ${iter + 1} W[${String(wStart).padStart(3)}–${String(wEnd).padStart(3)}]` + + ` Z: ${layers[wStart].startZ.toFixed(3)}–${(layers[wEnd].startZ + layers[wEnd].thickness).toFixed(3)} mm` + + ` | [${w.currentFilaments.join(' → ')}]` + + ` | errorFactor: ${bestErrorFactor.toFixed(4)}` + + ` | colors: ${colorMap.size}` + ); + } + + console.log(` Total: ${selectedWindows.length} window(s) applied.`); + console.groupEnd(); + + // Build the per-colour filament-per-layer map the renderer consumes. + // Default every colour to the global (consensus) stack, then overwrite the + // layers of each non-overlapping window with that colour's own assignment. + const globalSeq = layers.map((l) => l.filamentIdx); + const colorLayerFilaments = new Map(); + for (const hex of hexToGroupIdx.keys()) { + const seq = globalSeq.slice(); + for (let wi = 0; wi < selectedWindows.length; wi++) { + const slots = selectedAssignments[wi].get(hex); + if (!slots) continue; + const { runs: wRuns, uniqueIndices } = windowRunInfo[wi]; + for (let r = 0; r < wRuns.length; r++) { + const filamentIdx = uniqueIndices[slots[r]]; + for (let i = wRuns[r].startLayerIdx; i <= wRuns[r].endLayerIdx; i++) { + seq[i] = filamentIdx; + } + } + } + colorLayerFilaments.set(hex, seq); + } + + // One unique filament ID per window — used by the nozzle optimizer. + // uniqueIndices already holds the deduplicated set of filament indices for the + // window; bestEntry maps run slots onto those indices but a single index can + // appear for multiple slots. The optimizer assigns one nozzle per unique + // filament (not one per run slot), so we use uniqueIndices directly. + const windowRunFilaments: string[][] = windowRunInfo.map(({ uniqueIndices }) => + uniqueIndices.map((fi) => filaments[fi]?.id ?? `f${fi}`) + ); + + // Build the full print-order event list: real windows interleaved with virtual + // events for every non-windowed range (pre-window, gaps, post-window). + // Each event carries the unique filament IDs needed in its layer range. + // Non-windowed layers are single-filament (all pixels use globalSeq[l]), + // so we collect unique filament IDs per range directly from globalSeq. + interface PrintEvent { + type: 'window' | 'virtual'; + selIdx?: number; // selection-order window index (for 'window' type) + rangeStart: number; + rangeEnd: number; + filaments: string[]; // unique IDs needed (optimizer input slots) + } + + const sortedByStart = Array.from({ length: selectedWindows.length }, (_, i) => i) + .sort((a, b) => selectedWindows[a].windowStart - selectedWindows[b].windowStart); + + // Add virtual events for a layer range, splitting into ≤N sub-ranges if needed. + const allPrintEvents: PrintEvent[] = []; + const addVirtualRange = (rangeStart: number, rangeEnd: number) => { + let subStart = rangeStart; + const curFils: string[] = []; + const inCur = new Set(); + for (let l = rangeStart; l <= rangeEnd; l++) { + const fi = globalSeq[l]; + if (fi < 0 || fi >= filaments.length) continue; + const fid = filaments[fi].id; + if (!inCur.has(fid)) { + if (inCur.size >= N) { + allPrintEvents.push({ type: 'virtual', rangeStart: subStart, rangeEnd: l - 1, filaments: [...curFils] }); + subStart = l; + curFils.length = 0; + inCur.clear(); + } + curFils.push(fid); + inCur.add(fid); + } + } + if (curFils.length > 0) { + allPrintEvents.push({ type: 'virtual', rangeStart: subStart, rangeEnd: rangeEnd, filaments: [...curFils] }); + } + }; + + // Pre-window range + if (sortedByStart.length > 0 && selectedWindows[sortedByStart[0]].windowStart > 0) { + addVirtualRange(0, selectedWindows[sortedByStart[0]].windowStart - 1); + } + // Real windows interleaved with gap virtual ranges + for (let ri = 0; ri < sortedByStart.length; ri++) { + const selIdx = sortedByStart[ri]; + const w = selectedWindows[selIdx]; + allPrintEvents.push({ type: 'window', selIdx, rangeStart: w.windowStart, rangeEnd: w.windowEnd, filaments: windowRunFilaments[selIdx] }); + if (ri + 1 < sortedByStart.length) { + const gapStart = w.windowEnd + 1; + const gapEnd = selectedWindows[sortedByStart[ri + 1]].windowStart - 1; + if (gapStart <= gapEnd) addVirtualRange(gapStart, gapEnd); + } + } + // Post-window range + if (sortedByStart.length > 0) { + const lastW = selectedWindows[sortedByStart[sortedByStart.length - 1]]; + if (lastW.windowEnd + 1 < layers.length) { + addVirtualRange(lastW.windowEnd + 1, layers.length - 1); + } + } + + // Run the optimizer over ALL events (real + virtual) in print order. + const allAssignments = optimizeNozzleAssignments( + allPrintEvents.map(e => e.filaments), + N + ); + + // Map optimizer results back: + // - real windows → nozzleAssignments[selIdx] (injection, for useSwapPlan) + // - virtual ranges → nonWindowedRanges with realized head state + const nozzleAssignments: number[][] = new Array(selectedWindows.length); + const nonWindowedRanges: MultiHeadRangeAssignment[] = []; + let headState = new Array(N).fill(''); + + for (let ei = 0; ei < allPrintEvents.length; ei++) { + const event = allPrintEvents[ei]; + const inj = allAssignments[ei]; + const runs = event.filaments; + // Apply injection: active heads get new filament; IDLE (-1) keeps previous. + headState = inj.map((r, k) => r === -1 ? headState[k] : (runs[r] ?? '')); + if (event.type === 'window') { + nozzleAssignments[event.selIdx!] = inj; + } else { + nonWindowedRanges.push({ + rangeStart: event.rangeStart, + rangeEnd: event.rangeEnd, + nozzleFilaments: [...headState], + }); + } + } + + // Backward-compat: preWindowFilaments from the first non-windowed range (if any). + const preWindowFilaments = nonWindowedRanges.length > 0 && nonWindowedRanges[0].rangeStart === 0 + ? nonWindowedRanges[0].nozzleFilaments.filter(Boolean) + : []; + + // Debug: show what filaments each window/range needs and what the optimizer chose. + console.group('[MultiHead] full print-order schedule'); + for (const evt of allPrintEvents) { + if (evt.type === 'window') { + const selIdx = evt.selIdx!; + const w = selectedWindows[selIdx]; + const { uniqueIndices } = windowRunInfo[selIdx]; + const runs = windowRunFilaments[selIdx]; + const assgn = nozzleAssignments[selIdx] ?? []; + console.log( + ` WIN W[${w.windowStart}–${w.windowEnd}]` + + ` Z:${w.windowBottomZ.toFixed(3)}–${w.windowTopZ.toFixed(3)} mm` + + ` filaments: [${runs.map((id, r) => { + const hex = filaments.find(f => f.id === id)?.color ?? id; + return `${hex}(idx${uniqueIndices[r]})`; + }).join(', ')}]` + ); + console.log( + ` nozzle assignments: ` + + assgn.map((r, k) => { + const fid = r === -1 ? 'idle' : (runs[r] ?? '?'); + const hex = r === -1 ? '-' : (filaments.find(f => f.id === fid)?.color ?? fid); + return `N${k+1}→${r === -1 ? 'idle' : hex}`; + }).join(' ') + ); + } else { + const nr = nonWindowedRanges.find(r => r.rangeStart === evt.rangeStart); + console.log( + ` VIRT L[${evt.rangeStart}–${evt.rangeEnd}]` + + ` filaments: [${evt.filaments.map(id => filaments.find(f => f.id === id)?.color ?? id).join(', ')}]` + ); + if (nr) { + console.log( + ` realized: ` + + nr.nozzleFilaments.map((fid, k) => { + const hex = fid ? (filaments.find(f => f.id === fid)?.color ?? fid) : 'idle'; + return `N${k+1}→${hex}`; + }).join(' ') + ); + } + } + } + console.groupEnd(); + + return { + windows: selectedWindows, + colorAssignments: selectedAssignments, + uniqueLayerCount: pixels.length, + patchedLayers: layers.slice(), + colorLayerFilaments, + windowRunFilaments, + nozzleAssignments, + preWindowFilaments, + nonWindowedRanges, + }; +} diff --git a/src/lib/multiHeadSchedule.ts b/src/lib/multiHeadSchedule.ts new file mode 100644 index 0000000..96151b0 --- /dev/null +++ b/src/lib/multiHeadSchedule.ts @@ -0,0 +1,119 @@ +import type { Filament, MultiHeadRangeAssignment } from '../types'; +import type { WindowResult } from './multiHeadAnalysis'; + +/** One nozzle's assignment at a schedule event. */ +export interface MultiHeadNozzleEntry { + nozzle: number; // 1-based + filamentHex: string; + filamentId: string; + /** True when this nozzle's filament differs from the previous event (requires a physical swap). */ + changed: boolean; +} + +/** A single event in the head-load schedule: either the initial load or a swap checkpoint. */ +export interface MultiHeadScheduleEvent { + /** 1-based printer layer number where this event occurs (0 = before print starts). */ + startLayer: number; + /** State of every nozzle at this event. */ + nozzles: MultiHeadNozzleEntry[]; + /** Number of nozzles that change filament at this event. */ + swapCount: number; + /** True for the synthetic "before print" event that shows the initial head setup. */ + isPrePrint?: boolean; +} + +export interface BuildMultiHeadScheduleParams { + multiHeadWindows?: WindowResult[]; + nozzleAssignments?: number[][]; + windowRunFilaments?: string[][]; + nonWindowedRanges?: MultiHeadRangeAssignment[]; + filaments?: Filament[]; +} + +/** + * Build the per-checkpoint head-load schedule for multi-head mode, ordered by layer. + * + * Each window/non-windowed range starts a "phase" with a specific filament loaded on + * each nozzle. Where a nozzle's filament differs from the previous phase, the operator + * must physically swap it — those checkpoints (swapCount > 0) are the layers where the + * print must pause. The result is consumed both by the on-screen Head Schedule and by + * the 3MF exporter (to emit pause/`M600` markers at swap layers). + */ +export function buildMultiHeadSchedule({ + multiHeadWindows, + nozzleAssignments, + windowRunFilaments, + nonWindowedRanges, + filaments, +}: BuildMultiHeadScheduleParams): MultiHeadScheduleEvent[] | null { + if ( + !multiHeadWindows?.length || + !nozzleAssignments?.length || + !windowRunFilaments?.length || + !filaments?.length + ) + return null; + + const hexById = new Map(); + for (const f of filaments) hexById.set(f.id, f.color); + + // Build a unified sorted list of all print-order events: real windows + + // non-windowed ranges (pre-window, gaps, post-window). + type RawEvent = + | { kind: 'window'; startLayer0: number; w: number } + | { kind: 'range'; startLayer0: number; range: MultiHeadRangeAssignment }; + + const rawEvents: RawEvent[] = []; + + for (let w = 0; w < multiHeadWindows.length; w++) { + rawEvents.push({ kind: 'window', startLayer0: multiHeadWindows[w].windowStart, w }); + } + if (nonWindowedRanges) { + for (const range of nonWindowedRanges) { + rawEvents.push({ kind: 'range', startLayer0: range.rangeStart, range }); + } + } + + rawEvents.sort((a, b) => a.startLayer0 - b.startLayer0); + if (rawEvents.length === 0) return null; + + const events: MultiHeadScheduleEvent[] = []; + let loadedIds: string[] = []; + let isFirst = true; + + for (const raw of rawEvents) { + let newLoadedIds: string[]; + + if (raw.kind === 'window') { + const assgn = nozzleAssignments[raw.w] ?? []; + const runs = windowRunFilaments[raw.w] ?? []; + if (loadedIds.length === 0) loadedIds = new Array(assgn.length).fill(''); + newLoadedIds = assgn.map((r, k) => (r === -1 ? loadedIds[k] : (runs[r] ?? ''))); + } else { + newLoadedIds = raw.range.nozzleFilaments.slice(); + if (loadedIds.length === 0) loadedIds = new Array(newLoadedIds.length).fill(''); + } + + const nozzles: MultiHeadNozzleEntry[] = []; + let swapCount = 0; + for (let k = 0; k < newLoadedIds.length; k++) { + const fid = newLoadedIds[k]; + const hex = hexById.get(fid) ?? '#888888'; + const changed = !isFirst && fid !== loadedIds[k]; + if (changed) swapCount++; + nozzles.push({ nozzle: k + 1, filamentHex: hex, filamentId: fid, changed }); + } + + events.push({ + startLayer: isFirst ? 0 : raw.startLayer0 + 1, + nozzles, + swapCount, + isPrePrint: isFirst, + }); + + loadedIds = newLoadedIds; + isFirst = false; + } + + return events.length > 0 ? events : null; +} diff --git a/src/lib/multiHeadSpatialVariance.ts b/src/lib/multiHeadSpatialVariance.ts new file mode 100644 index 0000000..3915881 --- /dev/null +++ b/src/lib/multiHeadSpatialVariance.ts @@ -0,0 +1,357 @@ +/** + * Multi-head spatial-variance minimization optimizer. + * + * Groups K image colours into M = ⌈K/N⌉ phases (bands), each holding up to N + * colours. All pixels whose colour is in phase j print at the same height, + * collapsing the height map from K distinct levels to M. The reduction follows + * the plan in spatial-variance-plan.txt §§3.1–3.3. + * + * Spectral-ordering proxy: colours are sorted by luminance. For natural images + * luminance is strongly correlated with spatial adjacency (the adjacency graph + * Laplacian's Fiedler vector), so luminance rank is a fast, accurate stand-in + * for the full spectral sort. + * + * Output shape: identical to ColorFirstResult so it drops straight into the + * patchedLayersToPlan / buildPerColorLayerColors render path unchanged. + */ + +import type { AutoPaintResult } from './autoPaint.ts'; +import type { Filament } from '../types/index.ts'; +import { hexToRgb, getLuminance, deltaE, type RGB } from './autoPaint.ts'; +import type { PrinterLayer, WindowResult } from './multiHeadAnalysis.ts'; +import type { ColorFirstResult } from './multiHeadAnalysisColorFirst.ts'; +import { optimizeNozzleAssignments } from './multiHeadAnalysisColorFirst.ts'; + +const FRONTLIT_TD_SCALE = 0.1; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface SpatialVarianceResult extends ColorFirstResult { + /** + * Total height of the spatial-variance model in mm. + * = max(layerHeight, firstLayerHeight) + (M−1) × layerHeight + * Pass this as autoPaintTotalHeight to ThreeDView so the standard + * luminance → height mapping naturally quantises to exactly M levels. + */ + spatialVarianceTotalHeight: number; + /** Phase index [0, M) for each image-palette hex. */ + phaseOf: Map; + /** Number of phases M = ⌈K/N⌉. */ + phaseCount: number; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Lab ΔE to the nearest filament; returns that filament's index. */ +function nearestFilamentIndex(target: RGB, filaments: Filament[]): number { + let best = 0; + let bestDE = Infinity; + for (let i = 0; i < filaments.length; i++) { + const d = deltaE(target, hexToRgb(filaments[i].color)); + if (d < bestDE) { bestDE = d; best = i; } + } + return best; +} + +/** RGB centroid of a non-empty colour list. */ +function centroidRgb(rgbs: RGB[]): RGB { + const n = rgbs.length; + return { + r: rgbs.reduce((s, c) => s + c.r, 0) / n, + g: rgbs.reduce((s, c) => s + c.g, 0) / n, + b: rgbs.reduce((s, c) => s + c.b, 0) / n, + }; +} + +// --------------------------------------------------------------------------- +// Variance proxy (plan §3.2) — used by local-search refinement +// --------------------------------------------------------------------------- + +/** + * Compute Σ_{A, + bands: number[] +): number { + const K = colors.length; + let s = 0; + for (let a = 0; a < K; a++) { + for (let b = a + 1; b < K; b++) { + const lumDist = Math.abs(colors[a].lum - colors[b].lum); + const w = 1 / (lumDist * lumDist + 1e-4); + const d = bands[a] - bands[b]; + s += w * d * d; + } + } + return s; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Spatial-variance multi-head optimizer — drop-in analogue of + * runMultiHeadLayerAnalysisColorFirst for the spatial-variance objective. + * + * @param filaments Available filament set (N heads loaded simultaneously). + * @param _result AutoPaintResult (signature parity; not used by this path). + * @param imageSwatches Unique image-palette entries from the quantised image. + * @param layerHeight Printer layer height in mm. + * @param firstLayerHeight Slicer first-layer height in mm. + * @param n Number of print heads (≥ 1). + */ +export function runMultiHeadSpatialVarianceOptimization( + filaments: Filament[], + _result: AutoPaintResult, + imageSwatches: Array<{ hex: string; count?: number }>, + layerHeight: number, + firstLayerHeight: number, + n: number +): SpatialVarianceResult { + const empty: SpatialVarianceResult = { + windows: [], + colorAssignments: [], + uniqueLayerCount: 0, + patchedLayers: [], + colorLayerFilaments: new Map(), + windowRunFilaments: [], + nozzleAssignments: [], + preWindowFilaments: [], + nonWindowedRanges: [], + spatialVarianceTotalHeight: 0, + phaseOf: new Map(), + phaseCount: 0, + }; + + const N = Math.min(n, filaments.length); + if (N < 1 || imageSwatches.length === 0) return empty; + + // ------------------------------------------------------------------ + // 1. Deduplicate image colours and sort by luminance (spectral proxy). + // ------------------------------------------------------------------ + const seen = new Set(); + const uniqueColors: Array<{ hex: string; rgb: RGB; lum: number; count: number }> = []; + for (const s of imageSwatches) { + if (seen.has(s.hex)) continue; + seen.add(s.hex); + const rgb = hexToRgb(s.hex); + uniqueColors.push({ + hex: s.hex, + rgb, + lum: getLuminance(rgb) / 255, + count: s.count ?? 1, + }); + } + uniqueColors.sort((a, b) => a.lum - b.lum); + + const K = uniqueColors.length; + const M = Math.ceil(K / N); + + // ------------------------------------------------------------------ + // 2. Initial band assignment: consecutive N-sized groups of the + // luminance-sorted colour list → band index = floor(i / N). + // ------------------------------------------------------------------ + // Single-head baseline: every colour at its own unique height level. + const singleHeadBands: number[] = uniqueColors.map((_, i) => i); + const varSingleHead = varianceProxy(uniqueColors, singleHeadBands); + + // Initial multi-head assignment: consecutive N-sized luminance groups. + const bands: number[] = uniqueColors.map((_, i) => Math.floor(i / N)); + const varInitial = varianceProxy(uniqueColors, bands); + + // ------------------------------------------------------------------ + // 3. Local-search refinement (plan §3.3 tryMoveOrSwap). + // Swap pairs of colours between adjacent bands if the variance proxy + // strictly decreases. One sweep is usually sufficient for the + // luminance-proximity proxy (which already yields a near-optimal + // ordering); iterate until convergence. + // ------------------------------------------------------------------ + const bandSize = (b: number) => bands.filter(x => x === b).length; + let improved = true; + while (improved) { + improved = false; + for (let a = 0; a < K; a++) { + for (const target of [bands[a] - 1, bands[a] + 1]) { + if (target < 0 || target >= M) continue; + + // Try moving colour a into target band. + const origBands = bands.slice(); + const origSize = bandSize(bands[a]); + const targetSize = bandSize(target); + + if (targetSize < N) { + // Spare slot — just move. + bands[a] = target; + if (varianceProxy(uniqueColors, bands) < varianceProxy(uniqueColors, origBands)) { + improved = true; + } else { + bands[a] = origBands[a]; // revert + } + } else if (origSize > 1) { + // No spare slot; try swapping a with every colour in target band. + for (let b = 0; b < K; b++) { + if (bands[b] !== target) continue; + bands[a] = target; + bands[b] = origBands[a]; + if (varianceProxy(uniqueColors, bands) < varianceProxy(uniqueColors, origBands)) { + improved = true; + break; + } + // Revert swap. + bands[a] = origBands[a]; + bands[b] = origBands[b]; + } + } + } + } + } + + // ------------------------------------------------------------------ + // 4. Materialise phase groups from final band assignment. + // ------------------------------------------------------------------ + const phaseOf = new Map(); + const phaseColors: typeof uniqueColors[] = Array.from({ length: M }, () => []); + for (let i = 0; i < K; i++) { + phaseOf.set(uniqueColors[i].hex, bands[i]); + phaseColors[bands[i]].push(uniqueColors[i]); + } + + // ------------------------------------------------------------------ + // 5. Nearest-filament assignment for each image colour. + // ------------------------------------------------------------------ + const filamentFor = new Map(); + for (const c of uniqueColors) { + filamentFor.set(c.hex, nearestFilamentIndex(c.rgb, filaments)); + } + + // ------------------------------------------------------------------ + // 6. Build M printer layers (one per phase, each layerHeight thick). + // Layer 0 uses max(layerHeight, firstLayerHeight) to respect the + // slicer's minimum first-layer requirement. + // ------------------------------------------------------------------ + const effectiveFirstLayer = Math.max(layerHeight, firstLayerHeight); + const patchedLayers: PrinterLayer[] = []; + for (let j = 0; j < M; j++) { + const group = phaseColors[j]; + const repFilamentIdx = group.length > 0 + ? nearestFilamentIndex(centroidRgb(group.map(c => c.rgb)), filaments) + : 0; + const repFilament = filaments[repFilamentIdx]; + const thickness = j === 0 ? effectiveFirstLayer : layerHeight; + const startZ = j === 0 ? 0 : effectiveFirstLayer + (j - 1) * layerHeight; + patchedLayers.push({ + startZ, + thickness, + filamentIdx: repFilamentIdx, + filamentRgb: hexToRgb(repFilament.color), + td: repFilament.td * FRONTLIT_TD_SCALE, + }); + } + + // Total height used by ThreeDView to scale the luminance → height map. + // With this value, luminance snapped to layerHeight grid gives exactly M + // discrete levels matching the M phases. + const spatialVarianceTotalHeight = + patchedLayers[M - 1].startZ + patchedLayers[M - 1].thickness; + + // ------------------------------------------------------------------ + // 7. Per-colour filament-per-layer sequences. + // Colour C in phase j: + // layers 0 … j−1 → filament 0 (support; not visible in opaque model) + // layers j … M−1 → nearest filament for C + // ------------------------------------------------------------------ + const colorLayerFilaments = new Map(); + for (const [hex, phase] of phaseOf) { + const assigned = filamentFor.get(hex) ?? 0; + const seq = new Array(M); + for (let j = 0; j < M; j++) { + seq[j] = j < phase ? 0 : assigned; + } + colorLayerFilaments.set(hex, seq); + } + + // ------------------------------------------------------------------ + // 8. WindowResult entries (one per phase) for the swap-plan display. + // ------------------------------------------------------------------ + const windows: WindowResult[] = phaseColors.map((colors, j) => { + const uniqueFI = [...new Set(colors.map(c => filamentFor.get(c.hex) ?? 0))]; + return { + windowStart: j, + windowEnd: j, + windowBottomZ: patchedLayers[j].startZ, + windowTopZ: patchedLayers[j].startZ + patchedLayers[j].thickness, + currentFilaments: uniqueFI.map( + fi => filaments[fi]?.name ?? filaments[fi]?.color ?? `f${fi}` + ), + filamentIds: uniqueFI.map(fi => filaments[fi]?.id ?? `f${fi}`), + affectedSwatches: colors.reduce((s, c) => s + c.count, 0), + errorFactor: 0, + lut: [], + pixelOptimalLUTIdx: [], + }; + }); + + // ------------------------------------------------------------------ + // 9. Schedule: nozzle assignments across phase transitions. + // windowRunFilaments[j] = filament IDs active in phase j. + // optimizeNozzleAssignments minimises head swaps across M-1 transitions. + // ------------------------------------------------------------------ + const windowRunFilaments: string[][] = windows.map(w => w.filamentIds); + const nozzleAssignments = optimizeNozzleAssignments(windowRunFilaments, N); + // In SV mode every layer belongs to a phase — no non-windowed gaps. + const nonWindowedRanges: ColorFirstResult['nonWindowedRanges'] = []; + const preWindowFilaments: string[] = []; + + const varAfter = varianceProxy(uniqueColors, bands); + + // Reduction vs single-head baseline (the meaningful improvement). + const pctVsSingle = varSingleHead > 0 + ? ((varSingleHead - varAfter) / varSingleHead * 100).toFixed(1) + : '0.0'; + // Additional improvement from local search on top of the initial bands. + const pctLocalSearch = varInitial > 0 + ? ((varInitial - varAfter) / varInitial * 100).toFixed(1) + : '0.0'; + + console.group( + `[SpatialVariance] K=${K} colours → M=${M} phases × N=${N} heads` + + ` | totalHeight=${spatialVarianceTotalHeight.toFixed(3)} mm` + ); + console.log(` Single-head baseline (K=${K} heights): ${varSingleHead.toFixed(2)}`); + console.log(` Multi-head optimized (M=${M} heights): ${varAfter.toFixed(2)}` + + ` (${pctVsSingle}% reduction vs single-head)`); + if (pctLocalSearch !== '0.0') { + console.log(` Local-search refinement: ${pctLocalSearch}% additional improvement`); + } + console.log(` Swaps (M−1): ${M - 1} | Phase sizes: ${Array.from({ length: M }, (_, j) => + phaseColors[j].length + ).join(', ')}`); + console.groupEnd(); + + return { + windows, + colorAssignments: phaseColors.map((colors) => { + const map = new Map(); + colors.forEach((c, i) => map.set(c.hex, [i % N])); + return map; + }), + uniqueLayerCount: M, + patchedLayers, + colorLayerFilaments, + windowRunFilaments, + nozzleAssignments, + preWindowFilaments, + nonWindowedRanges, + spatialVarianceTotalHeight, + phaseOf, + phaseCount: M, + }; +} diff --git a/src/lib/patchedLayersToPlan.ts b/src/lib/patchedLayersToPlan.ts new file mode 100644 index 0000000..e3c4e26 --- /dev/null +++ b/src/lib/patchedLayersToPlan.ts @@ -0,0 +1,155 @@ +/** + * Convert a patched printer-layer stack (from ColorFirstResult.patchedLayers) + * into a TransitionZone[] that the existing swap-plan and mesh-generation + * pipeline can consume without further modification. + * + * The output has the same shape as AutoPaintResult.transitionZones, so any + * code that already iterates transition zones works unchanged. + */ + +import type { TransitionZone } from './autoPaint.ts'; +import { rgbToHex, hexToRgb } from './autoPaint.ts'; +import type { PrinterLayer } from './multiHeadAnalysis.ts'; +import { buildColorStack } from './multiHeadAnalysis.ts'; +import type { Filament } from '../types'; +import { buildColorRuns } from './multiHeadAnalysisColorFirst.ts'; + +/** Frontlit TD scaling — must match the value used in the analysis pipeline. */ +const FRONTLIT_TD_SCALE = 0.1; + +// --------------------------------------------------------------------------- +// Slice data — ThreeDView mesh generation +// --------------------------------------------------------------------------- + +/** + * The three arrays ThreeDView needs to build a layered mesh, derived from the + * patched printer-layer stack. Structurally identical to what App.tsx normally + * derives from autoPaintSliceData, so ThreeDView requires no changes. + * + * colorOrder is always the identity mapping [0, 1, …, N-1] so that + * colorSliceHeights[colorOrder[i]] == colorSliceHeights[i] == thickness of layer i. + */ +export interface PatchedSliceData { + colorOrder: number[]; + colorSliceHeights: number[]; + swatches: { hex: string; a: number }[]; +} + +/** + * Derive a PatchedSliceData triple from the patched printer-layer stack. + * + * Mirrors autoPaintToSliceHeights: one slice per *printer layer* (not per run), + * each carrying the Beer-Lambert blended colour at that layer. This preserves + * the smooth per-layer gradient of the standard auto-paint render while + * reflecting the reordered filament assignment in patchedLayers — that is what + * makes the remixed window layers visible. + * + * Layer 0's thickness already accounts for firstLayerHeight because + * expandZonesToPrinterLayers clamps it to max(firstLayerHeight, layerHeight). + * The firstLayerHeight argument is kept for signature parity with the original + * slice-height helper and to guard against a degenerate base. + */ +export function patchedLayersToSliceData( + layers: PrinterLayer[], + _filaments: Filament[], + firstLayerHeight: number +): PatchedSliceData { + if (layers.length === 0) return { colorOrder: [], colorSliceHeights: [], swatches: [] }; + + // Beer-Lambert blended colour accumulated from the opaque foundation up. + const colorAtLayer = buildColorStack(layers); + + const colorOrder: number[] = []; + const colorSliceHeights: number[] = []; + const swatches: { hex: string; a: number }[] = []; + + for (let i = 0; i < layers.length; i++) { + colorOrder.push(i); + colorSliceHeights.push( + i === 0 ? Math.max(layers[i].thickness, firstLayerHeight) : layers[i].thickness + ); + swatches.push({ hex: rgbToHex(colorAtLayer[i]), a: 255 }); + } + + return { colorOrder, colorSliceHeights, swatches }; +} + +// --------------------------------------------------------------------------- +// Per-colour layer colours — per-pixel mesh rendering +// --------------------------------------------------------------------------- + +/** + * For each image colour, compute the Beer-Lambert blended colour at every printer + * layer following that colour's own filament path (from colorLayerFilaments). + * + * Two pixels of different colour at the same height can therefore show different + * colours, which is what makes the reordered "mixed" window layers visible. + * + * @returns Map keyed by image-colour hex → array (length = layers.length) of + * blended colour hex strings, one per printer layer. + */ +export function buildPerColorLayerColors( + layers: PrinterLayer[], + colorLayerFilaments: Map, + filaments: Filament[] +): Map { + const out = new Map(); + if (layers.length === 0) return out; + + // Pre-resolve each palette filament's rgb + frontlit-scaled TD. + const palette = filaments.map((f) => ({ + rgb: hexToRgb(f.color), + td: f.td * FRONTLIT_TD_SCALE, + })); + + for (const [hex, seq] of colorLayerFilaments) { + // Build a per-colour printer-layer stack: same geometry as `layers`, but + // each layer's filament comes from this colour's path. + const colorLayers: PrinterLayer[] = layers.map((l, i) => { + const p = palette[seq[i]] ?? { rgb: l.filamentRgb, td: l.td }; + return { ...l, filamentIdx: seq[i], filamentRgb: p.rgb, td: p.td }; + }); + const stack = buildColorStack(colorLayers); + out.set(hex, stack.map(rgbToHex)); + } + + return out; +} + +/** + * Convert a (possibly reordered) printer-layer stack into a TransitionZone[]. + * + * Each contiguous run of layers sharing the same filamentIdx becomes one zone. + * Zone heights are derived from the actual layer startZ values and thicknesses + * stored in the stack, so they are exact rather than ideal floats. + * + * idealThickness and actualThickness are set to the same value — there is no + * compression step in the multi-head pipeline; the layer heights are already + * discretised to the printer's layer height. + */ +export function patchedLayersToPlan( + layers: PrinterLayer[], + filaments: Filament[] +): TransitionZone[] { + if (layers.length === 0) return []; + + const runs = buildColorRuns(layers); + + return runs.map((run) => { + const filament = filaments[run.filamentIdx]; + const startHeight = layers[run.startLayerIdx].startZ; + const lastLayer = layers[run.endLayerIdx]; + const endHeight = lastLayer.startZ + lastLayer.thickness; + const thickness = endHeight - startHeight; + + return { + filamentId: filament?.id ?? `f${run.filamentIdx}`, + filamentColor: filament?.color ?? '#000000', + filamentTd: filament?.td ?? 0, + startHeight, + endHeight, + idealThickness: thickness, + actualThickness: thickness, + }; + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index 1955e62..ab629a9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,6 @@ -import type { AutoPaintResult } from '../lib/autoPaint'; +import type { AutoPaintResult, TransitionZone } from '../lib/autoPaint'; +import type { WindowResult } from '../lib/multiHeadAnalysis'; +import type { PatchedSliceData } from '../lib/patchedLayersToPlan'; import type { CalibrationResult } from '../lib/calibration'; export type Swatch = { hex: string; a: number }; @@ -21,6 +23,19 @@ export interface Filament { brand?: string; } +/** Realized nozzle state for a non-windowed layer range (pre-window, gap, or post-window). */ +export interface MultiHeadRangeAssignment { + /** First layer index (0-indexed, inclusive). */ + rangeStart: number; + /** Last layer index (0-indexed, inclusive). */ + rangeEnd: number; + /** + * Which filament is loaded on each head during this range (length = N heads). + * nozzleFilaments[k] = filament ID for head k+1. '' = head is unused. + */ + nozzleFilaments: string[]; +} + export interface ThreeDControlsStateShape { layerHeight: number; slicerFirstLayerHeight: number; @@ -43,6 +58,49 @@ export interface ThreeDControlsStateShape { optimizerAlgorithm?: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'; optimizerSeed?: number; regionWeightingMode?: 'uniform' | 'center' | 'edge'; + // Multi-head mode (per-pixel layer order optimization) + multiHeadMode?: boolean; + multiHeadCount?: number; // 2–5 heads + multiHeadSearchDepth?: 'fast' | 'balanced' | 'thorough'; + multiHeadOptimizationMode?: 'color-accuracy' | 'spatial-variance'; + /** + * Total height override for spatial-variance mode (M * layerHeight). + * When set, ThreeDView uses this instead of autoPaintResult.totalHeight so + * the luminance → height mapping quantises to exactly M discrete levels. + */ + spatialVarianceTotalHeight?: number; + multiHeadWindows?: WindowResult[]; + /** Reordered transition zones derived from the multi-head patched layer stack. */ + patchedTransitionZones?: TransitionZone[]; + /** Slice data for ThreeDView mesh generation derived from the patched layer stack. */ + patchedSliceData?: PatchedSliceData; + /** + * Per image-colour blended colour per printer layer (keys = image palette hex). + * Drives per-pixel filament mixing in the 3D render: a pixel of colour `hex` + * shows perColorLayerColors.get(hex)[layerIdx] at each layer. + */ + perColorLayerColors?: Map; + /** + * Per image-colour filament index per printer layer (keys = image palette hex). + * colorLayerFilaments.get(hex)[layerIdx] is the global filament-array index + * that a pixel of colour `hex` uses at that layer. Used with nozzleAssignments + * to tag each sub-mesh with the physical nozzle that prints it. + */ + colorLayerFilaments?: Map; + /** + * Consensus filament ID per run slot per window. + * windowRunFilaments[w][r] is the filament ID that run slot r carries in window w. + */ + windowRunFilaments?: string[][]; + /** + * Optimal nozzle-to-run-slot permutation per window. + * nozzleAssignments[w][k] = run-slot index for nozzle (k+1) in window w. + */ + nozzleAssignments?: number[][]; + /** Filament IDs used in non-windowed layers before the first window. */ + preWindowFilaments?: string[]; + /** Nozzle assignments for non-windowed layer ranges (pre-window, gaps, post-window). */ + nonWindowedRanges?: MultiHeadRangeAssignment[]; // Auto-paint computed state (only used when paintMode is 'autopaint') autoPaintResult?: AutoPaintResult; autoPaintSwatches?: Swatch[]; diff --git a/src/workers/autoPaint.worker.ts b/src/workers/autoPaint.worker.ts index 3936f8b..deb807f 100644 --- a/src/workers/autoPaint.worker.ts +++ b/src/workers/autoPaint.worker.ts @@ -23,6 +23,8 @@ export interface AutoPaintWorkerRequest { optimizerOptions?: Partial; regionWeightingMode: 'uniform' | 'center' | 'edge'; imageDimensions?: { width: number; height: number } | null; + multiHeadMode?: boolean; + multiHeadCount?: number; } export interface AutoPaintWorkerResponse { diff --git a/tests/multiHeadAnalysis.test.ts b/tests/multiHeadAnalysis.test.ts new file mode 100644 index 0000000..dadf079 --- /dev/null +++ b/tests/multiHeadAnalysis.test.ts @@ -0,0 +1,391 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { generateAutoLayers } from '../src/lib/autoPaint.ts'; +import { + analyzeMultiHeadWindows, + selectBestWindows, + buildLUT, + buildColorStack, + runMultiHeadLayerAnalysis, + type WindowResult, + type PrinterLayer, +} from '../src/lib/multiHeadAnalysis.ts'; +import type { Filament } from '../src/types/index.ts'; + +// --------------------------------------------------------------------------- +// Shared fixture helpers +// --------------------------------------------------------------------------- + +const LAYER_HEIGHT = 0.12; +const FIRST_LAYER_HEIGHT = 0.20; + +function filament(id: string, color: string, td: number, name?: string): Filament { + return { id, color, td, name }; +} + +/** Build an AutoPaintResult for a set of filaments against some swatches. */ +function buildResult(filaments: Filament[], swatches: Array<{ hex: string }>) { + return generateAutoLayers(filaments, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); +} + +// A simple 2-colour image: mid-grey and near-white +const SWATCHES_GREY = [{ hex: '#808080' }, { hex: '#e0e0e0' }]; + +// Black and white filaments — high TD so the model is tall enough for windowed analysis +const BLACK = filament('black', '#000000', 5.0, 'Black'); +const WHITE = filament('white', '#ffffff', 5.0, 'White'); + +// Two identical filaments (same colour, same TD) +const RED_A = filament('red-a', '#cc2200', 5.0, 'RedA'); +const RED_B = filament('red-b', '#cc2200', 5.0, 'RedB'); + +// --------------------------------------------------------------------------- +// buildLUT +// --------------------------------------------------------------------------- + +test('buildLUT — generates filamentCount^windowSize entries', () => { + assert.equal(buildLUT(2, 3).length, 9); // 3^2 + assert.equal(buildLUT(3, 2).length, 8); // 2^3 + assert.equal(buildLUT(4, 4).length, 256); // 4^4 +}); + +test('buildLUT — each entry has windowSize elements in [0, filamentCount)', () => { + const lut = buildLUT(3, 4); + for (const entry of lut) { + assert.equal(entry.length, 3); + for (const idx of entry) { + assert.ok(idx >= 0 && idx < 4, `index ${idx} out of range [0, 4)`); + } + } +}); + +test('buildLUT — all combinations are unique', () => { + const lut = buildLUT(2, 3); // 3^2 = 9 entries + const strs = new Set(lut.map((e) => e.join(','))); + assert.equal(strs.size, lut.length, 'duplicate entries found'); +}); + +test('buildLUT — single filament produces one all-zero entry', () => { + const lut = buildLUT(4, 1); + assert.equal(lut.length, 1); + assert.deepEqual(lut[0], [0, 0, 0, 0]); +}); + +// --------------------------------------------------------------------------- +// buildColorStack +// --------------------------------------------------------------------------- + +const LAYER_DARK: PrinterLayer = { + filamentIdx: 0, + filamentRgb: { r: 20, g: 20, b: 20 }, + td: 0.05, + thickness: 0.20, + startZ: 0, +}; +const LAYER_LIGHT: PrinterLayer = { + filamentIdx: 1, + filamentRgb: { r: 240, g: 240, b: 240 }, + td: 0.05, + thickness: 0.12, + startZ: 0.20, +}; + +test('buildColorStack — layer 0 is the raw foundation color', () => { + const stack = buildColorStack([LAYER_DARK, LAYER_LIGHT]); + assert.deepEqual(stack[0], LAYER_DARK.filamentRgb); +}); + +test('buildColorStack — stack length equals layer count', () => { + const layers = [LAYER_DARK, LAYER_LIGHT, { ...LAYER_DARK, startZ: 0.32 }]; + assert.equal(buildColorStack(layers).length, layers.length); +}); + +test('buildColorStack — blending light layer on dark foundation brightens the stack', () => { + const stack = buildColorStack([LAYER_DARK, LAYER_LIGHT]); + assert.ok(stack[1].r > stack[0].r, 'blending light should raise red channel'); +}); + +// --------------------------------------------------------------------------- +// analyzeMultiHeadWindows +// --------------------------------------------------------------------------- + +test('analyzeMultiHeadWindows — errorFactor is never negative', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + [BLACK, WHITE], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + assert.ok(windows.length > 0, 'should produce at least one window'); + + for (const w of windows) { + assert.ok( + w.errorFactor >= -1e-9, + `window [${w.windowStart}–${w.windowEnd}] errorFactor=${w.errorFactor} is negative` + ); + } +}); + +test('analyzeMultiHeadWindows — identical filaments give errorFactor of zero', () => { + const result = buildResult([RED_A, RED_B], SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + [RED_A, RED_B], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + assert.ok(windows.length > 0, 'should produce at least one window'); + + for (const w of windows) { + assert.ok( + Math.abs(w.errorFactor) < 1e-6, + `identical filaments: window [${w.windowStart}–${w.windowEnd}] ` + + `errorFactor=${w.errorFactor} should be ~0` + ); + } +}); + +test('analyzeMultiHeadWindows — window count matches expected sliding range', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + [BLACK, WHITE], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + for (let i = 0; i < windows.length; i++) { + assert.equal(windows[i].windowStart, i + 1, `window ${i} start`); + assert.equal(windows[i].windowEnd, i + 2, `window ${i} end`); + } +}); + +test('analyzeMultiHeadWindows — affectedSwatches is non-increasing as window moves up', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + [BLACK, WHITE], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + for (let i = 1; i < windows.length; i++) { + assert.ok( + windows[i].affectedSwatches <= windows[i - 1].affectedSwatches, + `affectedSwatches should be non-increasing: ` + + `window ${i} has ${windows[i].affectedSwatches} > ` + + `window ${i - 1} has ${windows[i - 1].affectedSwatches}` + ); + } +}); + +test('analyzeMultiHeadWindows — windowBottomZ increases monotonically', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + [BLACK, WHITE], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + for (let i = 1; i < windows.length; i++) { + assert.ok( + windows[i].windowBottomZ > windows[i - 1].windowBottomZ, + `windowBottomZ not increasing at window ${i}` + ); + } +}); + +test('analyzeMultiHeadWindows — returns empty when fewer than 2 filaments', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + assert.equal( + analyzeMultiHeadWindows([BLACK], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2).length, + 0 + ); +}); + +test('analyzeMultiHeadWindows — returns empty when no swatches', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + assert.equal( + analyzeMultiHeadWindows([BLACK, WHITE], result, [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2).length, + 0 + ); +}); + +test('analyzeMultiHeadWindows — 4-head mode produces N^N=256 LUT entries', () => { + const filaments = [ + filament('f0', '#000000', 5.0), + filament('f1', '#ff0000', 5.0), + filament('f2', '#00ff00', 5.0), + filament('f3', '#ffffff', 5.0), + ]; + const swatches = [ + { hex: '#202020' }, { hex: '#606060' }, { hex: '#a0a0a0' }, { hex: '#e0e0e0' }, + ]; + const result = buildResult(filaments, swatches); + const windows = analyzeMultiHeadWindows( + filaments, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 4 + ); + + assert.ok(windows.length > 0, 'should produce windows with 4 filaments'); + for (const w of windows) { + assert.equal(w.windowEnd - w.windowStart + 1, 4, 'each window should span 4 positions'); + assert.ok(w.currentFilaments.length <= 4, 'unique filaments cannot exceed window size'); + assert.ok(w.errorFactor >= -1e-9, 'errorFactor must not be negative'); + } +}); + +test('analyzeMultiHeadWindows — currentFilaments aligns with LUT indices', () => { + const filaments = [BLACK, WHITE]; + const result = buildResult(filaments, SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + filaments, result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + const knownNames = new Set(filaments.map((f) => f.name ?? f.color)); + const N = 2; + + for (const w of windows) { + for (const entry of w.lut) { + for (const idx of entry) { + assert.ok( + idx >= 0 && idx < w.currentFilaments.length, + `LUT index ${idx} out of bounds for currentFilaments (length ${w.currentFilaments.length})` + ); + } + } + + for (let p = 0; p < SWATCHES_GREY.length; p++) { + const lutIdx = w.pixelOptimalLUTIdx[p]; + if (lutIdx === -1) continue; + for (let n = 0; n < N; n++) { + const name = w.currentFilaments[w.lut[lutIdx][n]]; + assert.ok( + knownNames.has(name), + `currentFilaments[lut[pixelOptimalLUTIdx[${p}]][${n}]] = "${name}" is not a known filament` + ); + } + } + } +}); + +test('analyzeMultiHeadWindows — filamentIds chain resolves to known filament IDs', () => { + const filaments = [BLACK, WHITE]; + const result = buildResult(filaments, SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + filaments, result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + const knownIds = new Set(filaments.map((f) => f.id)); + const N = 2; + + for (const w of windows) { + assert.ok(w.filamentIds.length <= N, 'filamentIds cannot exceed window size'); + assert.equal(w.filamentIds.length, w.currentFilaments.length, 'filamentIds and currentFilaments must be same length'); + + for (let p = 0; p < SWATCHES_GREY.length; p++) { + const lutIdx = w.pixelOptimalLUTIdx[p]; + if (lutIdx === -1) continue; + for (let n = 0; n < N; n++) { + const id = w.filamentIds[w.lut[lutIdx][n]]; + assert.ok(knownIds.has(id), `filamentIds chain resolved to unknown ID "${id}"`); + } + } + } +}); + +test('analyzeMultiHeadWindows — pixelOptimalLUTIdx contains valid indices or -1', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + const windows = analyzeMultiHeadWindows( + [BLACK, WHITE], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + + for (const w of windows) { + assert.equal(w.pixelOptimalLUTIdx.length, SWATCHES_GREY.length); + for (let p = 0; p < SWATCHES_GREY.length; p++) { + const idx = w.pixelOptimalLUTIdx[p]; + assert.ok( + idx === -1 || (idx >= 0 && idx < w.lut.length), + `pixelOptimalLUTIdx[${p}]=${idx} is invalid (lut has ${w.lut.length} entries)` + ); + } + } +}); + +// --------------------------------------------------------------------------- +// selectBestWindows +// --------------------------------------------------------------------------- + +function fakeWindow(start: number, N: number, errorFactor: number): WindowResult { + return { + windowStart: start, + windowEnd: start + N - 1, + windowBottomZ: start * 0.12, + windowTopZ: (start + N) * 0.12, + currentFilaments: ['x'], + filamentIds: ['x'], + affectedSwatches: 1, + errorFactor, + lut: [[0]], + pixelOptimalLUTIdx: [0], + }; +} + +test('selectBestWindows — selects non-overlapping windows', () => { + const N = 2; + const windows = [ + fakeWindow(1, N, 10), // W[1-2] + fakeWindow(2, N, 5), // W[2-3] — overlaps with W[1-2] and W[3-4] + fakeWindow(3, N, 8), // W[3-4] + fakeWindow(4, N, 2), // W[4-5] + fakeWindow(5, N, 15), // W[5-6] + ]; + const selected = selectBestWindows(windows, N); + // Optimal: W[1-2](10) + W[3-4](8) + W[5-6](15) = 33 + const total = selected.reduce((s, w) => s + w.errorFactor, 0); + assert.equal(total, 33, `expected total 33, got ${total}`); + assert.equal(selected.length, 3); +}); + +test('selectBestWindows — selected windows do not overlap', () => { + const N = 4; + const windows = Array.from({ length: 20 }, (_, i) => + fakeWindow(i + 1, N, Math.sin(i) * 100 + 100) + ); + const selected = selectBestWindows(windows, N); + for (let i = 1; i < selected.length; i++) { + assert.ok( + selected[i].windowStart > selected[i - 1].windowEnd, + `windows ${i - 1} and ${i} overlap` + ); + } +}); + +test('selectBestWindows — returns empty for empty input', () => { + assert.deepEqual(selectBestWindows([], 4), []); +}); + +test('selectBestWindows — single window is always selected if errorFactor > 0', () => { + const selected = selectBestWindows([fakeWindow(1, 4, 42)], 4); + assert.equal(selected.length, 1); + assert.equal(selected[0].errorFactor, 42); +}); + +// --------------------------------------------------------------------------- +// runMultiHeadLayerAnalysis +// --------------------------------------------------------------------------- + +test('runMultiHeadLayerAnalysis — returns non-overlapping selected windows', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + const selected = runMultiHeadLayerAnalysis( + [BLACK, WHITE], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.ok(Array.isArray(selected), 'should return an array'); + assert.ok(selected.length > 0, 'should select at least one window'); + for (let i = 1; i < selected.length; i++) { + assert.ok( + selected[i].windowStart > selected[i - 1].windowEnd, + `returned windows ${i - 1} and ${i} overlap` + ); + } +}); + +test('runMultiHeadLayerAnalysis — returns empty for insufficient data', () => { + const result = buildResult([BLACK, WHITE], SWATCHES_GREY); + assert.deepEqual( + runMultiHeadLayerAnalysis([BLACK], result, SWATCHES_GREY, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2), + [] + ); + assert.deepEqual( + runMultiHeadLayerAnalysis([BLACK, WHITE], result, [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2), + [] + ); +}); diff --git a/tests/multiHeadAnalysisColorFirst.test.ts b/tests/multiHeadAnalysisColorFirst.test.ts new file mode 100644 index 0000000..a1af365 --- /dev/null +++ b/tests/multiHeadAnalysisColorFirst.test.ts @@ -0,0 +1,424 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { generateAutoLayers } from '../src/lib/autoPaint.ts'; +import { + selectBestWindows, + buildColorStack, + expandZonesToPrinterLayers, + type PrinterLayer, +} from '../src/lib/multiHeadAnalysis.ts'; +import { + buildPixelDataColorFirst, + buildColorRuns, + analyzeMultiHeadWindowsColorFirst, + runMultiHeadLayerAnalysisColorFirst, +} from '../src/lib/multiHeadAnalysisColorFirst.ts'; +import type { Filament } from '../src/types/index.ts'; + +const LAYER_HEIGHT = 0.12; +const FIRST_LAYER_HEIGHT = 0.20; + +function filament(id: string, color: string, td: number, name?: string): Filament { + return { id, color, td, name }; +} + +const BLACK = filament('black', '#000000', 5.0, 'Black'); +const WHITE = filament('white', '#ffffff', 5.0, 'White'); + +// Four-filament fixture for run-based window tests. +// High TD → tall model → each zone spans several printer layers → runs are wide. +const F0 = filament('f0', '#000000', 5.0, 'VeryDark'); +const F1 = filament('f1', '#555555', 5.0, 'Dark'); +const F2 = filament('f2', '#aaaaaa', 5.0, 'Light'); +const F3 = filament('f3', '#ffffff', 5.0, 'VeryLight'); +const FOUR_FILAMENTS = [F0, F1, F2, F3]; + +function gradient(n: number, count = 1): Array<{ hex: string; count: number }> { + return Array.from({ length: n }, (_, i) => { + const v = Math.round((i / (n - 1)) * 255); + const h = v.toString(16).padStart(2, '0'); + return { hex: `#${h}${h}${h}`, count }; + }); +} + +// --------------------------------------------------------------------------- +// buildColorRuns +// --------------------------------------------------------------------------- + +test('buildColorRuns — single run when all layers share the same filament', () => { + const swatches = [{ hex: '#808080' }]; + const result = generateAutoLayers([BLACK], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, [BLACK], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const runs = buildColorRuns(layers); + assert.equal(runs.length, 1); + assert.equal(runs[0].startLayerIdx, 0); + assert.equal(runs[0].endLayerIdx, layers.length - 1); +}); + +test('buildColorRuns — runs partition all layers with no gaps', () => { + const swatches = gradient(10); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, [BLACK, WHITE], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const runs = buildColorRuns(layers); + + // Every layer index must appear in exactly one run. + let covered = 0; + for (const run of runs) { + assert.ok(run.startLayerIdx <= run.endLayerIdx, 'run must have at least one layer'); + covered += run.endLayerIdx - run.startLayerIdx + 1; + } + assert.equal(covered, layers.length, 'runs must cover every printer layer exactly once'); +}); + +test('buildColorRuns — adjacent layers with different filaments each become their own run', () => { + const swatches = gradient(10); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, [BLACK, WHITE], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const runs = buildColorRuns(layers); + + for (let i = 1; i < runs.length; i++) { + assert.notEqual( + runs[i].filamentIdx, runs[i - 1].filamentIdx, + `adjacent runs at indices ${i - 1} and ${i} should have different filaments` + ); + } +}); + +// --------------------------------------------------------------------------- +// buildPixelDataColorFirst +// --------------------------------------------------------------------------- + +test('buildPixelDataColorFirst — produces fewer entries than input swatches for a dense gradient', () => { + const swatches = gradient(200); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, [BLACK, WHITE], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const colorAtLayer = buildColorStack(layers); + + const pixels = buildPixelDataColorFirst( + swatches, layers, colorAtLayer, + result.transitionZones, result.totalHeight, FIRST_LAYER_HEIGHT + ); + + assert.ok(pixels.length < swatches.length, + `expected color-first to collapse 200 swatches into fewer entries, got ${pixels.length}`); + assert.ok(pixels.length > 0, 'must produce at least one entry'); +}); + +test('buildPixelDataColorFirst — counts sum to total input count', () => { + const swatches = gradient(200, 10); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, [BLACK, WHITE], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const colorAtLayer = buildColorStack(layers); + + const pixels = buildPixelDataColorFirst( + swatches, layers, colorAtLayer, + result.transitionZones, result.totalHeight, FIRST_LAYER_HEIGHT + ); + + const totalCount = pixels.reduce((s, p) => s + p.count, 0); + const expectedCount = swatches.reduce((s, sw) => s + sw.count, 0); + assert.equal(totalCount, expectedCount); +}); + +test('buildPixelDataColorFirst — all actualErr values are non-negative', () => { + const swatches = gradient(100); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, [BLACK, WHITE], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const colorAtLayer = buildColorStack(layers); + + const pixels = buildPixelDataColorFirst( + swatches, layers, colorAtLayer, + result.transitionZones, result.totalHeight, FIRST_LAYER_HEIGHT + ); + for (const p of pixels) { + assert.ok(p.actualErr >= 0, `actualErr=${p.actualErr} at layerIdx=${p.layerIdx}`); + } +}); + +// --------------------------------------------------------------------------- +// analyzeMultiHeadWindowsColorFirst (run-based windowing) +// --------------------------------------------------------------------------- + +test('analyzeMultiHeadWindowsColorFirst — produces at least one window', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const windows = analyzeMultiHeadWindowsColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.ok(windows.length > 0, 'should produce at least one run-based window'); +}); + +test('analyzeMultiHeadWindowsColorFirst — windows never cover the foundation layer (layer 0)', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const windows = analyzeMultiHeadWindowsColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const w of windows) { + assert.ok(w.windowStart > 0, `window starts at layer 0 (foundation): windowStart=${w.windowStart}`); + } +}); + +test('analyzeMultiHeadWindowsColorFirst — errorFactor is never negative', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const windows = analyzeMultiHeadWindowsColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const w of windows) { + assert.ok(w.errorFactor >= -1e-9, + `window [${w.windowStart}–${w.windowEnd}] errorFactor=${w.errorFactor}`); + } +}); + +test('analyzeMultiHeadWindowsColorFirst — windows span multiple layers (not just N)', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const windows = analyzeMultiHeadWindowsColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + const N = 2; + const widerThanN = windows.some((w) => w.windowEnd - w.windowStart + 1 > N); + assert.ok(widerThanN, 'at least one run-based window should span more than N=2 printer layers'); +}); + +test('analyzeMultiHeadWindowsColorFirst — returns empty for insufficient data', () => { + const result = generateAutoLayers(FOUR_FILAMENTS, gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + assert.equal( + analyzeMultiHeadWindowsColorFirst([F0], result, gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2).length, + 0 + ); + assert.equal( + analyzeMultiHeadWindowsColorFirst(FOUR_FILAMENTS, result, [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2).length, + 0 + ); +}); + +test('analyzeMultiHeadWindowsColorFirst — LUT indices in pixelOptimalLUTIdx are valid or -1', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const windows = analyzeMultiHeadWindowsColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const w of windows) { + for (const idx of w.pixelOptimalLUTIdx) { + assert.ok( + idx === -1 || (idx >= 0 && idx < w.lut.length), + `pixelOptimalLUTIdx ${idx} out of range [0, ${w.lut.length})` + ); + } + } +}); + +// --------------------------------------------------------------------------- +// runMultiHeadLayerAnalysisColorFirst +// --------------------------------------------------------------------------- + +test('runMultiHeadLayerAnalysisColorFirst — returns empty for insufficient data', () => { + const result = generateAutoLayers([BLACK, WHITE], gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const emptyShape = { + windows: [], colorAssignments: [], uniqueLayerCount: 0, patchedLayers: [], + colorLayerFilaments: new Map(), windowRunFilaments: [], nozzleAssignments: [], + preWindowFilaments: [], nonWindowedRanges: [], + }; + assert.deepEqual( + runMultiHeadLayerAnalysisColorFirst([BLACK], result, gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2), + emptyShape + ); + assert.deepEqual( + runMultiHeadLayerAnalysisColorFirst([BLACK, WHITE], result, [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2), + emptyShape + ); +}); + +test('runMultiHeadLayerAnalysisColorFirst — colorAssignments length matches windows length', () => { + const swatches = gradient(200); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { windows, colorAssignments } = runMultiHeadLayerAnalysisColorFirst( + [BLACK, WHITE], result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(colorAssignments.length, windows.length); +}); + +test('runMultiHeadLayerAnalysisColorFirst — colorAssignments only contain input hex colors', () => { + const swatches = gradient(200); + const knownHexes = new Set(swatches.map((s) => s.hex)); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorAssignments } = runMultiHeadLayerAnalysisColorFirst( + [BLACK, WHITE], result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let i = 0; i < colorAssignments.length; i++) { + for (const hex of colorAssignments[i].keys()) { + assert.ok(knownHexes.has(hex), `colorAssignments[${i}] contains unknown hex "${hex}"`); + } + } +}); + +test('runMultiHeadLayerAnalysisColorFirst — all slot indices in colorAssignments are valid', () => { + const swatches = gradient(200); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { windows, colorAssignments } = runMultiHeadLayerAnalysisColorFirst( + [BLACK, WHITE], result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let i = 0; i < windows.length; i++) { + const numFilaments = windows[i].filamentIds.length; + for (const [hex, slots] of colorAssignments[i]) { + for (let s = 0; s < slots.length; s++) { + assert.ok( + slots[s] >= 0 && slots[s] < numFilaments, + `colorAssignments[${i}].get("${hex}")[${s}] = ${slots[s]} out of range [0, ${numFilaments})` + ); + } + } + } +}); + +test('runMultiHeadLayerAnalysisColorFirst — direct lookup resolves to known filament IDs', () => { + const swatches = gradient(200); + const filaments = [BLACK, WHITE]; + const knownIds = new Set(filaments.map((f) => f.id)); + const result = generateAutoLayers(filaments, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { windows, colorAssignments } = runMultiHeadLayerAnalysisColorFirst( + filaments, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let i = 0; i < windows.length; i++) { + const w = windows[i]; + for (const [hex, slots] of colorAssignments[i]) { + for (let s = 0; s < slots.length; s++) { + const id = w.filamentIds[slots[s]]; + assert.ok(knownIds.has(id), + `window ${i}, hex "${hex}", slot ${s}: filamentId "${id}" unknown`); + } + } + } +}); + +test('runMultiHeadLayerAnalysisColorFirst — uniqueLayerCount is less than swatch count for dense gradient', () => { + const swatches = gradient(200); + const result = generateAutoLayers([BLACK, WHITE], swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { uniqueLayerCount } = runMultiHeadLayerAnalysisColorFirst( + [BLACK, WHITE], result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.ok(uniqueLayerCount < swatches.length, + `expected fewer unique layers than swatches (200), got ${uniqueLayerCount}`); +}); + +// --------------------------------------------------------------------------- +// colorLayerFilaments + non-overlapping windows +// --------------------------------------------------------------------------- + +test('runMultiHeadLayerAnalysisColorFirst — selected windows never overlap', () => { + const swatches = gradient(40); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { windows } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + const sorted = [...windows].sort((a, b) => a.windowStart - b.windowStart); + for (let i = 1; i < sorted.length; i++) { + assert.ok( + sorted[i].windowStart > sorted[i - 1].windowEnd, + `windows overlap: [${sorted[i - 1].windowStart}-${sorted[i - 1].windowEnd}] and ` + + `[${sorted[i].windowStart}-${sorted[i].windowEnd}]` + ); + } +}); + +test('runMultiHeadLayerAnalysisColorFirst — colorLayerFilaments has one entry per input colour', () => { + const swatches = gradient(40); + const knownHexes = new Set(swatches.map((s) => s.hex)); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorLayerFilaments } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const hex of colorLayerFilaments.keys()) { + assert.ok(knownHexes.has(hex), `colorLayerFilaments has unknown hex "${hex}"`); + } + assert.ok(colorLayerFilaments.size > 0, 'expected at least one colour mapping'); +}); + +test('runMultiHeadLayerAnalysisColorFirst — every colour sequence has length = patchedLayers and valid indices', () => { + const swatches = gradient(40); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorLayerFilaments, patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const [hex, seq] of colorLayerFilaments) { + assert.equal(seq.length, patchedLayers.length, `seq length mismatch for "${hex}"`); + for (const fi of seq) { + assert.ok(fi >= 0 && fi < FOUR_FILAMENTS.length, `filamentIdx ${fi} out of range for "${hex}"`); + } + } +}); + +test('runMultiHeadLayerAnalysisColorFirst — at least two colours differ somewhere in their layer sequences', () => { + const swatches = gradient(40); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorLayerFilaments, windows } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + if (windows.length === 0) return; // nothing to mix + const seqs = [...colorLayerFilaments.values()].map((s) => s.join(',')); + const distinct = new Set(seqs).size; + assert.ok(distinct > 1, 'expected per-colour variety in filament sequences, all were identical'); +}); + +// --------------------------------------------------------------------------- +// patchedLayers +// --------------------------------------------------------------------------- + +test('runMultiHeadLayerAnalysisColorFirst — patchedLayers is empty when no windows are found', () => { + const result = generateAutoLayers([BLACK, WHITE], gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + [BLACK], result, gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(patchedLayers.length, 0); +}); + +test('runMultiHeadLayerAnalysisColorFirst — patchedLayers is non-empty when windows are found', () => { + const swatches = gradient(200); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { windows, patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + if (windows.length === 0) return; // guard: no windows found, skip + assert.ok(patchedLayers.length > 0, 'patchedLayers must be non-empty when windows were applied'); +}); + +test('runMultiHeadLayerAnalysisColorFirst — patchedLayers filamentIdx values are all in range', () => { + const swatches = gradient(200); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let i = 0; i < patchedLayers.length; i++) { + assert.ok( + patchedLayers[i].filamentIdx >= 0 && patchedLayers[i].filamentIdx < FOUR_FILAMENTS.length, + `patchedLayers[${i}].filamentIdx = ${patchedLayers[i].filamentIdx} out of range` + ); + } +}); + +test('runMultiHeadLayerAnalysisColorFirst — patchedLayers length matches original layer count', () => { + const swatches = gradient(200); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + const originalLayers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + assert.equal(patchedLayers.length, originalLayers.length, + 'patchedLayers must have the same number of entries as the original layer stack'); +}); + +test('runMultiHeadLayerAnalysisColorFirst — patchedLayers startZ values are monotonically non-decreasing', () => { + const swatches = gradient(200); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let i = 1; i < patchedLayers.length; i++) { + assert.ok( + patchedLayers[i].startZ >= patchedLayers[i - 1].startZ, + `patchedLayers[${i}].startZ=${patchedLayers[i].startZ} < patchedLayers[${i-1}].startZ=${patchedLayers[i-1].startZ}` + ); + } +}); diff --git a/tests/multiHeadSpatialVariance.test.ts b/tests/multiHeadSpatialVariance.test.ts new file mode 100644 index 0000000..80980e9 --- /dev/null +++ b/tests/multiHeadSpatialVariance.test.ts @@ -0,0 +1,452 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { generateAutoLayers } from '../src/lib/autoPaint.ts'; +import { + runMultiHeadSpatialVarianceOptimization, + type SpatialVarianceResult, +} from '../src/lib/multiHeadSpatialVariance.ts'; +import type { Filament } from '../src/types/index.ts'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const LAYER_HEIGHT = 0.12; +const FIRST_LAYER_HEIGHT = 0.20; + +function filament(id: string, color: string, td: number, name?: string): Filament { + return { id, color, td, name }; +} + +const BLACK = filament('black', '#000000', 1.0, 'Black'); +const DARK = filament('dark', '#333333', 1.0, 'Dark'); +const MID = filament('mid', '#888888', 1.0, 'Mid'); +const LIGHT = filament('light', '#cccccc', 1.0, 'Light'); +const WHITE = filament('white', '#ffffff', 1.0, 'White'); + +/** Dummy AutoPaintResult produced by generating 1-colour auto-layers. */ +function dummyResult() { + return generateAutoLayers([BLACK], [{ hex: '#808080' }], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); +} + +/** Build K evenly-spaced greyscale swatches. */ +function greySwatches(K: number): Array<{ hex: string; count: number }> { + return Array.from({ length: K }, (_, i) => { + const v = K === 1 ? 128 : Math.round((i / (K - 1)) * 255); + const h = v.toString(16).padStart(2, '0'); + return { hex: `#${h}${h}${h}`, count: 1 }; + }); +} + +// --------------------------------------------------------------------------- +// Basic shape +// --------------------------------------------------------------------------- + +test('returns empty result for empty swatches', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.patchedLayers.length, 0); + assert.equal(r.spatialVarianceTotalHeight, 0); + assert.equal(r.phaseCount, 0); +}); + +test('returns empty result when no filaments', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.patchedLayers.length, 0); +}); + +// --------------------------------------------------------------------------- +// Phase count M = ⌈K/N⌉ +// --------------------------------------------------------------------------- + +test('K=1, N=2 → M=1 (one phase)', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(1), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.phaseCount, 1); + assert.equal(r.patchedLayers.length, 1); + assert.equal(r.windows.length, 1); +}); + +test('K=2, N=2 → M=1 (all colours fit in one phase)', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(2), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.phaseCount, 1); + assert.equal(r.patchedLayers.length, 1); +}); + +test('K=4, N=2 → M=2 phases', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.phaseCount, 2); + assert.equal(r.patchedLayers.length, 2); + assert.equal(r.windows.length, 2); +}); + +test('K=6, N=2 → M=3 phases', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.phaseCount, 3); + assert.equal(r.patchedLayers.length, 3); +}); + +test('K=5, N=2 → M=3 (ceil division)', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(5), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.phaseCount, 3); +}); + +test('K=4, N=4 → M=1 (all colours fit in one phase)', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, DARK, LIGHT, WHITE], dummyResult(), greySwatches(4), + LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 4 + ); + assert.equal(r.phaseCount, 1); + assert.equal(r.patchedLayers.length, 1); +}); + +// --------------------------------------------------------------------------- +// Layer heights +// --------------------------------------------------------------------------- + +test('layer 0 uses max(layerHeight, firstLayerHeight) as thickness', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + const effective = Math.max(LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + assert.equal(r.patchedLayers[0].thickness, effective); + assert.equal(r.patchedLayers[0].startZ, 0); +}); + +test('subsequent layers use layerHeight', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let j = 1; j < r.patchedLayers.length; j++) { + assert.equal(r.patchedLayers[j].thickness, LAYER_HEIGHT); + } +}); + +test('spatialVarianceTotalHeight = effectiveFirstLayer + (M-1)*layerHeight', () => { + const fourFilaments = [BLACK, DARK, LIGHT, WHITE]; + const effective = Math.max(LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + + const cases: Array<[number, number, number, Filament[]]> = [ + [4, 2, 2, [BLACK, WHITE]], + [6, 2, 3, [BLACK, WHITE]], + [4, 4, 1, fourFilaments], + ]; + + for (const [K, N, M, fils] of cases) { + const r = runMultiHeadSpatialVarianceOptimization( + fils, dummyResult(), greySwatches(K), + LAYER_HEIGHT, FIRST_LAYER_HEIGHT, N + ); + const expected = effective + (M - 1) * LAYER_HEIGHT; + assert.ok( + Math.abs(r.spatialVarianceTotalHeight - expected) < 1e-9, + `K=${K},N=${N}: expected ${expected}, got ${r.spatialVarianceTotalHeight}` + ); + } +}); + +// --------------------------------------------------------------------------- +// Phase assignment — darkest colours go to lowest phase +// --------------------------------------------------------------------------- + +test('K=4, N=2: two darkest colours in phase 0, two lightest in phase 1', () => { + // Swatches sorted darkest→lightest: #000000, #555555, #aaaaaa, #ffffff + const swatches = [ + { hex: '#000000', count: 1 }, + { hex: '#555555', count: 1 }, + { hex: '#aaaaaa', count: 1 }, + { hex: '#ffffff', count: 1 }, + ]; + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.phaseOf.get('#000000'), 0); + assert.equal(r.phaseOf.get('#555555'), 0); + assert.equal(r.phaseOf.get('#aaaaaa'), 1); + assert.equal(r.phaseOf.get('#ffffff'), 1); +}); + +// --------------------------------------------------------------------------- +// colorLayerFilaments +// --------------------------------------------------------------------------- + +test('every image colour has a sequence of length M', () => { + const swatches = greySwatches(6); + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const [, seq] of r.colorLayerFilaments) { + assert.equal(seq.length, r.phaseCount); + } +}); + +test('all K image colours have an entry in colorLayerFilaments', () => { + const swatches = greySwatches(6); + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const s of swatches) { + assert.ok( + r.colorLayerFilaments.has(s.hex), + `Missing colorLayerFilaments entry for ${s.hex}` + ); + } +}); + +test('phase-0 colours use assigned filament at all M layers', () => { + // With K=4, N=2, phase-0 colours are the two darkest. + const swatches = [ + { hex: '#000000', count: 1 }, // phase 0 + { hex: '#555555', count: 1 }, // phase 0 + { hex: '#aaaaaa', count: 1 }, // phase 1 + { hex: '#ffffff', count: 1 }, // phase 1 + ]; + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ) as SpatialVarianceResult; + + // Phase-0 colours: all layers use the assigned filament (no support layers below them). + for (const hex of ['#000000', '#555555']) { + const seq = r.colorLayerFilaments.get(hex)!; + assert.ok(seq, `Missing sequence for ${hex}`); + // All M layers should be the assigned filament (phase 0 → no layers below). + for (let j = 0; j < r.phaseCount; j++) { + assert.equal(typeof seq[j], 'number'); + } + } +}); + +test('phase-1 colour uses filament 0 at layer 0, assigned filament at layer 1', () => { + const swatches = [ + { hex: '#000000', count: 1 }, // phase 0 + { hex: '#555555', count: 1 }, // phase 0 + { hex: '#aaaaaa', count: 1 }, // phase 1 + { hex: '#ffffff', count: 1 }, // phase 1 + ]; + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ) as SpatialVarianceResult; + + for (const hex of ['#aaaaaa', '#ffffff']) { + const seq = r.colorLayerFilaments.get(hex)!; + assert.ok(seq, `Missing sequence for ${hex}`); + // Layer 0 should use base filament (index 0). + assert.equal(seq[0], 0, `${hex}: expected base filament at layer 0`); + // Layer 1 should use the assigned filament. + assert.equal(typeof seq[1], 'number'); + } +}); + +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + +test('windows are non-overlapping and cover all M phases', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.windows.length, r.phaseCount); + for (let j = 0; j < r.windows.length; j++) { + assert.equal(r.windows[j].windowStart, j); + assert.equal(r.windows[j].windowEnd, j); + } +}); + +test('window Z ranges are contiguous and cover total height', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + // First window starts at Z=0. + assert.ok(Math.abs(r.windows[0].windowBottomZ) < 1e-9); + // Last window top = totalHeight. + const lastW = r.windows[r.windows.length - 1]; + assert.ok( + Math.abs(lastW.windowTopZ - r.spatialVarianceTotalHeight) < 1e-9, + `last window top ${lastW.windowTopZ} ≠ totalHeight ${r.spatialVarianceTotalHeight}` + ); + // Consecutive windows share a boundary. + for (let j = 1; j < r.windows.length; j++) { + assert.ok( + Math.abs(r.windows[j].windowBottomZ - r.windows[j - 1].windowTopZ) < 1e-9, + `gap between windows ${j - 1} and ${j}` + ); + } +}); + +// --------------------------------------------------------------------------- +// N-head cap: n is capped to filaments.length +// --------------------------------------------------------------------------- + +test('n > filaments.length is clamped to filaments.length', () => { + // 2 filaments, ask for n=10 heads → effective N=2 → M=ceil(4/2)=2 + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 10 + ); + assert.equal(r.phaseCount, 2); // K=4, effective N=2 → M=2 +}); + +// --------------------------------------------------------------------------- +// Scheduling fields (windowRunFilaments, nozzleAssignments, nonWindowedRanges, +// preWindowFilaments) — added with the scheduling layer implementation. +// --------------------------------------------------------------------------- + +test('empty result has all scheduling fields as empty arrays', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.deepEqual(r.windowRunFilaments, []); + assert.deepEqual(r.nozzleAssignments, []); + assert.deepEqual(r.preWindowFilaments, []); + assert.deepEqual(r.nonWindowedRanges, []); +}); + +test('windowRunFilaments has one entry per phase', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + // K=4, N=2 → M=2 phases + assert.equal(r.windowRunFilaments.length, r.phaseCount); +}); + +test('windowRunFilaments entries contain valid filament IDs', () => { + const filamentSet = [BLACK, DARK, MID, LIGHT, WHITE]; + const validIds = new Set(filamentSet.map(f => f.id)); + const r = runMultiHeadSpatialVarianceOptimization( + filamentSet, dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (const phaseIds of r.windowRunFilaments) { + assert.ok(phaseIds.length > 0, 'each phase must have at least one filament ID'); + for (const id of phaseIds) { + assert.ok(validIds.has(id), `unknown filament ID: ${id}`); + } + } +}); + +test('windowRunFilaments matches windows[j].filamentIds', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let j = 0; j < r.phaseCount; j++) { + assert.deepEqual( + r.windowRunFilaments[j], + r.windows[j].filamentIds, + `phase ${j}: windowRunFilaments ≠ windows[j].filamentIds` + ); + } +}); + +test('nozzleAssignments has one entry per phase', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(r.nozzleAssignments.length, r.phaseCount); +}); + +test('nozzleAssignments[j] has length N (one slot per nozzle)', () => { + const N = 2; + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, N + ); + for (let j = 0; j < r.phaseCount; j++) { + assert.equal( + r.nozzleAssignments[j].length, N, + `phase ${j}: expected ${N} nozzle slots` + ); + } +}); + +test('nozzleAssignments slots are valid run-slot indices or -1 (idle)', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let j = 0; j < r.phaseCount; j++) { + const K = r.windowRunFilaments[j].length; + for (const slot of r.nozzleAssignments[j]) { + assert.ok( + slot === -1 || (slot >= 0 && slot < K), + `phase ${j}: slot ${slot} out of range [−1, ${K})` + ); + } + } +}); + +test('each phase has at least one active nozzle (not all idle)', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let j = 0; j < r.phaseCount; j++) { + const activeCount = r.nozzleAssignments[j].filter(s => s !== -1).length; + assert.ok(activeCount > 0, `phase ${j}: all nozzles idle`); + } +}); + +test('every filament in windowRunFilaments[j] is assigned to exactly one nozzle', () => { + // Verifies the assignment is injective (no two nozzles share the same run-slot). + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + for (let j = 0; j < r.phaseCount; j++) { + const active = r.nozzleAssignments[j].filter(s => s !== -1); + const unique = new Set(active); + assert.equal(unique.size, active.length, `phase ${j}: duplicate nozzle→slot assignment`); + // Every run slot must be covered by exactly one active nozzle. + const K = r.windowRunFilaments[j].length; + assert.equal(active.length, K, `phase ${j}: ${K} filaments but ${active.length} active nozzles`); + } +}); + +test('nonWindowedRanges is always empty (all layers are in phases)', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.deepEqual(r.nonWindowedRanges, []); +}); + +test('preWindowFilaments is always empty', () => { + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.deepEqual(r.preWindowFilaments, []); +}); + +test('nozzle swap count is minimal for a monotone ordering (0 or 1 swap)', () => { + // With 2 heads and 3 phases, the optimizer should find a schedule with + // at most 1 nozzle swap: the idle nozzle at phase 0 can carry its filament + // into phase 1 for free if the filament reappears there. + // We just verify the result doesn't exceed the theoretical maximum (N * (M-1)). + const N = 2; + const r = runMultiHeadSpatialVarianceOptimization( + [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, N + ); + const M = r.phaseCount; // 3 + + let totalSwaps = 0; + let prev: number[] = []; + for (let j = 0; j < M; j++) { + const cur = r.nozzleAssignments[j]; + const runs = r.windowRunFilaments[j]; + if (j > 0) { + const prevRuns = r.windowRunFilaments[j - 1]; + const curFilIds = cur.map(s => s === -1 ? prev[cur.indexOf(s)] : runs[s]); + const prevFilIds = prev.map(s => s === -1 ? '' : prevRuns[s] ?? ''); + for (let k = 0; k < N; k++) { + if (curFilIds[k] !== prevFilIds[k]) totalSwaps++; + } + } + prev = cur; + } + // Upper bound: every nozzle could change at every transition. + assert.ok(totalSwaps <= N * (M - 1), `totalSwaps ${totalSwaps} exceeds max ${N * (M - 1)}`); +}); diff --git a/tests/patchedLayersToPlan.test.ts b/tests/patchedLayersToPlan.test.ts new file mode 100644 index 0000000..b42716f --- /dev/null +++ b/tests/patchedLayersToPlan.test.ts @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { generateAutoLayers } from '../src/lib/autoPaint.ts'; +import { expandZonesToPrinterLayers } from '../src/lib/multiHeadAnalysis.ts'; +import { runMultiHeadLayerAnalysisColorFirst } from '../src/lib/multiHeadAnalysisColorFirst.ts'; +import { patchedLayersToPlan, patchedLayersToSliceData, buildPerColorLayerColors } from '../src/lib/patchedLayersToPlan.ts'; +import type { Filament } from '../src/types/index.ts'; + +const LAYER_HEIGHT = 0.12; +const FIRST_LAYER_HEIGHT = 0.20; + +function filament(id: string, color: string, td: number, name?: string): Filament { + return { id, color, td, name }; +} + +const F0 = filament('f0', '#000000', 5.0, 'VeryDark'); +const F1 = filament('f1', '#555555', 5.0, 'Dark'); +const F2 = filament('f2', '#aaaaaa', 5.0, 'Light'); +const F3 = filament('f3', '#ffffff', 5.0, 'VeryLight'); +const FOUR_FILAMENTS = [F0, F1, F2, F3]; + +function gradient(n: number): Array<{ hex: string }> { + return Array.from({ length: n }, (_, i) => { + const v = Math.round((i / (n - 1)) * 255); + const h = v.toString(16).padStart(2, '0'); + return { hex: `#${h}${h}${h}` }; + }); +} + +// --------------------------------------------------------------------------- +// patchedLayersToPlan — basic structural invariants +// --------------------------------------------------------------------------- + +test('patchedLayersToPlan — returns empty for empty input', () => { + assert.deepEqual(patchedLayersToPlan([], FOUR_FILAMENTS), []); +}); + +test('patchedLayersToPlan — zone count equals run count from the same layers', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const zones = patchedLayersToPlan(layers, FOUR_FILAMENTS); + // One zone per contiguous same-filament run — count must be ≥ 1 and ≤ layers.length. + assert.ok(zones.length >= 1, 'must produce at least one zone'); + assert.ok(zones.length <= layers.length, 'cannot have more zones than layers'); +}); + +test('patchedLayersToPlan — zones are contiguous (endHeight[i] === startHeight[i+1])', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const zones = patchedLayersToPlan(layers, FOUR_FILAMENTS); + + for (let i = 1; i < zones.length; i++) { + assert.ok( + Math.abs(zones[i].startHeight - zones[i - 1].endHeight) < 1e-9, + `gap between zone ${i - 1} (end ${zones[i - 1].endHeight}) and zone ${i} (start ${zones[i].startHeight})` + ); + } +}); + +test('patchedLayersToPlan — startHeight values are monotonically increasing', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const zones = patchedLayersToPlan(layers, FOUR_FILAMENTS); + + for (let i = 1; i < zones.length; i++) { + assert.ok( + zones[i].startHeight > zones[i - 1].startHeight, + `startHeight not increasing at zone ${i}: ${zones[i].startHeight} <= ${zones[i - 1].startHeight}` + ); + } +}); + +test('patchedLayersToPlan — all zone heights are non-negative', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const zones = patchedLayersToPlan(layers, FOUR_FILAMENTS); + + for (const z of zones) { + assert.ok(z.startHeight >= 0, `negative startHeight: ${z.startHeight}`); + assert.ok(z.endHeight > 0, `non-positive endHeight: ${z.endHeight}`); + assert.ok(z.endHeight > z.startHeight, `zero-thickness zone at ${z.startHeight}`); + } +}); + +test('patchedLayersToPlan — idealThickness equals actualThickness (no compression)', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const zones = patchedLayersToPlan(layers, FOUR_FILAMENTS); + + for (const z of zones) { + assert.equal(z.idealThickness, z.actualThickness, + `idealThickness ${z.idealThickness} !== actualThickness ${z.actualThickness}`); + } +}); + +test('patchedLayersToPlan — filamentId and filamentColor round-trip to the correct filament', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const zones = patchedLayersToPlan(layers, FOUR_FILAMENTS); + + const knownIds = new Set(FOUR_FILAMENTS.map((f) => f.id)); + const knownColors = new Set(FOUR_FILAMENTS.map((f) => f.color)); + + for (const z of zones) { + assert.ok(knownIds.has(z.filamentId), + `filamentId "${z.filamentId}" not found in filaments`); + assert.ok(knownColors.has(z.filamentColor), + `filamentColor "${z.filamentColor}" not found in filaments`); + } +}); + +// --------------------------------------------------------------------------- +// patchedLayersToPlan — integration with the iterative analysis +// --------------------------------------------------------------------------- + +test('patchedLayersToPlan — zones from patchedLayers cover the same total height as original layers', () => { + const swatches = gradient(200); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + if (patchedLayers.length === 0) return; // no windows found, skip + + const originalLayers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const originalTop = originalLayers.at(-1)!; + const expectedTop = originalTop.startZ + originalTop.thickness; + + const zones = patchedLayersToPlan(patchedLayers, FOUR_FILAMENTS); + const actualTop = zones.at(-1)!.endHeight; + + assert.ok( + Math.abs(actualTop - expectedTop) < 1e-6, + `total height mismatch: patched=${actualTop.toFixed(4)} original=${expectedTop.toFixed(4)}` + ); +}); + +test('patchedLayersToPlan — all zone filamentIds are valid after iterative reordering', () => { + const swatches = gradient(200); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + if (patchedLayers.length === 0) return; + + const knownIds = new Set(FOUR_FILAMENTS.map((f) => f.id)); + const zones = patchedLayersToPlan(patchedLayers, FOUR_FILAMENTS); + + for (const z of zones) { + assert.ok(knownIds.has(z.filamentId), + `reordered zone has unknown filamentId "${z.filamentId}"`); + } +}); + +// --------------------------------------------------------------------------- +// patchedLayersToSliceData +// --------------------------------------------------------------------------- + +test('patchedLayersToSliceData — returns empty triple for empty input', () => { + const result = patchedLayersToSliceData([], FOUR_FILAMENTS, FIRST_LAYER_HEIGHT); + assert.deepEqual(result, { colorOrder: [], colorSliceHeights: [], swatches: [] }); +}); + +test('patchedLayersToSliceData — colorOrder is the identity mapping', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorOrder } = patchedLayersToSliceData(layers, FOUR_FILAMENTS, FIRST_LAYER_HEIGHT); + assert.deepEqual(colorOrder, Array.from({ length: colorOrder.length }, (_, i) => i)); +}); + +test('patchedLayersToSliceData — all three arrays have the same length', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorOrder, colorSliceHeights, swatches: sw } = patchedLayersToSliceData( + layers, FOUR_FILAMENTS, FIRST_LAYER_HEIGHT + ); + assert.equal(colorSliceHeights.length, colorOrder.length); + assert.equal(sw.length, colorOrder.length); +}); + +test('patchedLayersToSliceData — first slice height is at least firstLayerHeight', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorSliceHeights } = patchedLayersToSliceData(layers, FOUR_FILAMENTS, FIRST_LAYER_HEIGHT); + assert.ok( + colorSliceHeights[0] >= FIRST_LAYER_HEIGHT, + `first slice height ${colorSliceHeights[0]} < firstLayerHeight ${FIRST_LAYER_HEIGHT}` + ); +}); + +test('patchedLayersToSliceData — cumulative heights match total model height', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorSliceHeights } = patchedLayersToSliceData(layers, FOUR_FILAMENTS, FIRST_LAYER_HEIGHT); + + const lastLayer = layers.at(-1)!; + const expectedTotal = lastLayer.startZ + lastLayer.thickness; + const actualTotal = colorSliceHeights.reduce((s, h) => s + h, 0); + + assert.ok( + Math.abs(actualTotal - expectedTotal) < 1e-6, + `cumulative height ${actualTotal.toFixed(4)} !== expected ${expectedTotal.toFixed(4)}` + ); +}); + +test('patchedLayersToSliceData — produces one slice per printer layer', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { colorOrder } = patchedLayersToSliceData(layers, FOUR_FILAMENTS, FIRST_LAYER_HEIGHT); + // One slice per printer layer (matching autoPaintToSliceHeights granularity), + // not one per color run — this preserves the smooth per-layer gradient. + assert.equal(colorOrder.length, layers.length, + `expected one slice per layer (${layers.length}), got ${colorOrder.length}`); +}); + +test('patchedLayersToSliceData — swatches are valid 6-digit hex with alpha 255', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { swatches: sw } = patchedLayersToSliceData(layers, FOUR_FILAMENTS, FIRST_LAYER_HEIGHT); + + // Colours are Beer-Lambert blends, not raw filament colours, so we only + // assert valid hex format rather than membership in the filament palette. + for (const s of sw) { + assert.ok(/^#[0-9a-fA-F]{6}$/.test(s.hex), `invalid hex "${s.hex}"`); + assert.equal(s.a, 255); + } +}); + +test('patchedLayersToSliceData — foundation slice colour equals the foundation filament colour', () => { + const swatches = gradient(20); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { swatches: sw } = patchedLayersToSliceData(layers, FOUR_FILAMENTS, FIRST_LAYER_HEIGHT); + + // Layer 0 is the opaque foundation — its blended colour is the raw filament. + const foundationFilament = FOUR_FILAMENTS[layers[0].filamentIdx]; + assert.equal(sw[0].hex.toLowerCase(), foundationFilament.color.toLowerCase()); +}); + +// --------------------------------------------------------------------------- +// buildPerColorLayerColors +// --------------------------------------------------------------------------- + +test('buildPerColorLayerColors — returns empty for empty layers', () => { + const out = buildPerColorLayerColors([], new Map(), FOUR_FILAMENTS); + assert.equal(out.size, 0); +}); + +test('buildPerColorLayerColors — one colour-array per colour, length = layer count', () => { + const swatches = gradient(40); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers, colorLayerFilaments } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + if (patchedLayers.length === 0) return; + + const perColor = buildPerColorLayerColors(patchedLayers, colorLayerFilaments, FOUR_FILAMENTS); + assert.equal(perColor.size, colorLayerFilaments.size); + for (const [hex, colors] of perColor) { + assert.equal(colors.length, patchedLayers.length, `wrong length for "${hex}"`); + for (const c of colors) { + assert.ok(/^#[0-9a-fA-F]{6}$/.test(c), `invalid blended hex "${c}" for "${hex}"`); + } + } +}); + +test('buildPerColorLayerColors — foundation layer colour is the foundation filament for every colour', () => { + const swatches = gradient(40); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers, colorLayerFilaments } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + if (patchedLayers.length === 0) return; + + const perColor = buildPerColorLayerColors(patchedLayers, colorLayerFilaments, FOUR_FILAMENTS); + const foundationColor = FOUR_FILAMENTS[patchedLayers[0].filamentIdx].color.toLowerCase(); + for (const [hex, colors] of perColor) { + assert.equal(colors[0].toLowerCase(), foundationColor, `foundation mismatch for "${hex}"`); + } +}); + +test('buildPerColorLayerColors — at least two colours differ in their blended top colour', () => { + const swatches = gradient(40); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { patchedLayers, colorLayerFilaments, windows } = runMultiHeadLayerAnalysisColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + if (windows.length === 0 || patchedLayers.length === 0) return; + + const perColor = buildPerColorLayerColors(patchedLayers, colorLayerFilaments, FOUR_FILAMENTS); + // Across all layers, there should be at least one layer index where two + // colours produce different blended colours (i.e. mixing is visible). + const L = patchedLayers.length; + let foundDivergence = false; + for (let i = 0; i < L && !foundDivergence; i++) { + const seen = new Set(); + for (const colors of perColor.values()) seen.add(colors[i]); + if (seen.size > 1) foundDivergence = true; + } + assert.ok(foundDivergence, 'expected at least one layer where colours diverge'); +}); diff --git a/tmp_implementation_plan.txt b/tmp_implementation_plan.txt new file mode 100644 index 0000000..97b7334 --- /dev/null +++ b/tmp_implementation_plan.txt @@ -0,0 +1,472 @@ +Multi-Head Reordered Layer Shipping Plan +========================================== +Goal: Take the multi-head iterative analysis result and produce a 3MF that a +slicer can execute, including additional filament swaps and correctly ordered +material assignments within each reordered window. + +Bird's-eye steps (this document expands each): + 1. Expose patchedLayers from ColorFirstResult + 2. patchedLayers → new transition plan (TransitionZone[]) + 3. Swap plan integration + 4. 3D mesh generation with reordered layers + 5. 3MF material assignment + +Current data flow (for reference): + ThreeDControls → ThreeDControlsStateShape → App + ↓ ↓ + ThreeDView useAppHandlers + (mesh build) (3MF export) + +After this work, the patched result must reach both ThreeDView and +useAppHandlers so geometry and export agree on the reordered ordering. + + +=========================================================================== +STEP 1 — Expose patchedLayers from ColorFirstResult +=========================================================================== + +File: src/lib/multiHeadAnalysisColorFirst.ts + +WHY +--- +The iterative loop in runMultiHeadLayerAnalysisColorFirst patches layers[] in- +place via applyComboToLayers. After all iterations that array is the authoritative +record of what the printer will actually do. Currently it is discarded. Everything +downstream (transition plan, mesh, export) needs it. + +CHANGES +------- +1a. Add patchedLayers to ColorFirstResult: + + export interface ColorFirstResult { + windows: WindowResult[]; + colorAssignments: Map[]; + uniqueLayerCount: number; + patchedLayers: PrinterLayer[]; // <-- new + } + +1b. At the end of runMultiHeadLayerAnalysisColorFirst, before the return, + capture a shallow copy of the mutated array so callers cannot accidentally + alias the internal buffer: + + return { + windows: selectedWindows, + colorAssignments: selectedAssignments, + uniqueLayerCount: pixels.length, + patchedLayers: layers.slice(), // <-- new + }; + +1c. Update the two empty-result literals that are returned on early exit: + + const empty: ColorFirstResult = { + windows: [], + colorAssignments: [], + uniqueLayerCount: 0, + patchedLayers: [], // <-- new + }; + +1d. Update tests: + tests/multiHeadAnalysisColorFirst.test.ts + - The two deepEqual checks for the empty return now need patchedLayers: [] + - Add one test: patchedLayers.length > 0 for a normal run, and that its + filamentIdx values are within range [0, filaments.length) + +EDGE CASES +---------- +- If no windows were selected (all errorFactors below threshold), patchedLayers + equals the original unexpanded stack — still correct, just unmodified. +- layers.slice() is O(N) on printer layer count, typically 50-500. Fine. + + +=========================================================================== +STEP 2 — patchedLayers → new TransitionZone[] +=========================================================================== + +File: src/lib/patchedLayersToPlan.ts (new file) + +WHY +--- +The rest of the pipeline (mesh builder, swap plan, export) all speak the +language of TransitionZone[], which is the shape already used by AutoPaintResult. +Converting patchedLayers to that shape lets downstream code remain unchanged. + +TYPES NEEDED (already defined in autoPaint.ts, import them) + TransitionZone { filamentId, filamentColor, filamentTd, + startHeight, endHeight, idealThickness, actualThickness } + PrinterLayer { filamentIdx, filamentRgb, td, thickness, startZ } + Filament { id, color, td, ... } + +FUNCTION SIGNATURE +------------------ +import { buildColorRuns } from './multiHeadAnalysisColorFirst'; +import type { TransitionZone } from './autoPaint'; +import type { PrinterLayer } from './multiHeadAnalysis'; +import type { Filament } from '../types'; + +export function patchedLayersToPlan( + layers: PrinterLayer[], + filaments: Filament[] +): TransitionZone[] + +ALGORITHM +--------- +1. Call buildColorRuns(layers) → ColorRun[] + Each run = { filamentIdx, startLayerIdx, endLayerIdx } + +2. For each run: + a. filament = filaments[run.filamentIdx] (safe: applyComboToLayers only + ever writes valid indices from the original filaments array) + b. startHeight = layers[run.startLayerIdx].startZ + c. endHeight = layers[run.endLayerIdx].startZ + + layers[run.endLayerIdx].thickness + d. thickness = endHeight - startHeight + e. Emit TransitionZone: + { + filamentId: filament.id, + filamentColor: filament.color, + filamentTd: filament.td, + startHeight, + endHeight, + idealThickness: thickness, + actualThickness: thickness, + } + +3. Return the array of TransitionZone. + +NOTE on idealThickness vs actualThickness: for the multi-head result there is +no compression step — the printer-layer heights are exact. Both fields carry the +same value. Callers that display "compression ratio" should be aware. + +TESTS +----- +tests/patchedLayersToPlan.test.ts (new file) +- Zone count equals run count from buildColorRuns on the same layers. +- Zones are contiguous: zones[i].endHeight === zones[i+1].startHeight. +- filamentId and filamentColor round-trip back to the correct filament. +- Heights are non-negative and monotonically increasing. +- Zero-length input returns []. + + +=========================================================================== +STEP 3 — Swap plan integration +=========================================================================== + +Files: + src/hooks/useSwapPlan.ts (modify) + src/types/index.ts (modify) + src/components/ThreeDControls.tsx (modify) + +WHY +--- +useSwapPlan currently generates swap instructions from autoPaintResult.layers. +After multi-head reordering the effective layer plan has additional swaps +inside what were previously single-filament zones. We need those extra swaps +to appear in the printed instructions the user reads. + +CHANGES +------- + +3a. Add patchedTransitionZones to ThreeDControlsStateShape (types/index.ts): + + interface ThreeDControlsStateShape { + ... + multiHeadWindows?: WindowResult[]; + patchedTransitionZones?: TransitionZone[]; // <-- new + ... + } + +3b. Propagate from ThreeDControls.tsx: + - Import patchedLayersToPlan from lib/patchedLayersToPlan. + - In handleApply, after runMultiHeadLayerAnalysisColorFirst: + + const cfResult = runMultiHeadLayerAnalysisColorFirst(...); + const newMultiHeadWindows = cfResult.windows; + const patchedTransitionZones = + cfResult.patchedLayers.length > 0 + ? patchedLayersToPlan(cfResult.patchedLayers, filaments) + : undefined; + + - Include patchedTransitionZones in both onChange({ ... }) calls. + +3c. Add patchedTransitionZones to UseSwapPlanOptions (useSwapPlan.ts): + + interface UseSwapPlanOptions { + ... + patchedTransitionZones?: TransitionZone[]; // <-- new + } + +3d. In the useMemo body of useSwapPlan, when paintMode === 'autopaint': + - Use patchedTransitionZones in preference to autoPaintResult.transitionZones + when it is present. + - The existing zone → swap-entry logic already iterates TransitionZone[] + in startHeight order, so no structural change is needed — just swap the + source array. + + Concretely, replace: + autoPaintResult.layers.forEach((layer, idx) => { ... }) + with logic that runs over (patchedTransitionZones ?? autoPaintResult.transitionZones) + converting each zone to a SwapEntry using the same height → layer-number + formula that already exists in the hook. + +SWAP COUNT IMPACT +----------------- +Original auto-paint with N filaments → N-1 swaps. +After multi-head reordering with W windows, each window of N runs adds up to +N-1 extra swaps within the window. Total worst-case swaps = original + W*(N-1). +For a typical 4-head model with 2 windows: 3 + 2*3 = 9 swaps. Still manageable. + +EDGE CASES +---------- +- If patchedLayers is empty (analysis ran but found nothing), patchedTransitionZones + is undefined and useSwapPlan falls back to the original autoPaintResult zones. +- Zone heights from patchedLayersToPlan may be slightly different from the + autoPaintResult zones because they are derived from discrete printer-layer + boundaries rather than the ideal float heights. The layer-number calculation + in useSwapPlan already rounds, so this is safe. + + +=========================================================================== +STEP 4 — 3D mesh generation with reordered layers +=========================================================================== + +Files: + src/lib/patchedLayersToPlan.ts (add second export) + src/types/index.ts (extend ThreeDControlsStateShape) + src/components/ThreeDControls.tsx (propagate new field) + src/components/ThreeDView.tsx (consume new field) + +WHY +--- +ThreeDView builds a mesh per color slice using colorOrder (swatch indices) and +colorSliceHeights (thicknesses). After multi-head reordering, some of those +slices have the wrong filament color, and slices within windows may need to be +split. The cleanest solution: derive a replacement colorOrder + colorSliceHeights ++ swatches triple from the patched run sequence, and feed it to ThreeDView +alongside the original data. + +GEOMETRY DOES NOT CHANGE. Only the per-slice material color changes. The pixel +height map (which pixels are active at which Z) stays identical because it is +derived from image luminance, not from filament colors. + +NEW EXPORT in patchedLayersToPlan.ts +------------------------------------- +export interface PatchedSliceData { + colorOrder: number[]; // 0..N-1 for N runs (identity mapping) + colorSliceHeights: number[]; // thickness of each run in mm + swatches: { hex: string; a: number }[]; // filament color per run +} + +export function patchedLayersToSliceData( + layers: PrinterLayer[], + filaments: Filament[], + firstLayerHeight: number +): PatchedSliceData + +ALGORITHM +--------- +1. runs = buildColorRuns(layers) +2. For each run i: + a. hex = '#' + filaments[run.filamentIdx].color (ensure # prefix) + b. thickness: + if i === 0: max(run thickness, firstLayerHeight) — matches ThreeDView's + existing first-layer logic + else: layers[run.startLayerIdx..run.endLayerIdx] thickness sum + = layers[run.endLayerIdx].startZ + layers[run.endLayerIdx].thickness + - layers[run.startLayerIdx].startZ +3. colorOrder = [0, 1, 2, ..., runs.length-1] (identity; each run is its own slice) +4. colorSliceHeights = indexed by run index (not by swatchIdx like the original) +5. swatches = [{hex, a:255}, ...] one per run + +STATE FIELD +----------- +Add to ThreeDControlsStateShape (types/index.ts): + + patchedSliceData?: PatchedSliceData; // <-- new + +Propagate in ThreeDControls.tsx handleApply: + + const patchedSliceData = + cfResult.patchedLayers.length > 0 + ? patchedLayersToSliceData(cfResult.patchedLayers, filaments, slicerFirstLayerHeight) + : undefined; + +Include in both onChange calls. + +THREEVIEW CHANGES +----------------- +ThreeDView.tsx already receives swatches, colorOrder, colorSliceHeights as +props. It also receives the full ThreeDControlsStateShape (via the parent's +onChange output). The cleanest approach: add patchedSliceData as a prop. + +In the autopaint mesh build block (currently keyed on autoPaintResult being +set), add a branch: + + if (paintMode === 'autopaint' && autoPaintResult && patchedSliceData) { + // Use patchedSliceData.colorOrder / colorSliceHeights / swatches + // instead of the autoPaintSliceData equivalents. + // Geometry (pixelHeightMap) is identical — only per-slice color changes. + } + +Because the geometry (pixelHeightMap) is unchanged, the only difference in the +mesh loop is: + - colorOrder comes from patchedSliceData.colorOrder + - colorSliceHeights comes from patchedSliceData.colorSliceHeights + - swatches comes from patchedSliceData.swatches + - filamentSwatches is not needed (swatches already carry the correct + physical filament colors) + +EDGE CASES +---------- +- If patchedSliceData is undefined (multi-head analysis found nothing or was + not run), ThreeDView falls back to the original autoPaintSliceData path. + No regression. +- Slice count may differ from original colorOrder.length because one original + auto-paint zone may now be represented as multiple printer-layer runs after + a window is applied. This is intentional. +- The first layer thickness clamping (max(h, slicerFirstLayerHeight)) must be + preserved in patchedLayersToSliceData for the layered mesh builder to produce + a watertight base. + + +=========================================================================== +STEP 5 — 3MF material assignment +=========================================================================== + +Files: + src/hooks/useAppHandlers.ts (modify) + src/lib/export3mf.ts (no change needed) + +WHY +--- +export3mf.ts already has layerFilamentColors?: string[] in Export3MFOptions. +This array is indexed by mesh-object index and overrides the per-object material +color. Currently nothing populates it for multi-head models. + +After step 4, each mesh object corresponds to one run from patchedSliceData. +layerFilamentColors must therefore have one hex string per run, in the same +order as patchedSliceData.colorOrder. + +CHANGES +------- + +5a. In useAppHandlers.ts, in the export-3MF handler, after the threeObject + is ready, check if patchedSliceData is in scope (passed through state): + + const layerFilamentColors: string[] | undefined = + patchedSliceData + ? patchedSliceData.swatches.map((s) => s.hex) + : undefined; + +5b. Pass layerFilamentColors into exportObjectTo3MFBlob: + + const blob = await exportObjectTo3MFBlob( + threeObject, + { layerFilamentColors, ...progressReporter }, + (meta) => updateZipStep(meta.percent) + ); + +HOW THE 3MF USES IT +-------------------- +export3mf.ts line ~229: + const overrideHex = options?.layerFilamentColors?.[i]; +This replaces the per-object color with the run's actual filament color, so +the slicer's material list reflects the reordered ordering rather than the +original auto-paint ordering. + +The slicer reads the per-object extruder assignment from the tag. The export already writes a 1-based colorIdx per component +(line ~715). That colorIdx is derived from the mesh object's material, which is +now overridden by layerFilamentColors. Verify that the material-to-extruder +mapping in the generated 3MF config remains correct after the override — it +should because the override only changes the color string, not the material +index itself. + +EDGE CASES +---------- +- If patchedSliceData is undefined, layerFilamentColors is undefined and the + existing export path runs unchanged. +- The filament colour list in the 3MF's project settings (filament_colour array) + is derived from the materials present. With the reordered layers, the colour + list may change order or include duplicates vs the original. Verify that + BambuStudio / PrusaSlicer handle this gracefully. +- Some slicers require the number of distinct filament entries to match the + printer's loaded filament count. If a window re-uses a filament that is + already in the list, de-duplicate before writing filament_colour. + + +=========================================================================== +DATA FLOW AFTER ALL STEPS +=========================================================================== + +ThreeDControls.handleApply + └─ runMultiHeadLayerAnalysisColorFirst(...) + └─ ColorFirstResult { windows, colorAssignments, uniqueLayerCount, + patchedLayers } + ├─ patchedLayersToPlan(patchedLayers, filaments) + │ └─ TransitionZone[] → stored as patchedTransitionZones + └─ patchedLayersToSliceData(patchedLayers, filaments, firstLayerH) + └─ PatchedSliceData → stored as patchedSliceData + +onChange({ ..., patchedTransitionZones, patchedSliceData }) + ├─ App state + ├─ ThreeDView ← receives patchedSliceData → drives mesh geometry colours + ├─ useSwapPlan ← receives patchedTransitionZones → prints correct swap steps + └─ useAppHandlers (export) ← derives layerFilamentColors from patchedSliceData + + +=========================================================================== +TESTING PLAN +=========================================================================== + +Unit tests +---------- +- patchedLayersToPlan: zone continuity, filament round-trip, monotone heights +- patchedLayersToSliceData: slice count = run count, first-layer height clamp, + identity colorOrder, hex format +- ColorFirstResult.patchedLayers: non-empty after a run that finds windows, + filamentIdx in valid range, length matches printer layer count + +Integration / visual +-------------------- +- Load a 4-filament image, enable multi-head mode, click Build 3D Model. + Verify the 3D preview shows the reordered filament colours in the windows. +- Export the 3MF. Open in BambuStudio / PrusaSlicer and confirm: + a. The number of colour swaps in the slicer preview matches the swap plan. + b. The per-layer material assignment matches the console log from + runMultiHeadLayerAnalysisColorFirst. +- Verify fallback: with multi-head mode off, the mesh and export are + byte-for-byte identical to a build with the existing code. + +Regression +---------- +- All existing tests continue to pass (npm test). +- The standard (non-multi-head) autopaint path is gated behind + `patchedSliceData !== undefined` checks and must not be affected. + + +=========================================================================== +OPEN QUESTIONS / RISKS +=========================================================================== + +Q1. Slicer extruder-count mismatch + Some slicers reject a 3MF whose filament list has more entries than the + printer profile allows. After multi-head reordering, the effective number + of distinct filaments is still ≤ N (the head count). Verify this holds + and add a de-duplication pass if needed. + +Q2. Filament swap feasibility + The reordered plan may require physically swapping filaments mid-print at + heights that the printer cannot pause at (e.g., inside a bridging span). + This is a UX / documentation issue, not a code bug, but worth noting in + the UI. + +Q3. colorSliceHeights indexing convention + ThreeDView expects colorSliceHeights indexed by swatch index (not by + position). PatchedSliceData uses an identity colorOrder so index == position, + but double-check every callsite in ThreeDView that does + colorSliceHeights[colorOrder[i]] vs colorSliceHeights[i]. + +Q4. smoothMeshing with patched slices + The marching-squares path in ThreeDView may have separate assumptions about + how many slices there are. Confirm it works with a higher-than-usual run + count (e.g., 20 runs instead of 4 swatches). From d59df28fedce4fae6c06466a24e079a013b2c0f8 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:33:46 -0500 Subject: [PATCH 02/10] Fix module import paths to include .ts extensions and update multi-head schedule tests Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/App.tsx | 730 ++++++++++++++++++------------ src/components/ThreeDControls.tsx | 7 +- src/components/ThreeDView.tsx | 1 + src/hooks/useSwapPlan.ts | 1 - src/lib/autoPaint.ts | 2 +- src/lib/optimizer.ts | 2 +- tests/export3mf.test.ts | 159 +++++++ tests/libModuleResolution.test.ts | 33 ++ tests/meshingOptions.test.ts | 90 ++++ tests/multiHeadSchedule.test.ts | 116 +++++ 10 files changed, 840 insertions(+), 301 deletions(-) create mode 100644 tests/libModuleResolution.test.ts create mode 100644 tests/meshingOptions.test.ts create mode 100644 tests/multiHeadSchedule.test.ts diff --git a/src/App.tsx b/src/App.tsx index 754a64c..49c61d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,9 @@ import type { CanvasPreviewHandle } from './components/CanvasPreview'; import { SwatchesPanel } from './components/SwatchesPanel'; import AdjustmentsPanel from './components/AdjustmentsPanel'; import DeditherPanel from './components/DeditherPanel'; +import ImageResizePanel from './components/ImageResizePanel'; import { ADJUSTMENT_DEFAULTS } from './lib/applyAdjustments'; +import { calculateImageResizeDimensions } from './lib/imageResize'; import SLIDER_DEFS from './components/sliderDefs'; import { useSwatches } from './hooks/useSwatches'; import type { SwatchEntry } from './hooks/useSwatches'; @@ -31,6 +33,10 @@ import { ControlsPanel } from './components/ControlsPanel'; import { usePaletteManager } from './hooks/usePaletteManager'; import { UpdateChecker } from './components/UpdateChecker'; import ProgressOverlay from './components/ProgressOverlay'; +import DocsPage from './components/docs/DocsPage'; +import { defaultDocSlug } from './docs'; +import { buildDocsPath, parseDocsLocation } from './lib/docs/navigation'; +import { applyHomeSeo } from './lib/seo'; import { AlertDialog, AlertDialogContent, @@ -74,6 +80,7 @@ type AutoPaintPersisted = Pick< | 'allowRepeatedSwaps' | 'heightDithering' | 'ditherLineWidth' + | 'flatPaint' >; const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { @@ -100,6 +107,7 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { allowRepeatedSwaps: parsed.allowRepeatedSwaps ?? false, heightDithering: parsed.heightDithering ?? false, ditherLineWidth: parsed.ditherLineWidth, + flatPaint: parsed.flatPaint ?? false, }; } catch { return null; @@ -139,7 +147,8 @@ function App(): React.ReactElement | null { logo, undefined ); - const { swatches, swatchesLoading, imageDimensions, invalidate, immediateOverride } = useSwatches(imageSrc); + const { swatches, swatchesLoading, imageDimensions, invalidate, immediateOverride } = + useSwatches(imageSrc); // adjustments managed locally inside AdjustmentsPanel now // initial selectedPalette derived from initial weight above const inputRef = useRef(null); @@ -192,6 +201,7 @@ function App(): React.ReactElement | null { const [adjustmentsEpoch, setAdjustmentsEpoch] = useState(0); // UI mode toggles (2D / 3D) - UI only for now const [mode, setMode] = useState<'2d' | '3d'>('2d'); + const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null); const [isOrtho, setIsOrtho] = useState(false); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 @@ -206,11 +216,15 @@ function App(): React.ReactElement | null { threeDState, setThreeDState, threeDBuildSignal, + builtThreeDState, + builtFlatPaint, buildWarning, handleThreeDStateChange, confirmBuild, cancelBuild, } = useBuildWarning({ imageSrc }); + const builtModelState = builtThreeDState ?? threeDState; + const builtModelAutoPaint = builtModelState.paintMode === 'autopaint'; // Hydrate threeDState once with persisted autopaint data const [autopaintHydrated] = useState(() => { @@ -225,11 +239,13 @@ function App(): React.ReactElement | null { paintMode: autopaintHydrated.paintMode ?? prev.paintMode, optimizerAlgorithm: autopaintHydrated.optimizerAlgorithm ?? prev.optimizerAlgorithm, optimizerSeed: autopaintHydrated.optimizerSeed ?? prev.optimizerSeed, - regionWeightingMode: autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, + regionWeightingMode: + autopaintHydrated.regionWeightingMode ?? prev.regionWeightingMode, enhancedColorMatch: autopaintHydrated.enhancedColorMatch ?? prev.enhancedColorMatch, allowRepeatedSwaps: autopaintHydrated.allowRepeatedSwaps ?? prev.allowRepeatedSwaps, heightDithering: autopaintHydrated.heightDithering ?? prev.heightDithering, ditherLineWidth: autopaintHydrated.ditherLineWidth ?? prev.ditherLineWidth, + flatPaint: autopaintHydrated.flatPaint ?? prev.flatPaint, })); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -248,6 +264,7 @@ function App(): React.ReactElement | null { allowRepeatedSwaps: threeDState.allowRepeatedSwaps, heightDithering: threeDState.heightDithering, ditherLineWidth: threeDState.ditherLineWidth, + flatPaint: threeDState.flatPaint, }); }, [ threeDState.filaments, @@ -259,6 +276,7 @@ function App(): React.ReactElement | null { threeDState.allowRepeatedSwaps, threeDState.heightDithering, threeDState.ditherLineWidth, + threeDState.flatPaint, ]); // No auto-build on tab switch — the user must click "Build 3D Model" / "Apply Changes". @@ -272,6 +290,44 @@ function App(): React.ReactElement | null { canvasPreviewRef.current?.redraw(); }, [imageSrc]); + useEffect(() => { + const syncDocsLocation = () => { + const target = parseDocsLocation(window.location); + setDocsOpen(target !== null); + }; + window.addEventListener('hashchange', syncDocsLocation); + window.addEventListener('popstate', syncDocsLocation); + return () => { + window.removeEventListener('hashchange', syncDocsLocation); + window.removeEventListener('popstate', syncDocsLocation); + }; + }, []); + + useEffect(() => { + if (!docsOpen) { + applyHomeSeo(); + } + }, [docsOpen]); + + const backToApp = () => { + setDocsOpen(false); + if (parseDocsLocation(window.location)) { + window.history.pushState(null, '', '/'); + } + }; + + const toggleDocs = () => { + if (docsOpen) { + backToApp(); + return; + } + + setDocsOpen(true); + if (!parseDocsLocation(window.location)) { + window.history.pushState(null, '', buildDocsPath(defaultDocSlug)); + } + }; + const handleFiles = (file?: File) => { if (!file) return; if (!file.type.startsWith('image/')) { @@ -290,6 +346,59 @@ function App(): React.ReactElement | null { if (inputRef.current) inputRef.current.value = ''; }; + const resizeCurrentImage = async (percent: number) => { + if (!canvasPreviewRef.current || !imageSrc) return; + + let sourceUrl: string | null = null; + try { + const sourceBlob = await canvasPreviewRef.current.exportImageBlob(); + if (!sourceBlob) return; + + sourceUrl = URL.createObjectURL(sourceBlob); + const imageUrl = sourceUrl; + const img = await new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + image.src = imageUrl; + }); + + if (!img) return; + + const target = calculateImageResizeDimensions( + img.naturalWidth, + img.naturalHeight, + percent + ); + + if (target.width >= img.naturalWidth && target.height >= img.naturalHeight) return; + + const canvas = document.createElement('canvas'); + canvas.width = target.width; + canvas.height = target.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, target.width, target.height); + + const resizedBlob = await new Promise((resolve) => + canvas.toBlob((blob) => resolve(blob), 'image/png') + ); + if (!resizedBlob) return; + + const url = URL.createObjectURL(resizedBlob); + invalidate(); + setImage(url, true); + } catch (error) { + console.warn('Image resize failed', error); + alert('Image resize failed. See console for details.'); + } finally { + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + } + }; + // splitter & layout management preserved below // wheel/pan handled in CanvasPreview @@ -312,22 +421,24 @@ function App(): React.ReactElement | null { exportObjectToStlBlob, exportObjectTo3MFBlob: (obj, onProgress, onZipProgress) => exportObjectTo3MFBlob(obj, { - layerHeight: threeDState.layerHeight, - firstLayerHeight: threeDState.slicerFirstLayerHeight, + layerHeight: builtModelState.layerHeight, + firstLayerHeight: builtModelState.slicerFirstLayerHeight, layerFilamentColors: - threeDState.paintMode === 'autopaint' - ? (threeDState.patchedSliceData?.swatches - ?? threeDState.autoPaintFilamentSwatches)?.map((s) => s.hex) + builtModelAutoPaint + ? (builtModelState.patchedSliceData?.swatches + ?? builtModelState.autoPaintFilamentSwatches)?.map((s) => s.hex) : undefined, - extruderCount: threeDState.multiHeadMode ? threeDState.multiHeadCount : undefined, - // Head Schedule swap checkpoints → pause markers at those layers. - swapLayers: threeDState.multiHeadMode + extruderCount: builtModelState.multiHeadMode + ? builtModelState.multiHeadCount + : undefined, + // Head Schedule swap checkpoints -> pause markers at those layers. + swapLayers: builtModelState.multiHeadMode ? buildMultiHeadSchedule({ - multiHeadWindows: threeDState.multiHeadWindows, - nozzleAssignments: threeDState.nozzleAssignments, - windowRunFilaments: threeDState.windowRunFilaments, - nonWindowedRanges: threeDState.nonWindowedRanges, - filaments: threeDState.filaments, + 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) => ({ @@ -347,306 +458,331 @@ function App(): React.ReactElement | null { return (
    { invalidate(); setImage(tdTestImg, true); + setMode('2d'); + if (parseDocsLocation(window.location)) { + window.history.pushState(null, '', '/'); + } + setDocsOpen(false); }} /> -
    - - -
    -
    - {mode === '2d' ? ( - <> - - {processingActive && ( - + )} + + ) : ( + <> + - )} - - ) : ( - <> - f.id)} - autoPaintEnabled={threeDState.paintMode === 'autopaint'} - autoPaintTotalHeight={ - (threeDState.multiHeadMode && - threeDState.multiHeadOptimizationMode === 'spatial-variance' && - threeDState.spatialVarianceTotalHeight) - ? threeDState.spatialVarianceTotalHeight - : threeDState.autoPaintResult?.totalHeight - } - autoPaintFilamentOrder={ - threeDState.autoPaintResult?.filamentOrder - } - enhancedColorMatch={threeDState.enhancedColorMatch} - heightDithering={threeDState.heightDithering} - ditherLineWidth={threeDState.ditherLineWidth} - smoothMeshing={threeDState.smoothMeshing} - isOrtho={isOrtho} - /> - {exportingSTL && ( - f.id)} + autoPaintEnabled={builtModelAutoPaint} + autoPaintTotalHeight={ + builtModelState.multiHeadMode && + builtModelState.multiHeadOptimizationMode === + 'spatial-variance' && + builtModelState.spatialVarianceTotalHeight + ? builtModelState.spatialVarianceTotalHeight + : builtModelState.autoPaintResult?.totalHeight + } + autoPaintFilamentOrder={ + builtModelState.autoPaintResult?.filamentOrder + } + enhancedColorMatch={builtModelState.enhancedColorMatch} + heightDithering={builtModelState.heightDithering} + ditherLineWidth={builtModelState.ditherLineWidth} + smoothMeshing={builtModelState.smoothMeshing} + isOrtho={isOrtho} /> - )} - - )} - imageSrc && setIsCropMode(true)} - onSaveCrop={async () => { - if (!canvasPreviewRef.current) return; - const blob = - await canvasPreviewRef.current.exportCroppedImage(); - if (!blob) return; - const url = URL.createObjectURL(blob); - invalidate(); - setImage(url, true); - setIsCropMode(false); - }} - onCancelCrop={() => setIsCropMode(false)} - onToggleCheckerboard={() => setShowCheckerboard((s) => !s)} - onPickFile={() => inputRef.current?.click()} - onClear={clear} - onExportImage={onExportImage} - onExportStl={onExportStl} - onExport3MF={onExport3MF} - isOrtho={isOrtho} - onToggleCamera={() => setIsOrtho((v) => !v)} - /> -
    -
    -
    + {exportingSTL && ( + + )} + + )} + imageSrc && setIsCropMode(true)} + onSaveCrop={async () => { + if (!canvasPreviewRef.current) return; + const blob = + await canvasPreviewRef.current.exportCroppedImage(); + if (!blob) return; + const url = URL.createObjectURL(blob); + invalidate(); + setImage(url, true); + setIsCropMode(false); + }} + onCancelCrop={() => setIsCropMode(false)} + onToggleCheckerboard={() => setShowCheckerboard((s) => !s)} + onPickFile={() => inputRef.current?.click()} + onClear={clear} + onExportImage={onExportImage} + onExportStl={onExportStl} + onExport3MF={onExport3MF} + flatPaintModel={builtFlatPaint} + isOrtho={isOrtho} + onToggleCamera={() => setIsOrtho((v) => !v)} + /> +
    + +
    {/* Build warning dialog */} diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index cbb5789..0da866b 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -341,7 +341,12 @@ export default function ThreeDControls({ // Run the appropriate multi-head optimizer based on the selected mode. const activeResult = (() => { if (!multiHeadMode || paintMode !== 'autopaint' || !autoPaintResult) return null; - const swatches = filtered.map((s) => ({ hex: s.hex, count: s.count })); + // `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, + })); if (multiHeadOptimizationMode === 'spatial-variance') { return runMultiHeadSpatialVarianceOptimization( filaments, autoPaintResult, swatches, diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index 1011a0d..4ceaf2c 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -1695,6 +1695,7 @@ export default function ThreeDView({ nozzleAssignments, windowRunFilaments, multiHeadWindows, + nonWindowedRanges, filamentIds, cameraRef, controlsRef, diff --git a/src/hooks/useSwapPlan.ts b/src/hooks/useSwapPlan.ts index c7ad753..a721436 100644 --- a/src/hooks/useSwapPlan.ts +++ b/src/hooks/useSwapPlan.ts @@ -48,7 +48,6 @@ export function useSwapPlan({ patchedTransitionZones, nozzleAssignments, windowRunFilaments, - preWindowFilaments, nonWindowedRanges, filaments, disabled = false, diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index cf6377a..910eda4 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -26,7 +26,7 @@ import { import { generateCenterWeightedMapSimple, generateEdgeWeightedMapSimple } from './regionWeighting.ts'; import { computeProfileConfidence } from './calibration.ts'; -export { LAYER_ACTIVATION_EPSILON } from './layerActivation'; +export { LAYER_ACTIVATION_EPSILON } from './layerActivation.ts'; /** RGB color representation (0-255 range) */ export interface RGB { diff --git a/src/lib/optimizer.ts b/src/lib/optimizer.ts index 8cfd373..a7d7c8d 100644 --- a/src/lib/optimizer.ts +++ b/src/lib/optimizer.ts @@ -11,7 +11,7 @@ */ import type { Filament } from '../types'; -import { rgbToLab, deltaELab, hexToRgb, blendColors, type RGB, type Lab } from './autoPaint'; +import { rgbToLab, deltaELab, hexToRgb, blendColors, type RGB, type Lab } from './autoPaint.ts'; // ============================================================================ // Type Definitions diff --git a/tests/export3mf.test.ts b/tests/export3mf.test.ts index 508b4e3..3b9e61e 100644 --- a/tests/export3mf.test.ts +++ b/tests/export3mf.test.ts @@ -2050,3 +2050,162 @@ test('3MF export keeps many smooth layers bounded to layer count', async () => { ); } }); + +// --------------------------------------------------------------------------- +// Multi-head (toolchanger) export — regression tests for the OrcaSlicer import +// crash chain. Each assertion maps to a real SIGABRT we fixed. See +// REBASE-NOTES.md §A. +// --------------------------------------------------------------------------- + +interface MultiHeadArchive { + projectSettings: Record; + modelSettingsXml: string; + customGcodeXml: string | null; + contentTypesXml: string; +} + +async function exportMultiHead( + nozzleIndices: number[], + options: Export3MFOptions +): Promise { + installFileReaderPolyfill(); + const root = new THREE.Group(); + nozzleIndices.forEach((nozzle, i) => { + const geometry = new THREE.BoxGeometry(1, 1, 1); + geometry.translate(0, 0, i); + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0xff0000 })); + mesh.userData.nozzleIndex = nozzle; + root.add(mesh); + }); + + const { exportObjectTo3MFBlob } = await loadExport3mfModule(); + const blob = await exportObjectTo3MFBlob(root, options); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const read = async (name: string) => { + const file = zip.file(name); + return file ? file.async('string') : null; + }; + + return { + projectSettings: JSON.parse( + (await read('Metadata/project_settings.config'))! + ) as Record, + modelSettingsXml: (await read('Metadata/model_settings.config'))!, + customGcodeXml: await read('Metadata/custom_gcode_per_layer.xml'), + contentTypesXml: (await read('[Content_Types].xml'))!, + }; +} + +function projectArray(ps: Record, key: string): unknown[] { + const value = ps[key]; + assert.ok(Array.isArray(value), `${key} should be an array`); + return value as unknown[]; +} + +test('multi-head export: filament + per-extruder arrays are all length N', async () => { + const N = 4; + const { projectSettings: ps } = await exportMultiHead([1, 2, 3, 4], { extruderCount: N }); + + for (const key of [ + 'nozzle_diameter', + 'filament_colour', + 'filament_type', + 'filament_settings_id', + 'filament_vendor', + 'filament_diameter', + 'filament_map', + 'extruder_type', + 'nozzle_volume_type', + 'z_hop_types', + 'retract_lift_enforce', + 'nozzle_type', + 'nozzle_flush_dataset', + 'wipe', + 'retract_when_changing_layer', + 'long_retractions_when_cut', + 'extruder_offset', + 'extruder_colour', + 'retraction_length', + 'z_hop', + 'flush_multiplier', + ]) { + assert.equal(projectArray(ps, key).length, N, `${key} should be length ${N}`); + } + + // Serialization Orca expects: enums as labels, bools as "1"/"0", points as "0x0". + assert.deepEqual(ps.extruder_type, [ + 'Direct Drive', + 'Direct Drive', + 'Direct Drive', + 'Direct Drive', + ]); + assert.deepEqual(ps.wipe, ['1', '1', '1', '1']); + assert.deepEqual(ps.extruder_offset, ['0x0', '0x0', '0x0', '0x0']); +}); + +test('multi-head export: flush_volumes_matrix is nozzleCount * filamentCount^2 zeros', async () => { + const N = 4; + const { projectSettings: ps } = await exportMultiHead([1, 2, 3, 4], { extruderCount: N }); + const matrix = projectArray(ps, 'flush_volumes_matrix'); + assert.equal(matrix.length, N * N * N); + assert.ok(matrix.every((v) => v === '0')); +}); + +test('multi-head export: filament_map identity + Manual, G92 reset, pause gcode', async () => { + const { projectSettings: ps } = await exportMultiHead([1, 2, 3], { extruderCount: 3 }); + assert.deepEqual(ps.filament_map, ['1', '2', '3']); + assert.equal(ps.filament_map_mode, 'Manual'); + assert.match(String(ps.before_layer_change_gcode), /G92 E0/); + assert.equal(ps.layer_gcode, undefined, 'must not use the non-Orca layer_gcode key'); + assert.equal(ps.machine_pause_gcode, 'M600'); +}); + +test('multi-head export: part extruder matches mesh nozzle index, clamped to [1,N]', async () => { + const N = 3; + // 5 clamps down to 3. + const { modelSettingsXml } = await exportMultiHead([2, 3, 1, 5], { extruderCount: N }); + const extruders = parseModelSettingsPartExtruders(modelSettingsXml); + assert.deepEqual([...extruders.values()], [2, 3, 1, 3]); +}); + +test('multi-head export: swapLayers become PausePrint markers at the right print_z', async () => { + const { customGcodeXml, contentTypesXml } = await exportMultiHead([1, 2, 3, 4], { + extruderCount: 4, + layerHeight: 0.08, + firstLayerHeight: 0.2, + swapLayers: [ + { layer: 9, color: '#63646b' }, + { layer: 17, color: '#cacccb' }, + ], + }); + + assert.ok(customGcodeXml, 'custom_gcode_per_layer.xml should be present'); + assert.match(contentTypesXml, /Extension="xml"/); + // print_z = firstLayerHeight + (layer - 1) * layerHeight + const tops = [...customGcodeXml!.matchAll(/top_z="([^"]+)"/g)].map((m) => Number(m[1])); + assert.deepEqual(tops, [0.84, 1.48]); + assert.equal( + (customGcodeXml!.match(/type="1"/g) ?? []).length, + 2, + 'both markers should be PausePrint (type=1)' + ); +}); + +test('single-head export omits toolchanger settings and pauses', async () => { + const { projectSettings: ps, customGcodeXml } = await exportMultiHead([1, 1], {}); + assert.equal(ps.filament_map, undefined); + assert.equal(ps.flush_volumes_matrix, undefined); + assert.equal(ps.machine_pause_gcode, undefined); + assert.equal(customGcodeXml, null); +}); + +test('multi-head export: filament_colour uses real per-nozzle colours (not all white)', async () => { + // Regression for the "white object" symptom: nozzleRepColor must be populated + // from the tagged meshes, not fall back to #FFFFFF for every slot. + const { projectSettings: ps } = await exportMultiHead([1, 2, 3, 4], { extruderCount: 4 }); + const colours = projectArray(ps, 'filament_colour') as string[]; + assert.ok( + colours.every((c) => c.toUpperCase() !== '#FFFFFF'), + `filament_colour should reflect the tagged mesh colours, got ${JSON.stringify(colours)}` + ); +}); diff --git a/tests/libModuleResolution.test.ts b/tests/libModuleResolution.test.ts new file mode 100644 index 0000000..4d21667 --- /dev/null +++ b/tests/libModuleResolution.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +// Guard against the import-extension regression introduced during the develop +// rebase: relative *value* imports in lib modules that are loaded node-directly +// (via --experimental-strip-types) must carry the `.ts` extension — the runner +// does NOT auto-resolve extensionless relative paths. +// +// When that broke (e.g. autoPaint -> './layerActivation', optimizer -> +// './autoPaint'), it surfaced only as a confusing "a resource generated +// asynchronous activity after the test ended" message plus a silently lower +// test count (177 -> 85). These cases turn that into an explicit, named failure. +const modules = [ + '../src/lib/autoPaint.ts', + '../src/lib/optimizer.ts', + '../src/lib/layerActivation.ts', + '../src/lib/flatPaint.ts', + '../src/lib/meshing.ts', + '../src/lib/multiHeadAnalysis.ts', + '../src/lib/multiHeadAnalysisColorFirst.ts', + '../src/lib/multiHeadSchedule.ts', + '../src/lib/multiHeadSpatialVariance.ts', + '../src/lib/patchedLayersToPlan.ts', +]; + +for (const spec of modules) { + test(`lib module resolves all transitive imports: ${spec}`, async () => { + await assert.doesNotReject( + () => import(spec), + `${spec} (or something it imports) failed to resolve — check for an extensionless relative import` + ); + }); +} diff --git a/tests/meshingOptions.test.ts b/tests/meshingOptions.test.ts new file mode 100644 index 0000000..5774b6b --- /dev/null +++ b/tests/meshingOptions.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { generateGreedyMesh, generateSmoothMesh, type MeshData } from '../src/lib/meshing.ts'; + +// Re-applied during the develop rebase: the multi-head / per-colour-group build +// path passes skipBottomCap (stacked sub-meshes) and skipRepair (independent +// colour groups). These guard that wiring against future mesher refactors. + +const noYield = { yieldIntervalMs: Infinity, onYield: async () => undefined }; + +function maskFromRows(rows: string[]): { activePixels: Uint8Array; width: number; height: number } { + const width = rows[0].length; + const height = rows.length; + const activePixels = new Uint8Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (rows[y][x] === '#') activePixels[y * width + x] = 1; + } + } + return { activePixels, width, height }; +} + +// Count triangles whose geometric normal points downward (-Z) — i.e. bottom-cap faces. +function countDownwardFacingTriangles(mesh: MeshData): number { + const { positions, indices } = mesh; + let count = 0; + for (let i = 0; i < indices.length; i += 3) { + const a = indices[i] * 3; + const b = indices[i + 1] * 3; + const c = indices[i + 2] * 3; + const abx = positions[b] - positions[a]; + const aby = positions[b + 1] - positions[a + 1]; + const acx = positions[c] - positions[a]; + const acy = positions[c + 1] - positions[a + 1]; + const nz = abx * acy - aby * acx; // z-component of (B-A) x (C-A) + if (nz < -1e-6) count++; + } + return count; +} + +const meshers = [ + { name: 'greedy', generate: generateGreedyMesh }, + { name: 'smooth', generate: generateSmoothMesh }, +]; + +for (const { name, generate } of meshers) { + test(`${name}: skipBottomCap omits the downward-facing bottom cap`, async () => { + const mask = maskFromRows(['###', '###', '###']); + + const withCap = await generate(mask.activePixels, mask.width, mask.height, 1, 0, 1, 1, { + ...noYield, + }); + const withoutCap = await generate(mask.activePixels, mask.width, mask.height, 1, 0, 1, 1, { + ...noYield, + skipBottomCap: true, + }); + + assert.ok( + countDownwardFacingTriangles(withCap) > 0, + 'default build should emit bottom-cap (downward) faces' + ); + assert.equal( + countDownwardFacingTriangles(withoutCap), + 0, + 'skipBottomCap should emit no downward-facing faces' + ); + assert.ok( + withoutCap.indices.length < withCap.indices.length, + 'skipBottomCap should produce fewer triangles' + ); + }); +} + +test('skipRepair leaves diagonal corner-contacts split (default merges them)', async () => { + // An "X" of pixels that touch only at corners. repairBinaryCornerContacts + // welds the contacts (fewer triangles); skipRepair leaves each pixel as its + // own island (more triangles). + const mask = maskFromRows(['#.#', '.#.', '#.#']); + + const repaired = await generateGreedyMesh(mask.activePixels, 3, 3, 1, 0, 1, 1, { ...noYield }); + const unrepaired = await generateGreedyMesh(mask.activePixels, 3, 3, 1, 0, 1, 1, { + ...noYield, + skipRepair: true, + }); + + assert.ok( + unrepaired.indices.length > repaired.indices.length, + 'skipRepair should leave more (unwelded) geometry than the default repair pass' + ); +}); diff --git a/tests/multiHeadSchedule.test.ts b/tests/multiHeadSchedule.test.ts new file mode 100644 index 0000000..b1ed8de --- /dev/null +++ b/tests/multiHeadSchedule.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildMultiHeadSchedule } from '../src/lib/multiHeadSchedule.ts'; +import type { Filament, MultiHeadRangeAssignment } from '../src/types/index.ts'; +import type { WindowResult } from '../src/lib/multiHeadAnalysis.ts'; + +// Minimal window — buildMultiHeadSchedule only reads `windowStart`. +const win = (start: number): WindowResult => + ({ windowStart: start, windowEnd: start }) as unknown as WindowResult; + +const fil = (id: string, color: string): Filament => ({ id, color, td: 0.5 }); + +const FILAMENTS = [ + fil('A', '#aaaaaa'), + fil('B', '#bbbbbb'), + fil('C', '#cccccc'), + fil('D', '#dddddd'), +]; + +test('buildMultiHeadSchedule returns null without the required inputs', () => { + assert.equal(buildMultiHeadSchedule({}), null); + assert.equal( + buildMultiHeadSchedule({ multiHeadWindows: [win(0)], filaments: FILAMENTS }), + null, + 'needs nozzleAssignments + windowRunFilaments too' + ); +}); + +test('buildMultiHeadSchedule: pre-print load + a full 2-head swap at a window boundary', () => { + const events = buildMultiHeadSchedule({ + multiHeadWindows: [win(0), win(8)], + nozzleAssignments: [ + [0, 1], + [0, 1], + ], + windowRunFilaments: [ + ['A', 'B'], + ['C', 'D'], + ], + filaments: FILAMENTS, + }); + + assert.ok(events, 'expected a schedule'); + assert.equal(events!.length, 2); + + const [load, swap] = events!; + + // Pre-print event: layer 0, no swaps, both heads loaded. + assert.equal(load.startLayer, 0); + assert.equal(load.isPrePrint, true); + assert.equal(load.swapCount, 0); + assert.deepEqual( + load.nozzles.map((n) => [n.nozzle, n.filamentId, n.filamentHex, n.changed]), + [ + [1, 'A', '#aaaaaa', false], + [2, 'B', '#bbbbbb', false], + ] + ); + + // Swap checkpoint: windowStart 8 -> 1-based startLayer 9, both heads change. + assert.equal(swap.startLayer, 9); + assert.equal(swap.isPrePrint, false); + assert.equal(swap.swapCount, 2); + assert.deepEqual( + swap.nozzles.map((n) => [n.nozzle, n.filamentId, n.changed]), + [ + [1, 'C', true], + [2, 'D', true], + ] + ); +}); + +test('buildMultiHeadSchedule: only the heads that actually change are counted as swaps', () => { + const events = buildMultiHeadSchedule({ + multiHeadWindows: [win(0), win(4)], + nozzleAssignments: [ + [0, 1], + [0, 1], + ], + // head 1 keeps 'A', head 2 changes 'B' -> 'C' + windowRunFilaments: [ + ['A', 'B'], + ['A', 'C'], + ], + filaments: FILAMENTS, + }); + + const swap = events!.at(-1)!; + assert.equal(swap.startLayer, 5); + assert.equal(swap.swapCount, 1); + assert.deepEqual( + swap.nozzles.map((n) => n.changed), + [false, true] + ); +}); + +test('buildMultiHeadSchedule: non-windowed ranges participate and sort by start layer', () => { + const range: MultiHeadRangeAssignment = { + rangeStart: 12, + rangeEnd: 20, + nozzleFilaments: ['A', 'D'], + }; + const events = buildMultiHeadSchedule({ + multiHeadWindows: [win(0)], + nozzleAssignments: [[0, 1]], + windowRunFilaments: [['A', 'B']], + nonWindowedRanges: [range], + filaments: FILAMENTS, + }); + + assert.equal(events!.length, 2); + assert.equal(events![0].startLayer, 0); // pre-print (window at 0) + assert.equal(events![1].startLayer, 13); // range at 12 -> 1-based 13 + // head 1 stays 'A', head 2 'B' -> 'D' + assert.equal(events![1].swapCount, 1); +}); From 2d99c22247e86d293b2ccc20eff8f99328f8b3d8 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:52:18 -0500 Subject: [PATCH 03/10] Add flat paint support for 3D view with remapping of mesh Z range Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/App.tsx | 1 + src/components/ThreeDView.tsx | 171 +++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 49c61d5..1fc4340 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -733,6 +733,7 @@ function App(): React.ReactElement | null { ditherLineWidth={builtModelState.ditherLineWidth} smoothMeshing={builtModelState.smoothMeshing} isOrtho={isOrtho} + flatPaint={builtFlatPaint} /> {exportingSTL && ( (null); const [isBuilding, setIsBuilding] = useState(false); @@ -1133,6 +1168,137 @@ export default function ThreeDView({ } } + if (flatPaint) { + // === FLAT_PAINT: uniform face-down slab (ported from develop) === + // Reverse each pixel column so the visible blend layer touches the + // plate (mirrored in X so the artwork reads correctly once flipped), + // backfill behind the columns with the foundation filament, and add a + // transparent carrier first layer. + const orientedCounts = new Uint16Array(boxW * boxH); + { + const rawCounts = heightMapToFlatPaintLayerCounts( + pixelHeightMap, + cumulativeHeights, + layerHeight + ); + for (let y = 0; y < boxH; y++) { + const srcRow = y * boxW; + const dstRow = (boxH - 1 - y) * boxW; + for (let x = 0; x < boxW; x++) { + orientedCounts[dstRow + (boxW - 1 - x)] = rawCounts[srcRow + x]; + } + } + } + + const layout = buildFlatPaintLayout({ + layerCounts: orientedCounts, + width: boxW, + height: boxH, + layerCount: colorOrder.length, + layerHeight, + carrierThickness: Math.max(slicerFirstLayerHeight, layerHeight), + layerVirtualHexes: colorOrder.map( + (swatchIdx) => swatches[swatchIdx]?.hex ?? '#888888' + ), + layerFilamentHexes: colorOrder.map( + (swatchIdx) => + (filamentSwatches?.[swatchIdx] ?? swatches[swatchIdx])?.hex ?? + '#888888' + ), + }); + + const partCount = Math.max(1, layout.parts.length); + const scanSpanEnd = 1 / (colorOrder.length + 1); + const pushPartProgress = (partIndex: number, progress: number) => { + pushProgress( + progressInSpan( + scanSpanEnd, + 1 - scanSpanEnd, + (partIndex + clampProgress(progress)) / partCount + ) + ); + }; + + const flatMeshCache = new WeakMap>(); + const getFlatMaskMesh = (part: (typeof layout.parts)[number]) => { + const cached = flatMeshCache.get(part.mask); + if (cached) return cached; + // Flat Paint always uses the greedy mesher: smoothing would open + // gaps between side-by-side colour regions inside the slab. + const promise = generateGreedyMesh( + part.mask, + boxW, + boxH, + 1, + 0, + pixelSize, + 1, + { + yieldIntervalMs: 8, + onProgress: (progress: MeshProgress) => + pushPartProgress( + layout.parts.indexOf(part), + progressInSpan(0, 0.9, progress.progress) + ), + } + ); + flatMeshCache.set(part.mask, promise); + return promise; + }; + + for (let partIdx = 0; partIdx < layout.parts.length; partIdx++) { + const part = layout.parts[partIdx]; + if (token !== buildTokenRef.current) return; + if (part.activeCount === 0) continue; + + const generatedMesh = remapMeshZRange( + await getFlatMaskMesh(part), + part.baseZ, + part.topZ, + heightScale + ); + + const geom = createFlatShadedGeometry( + generatedMesh.positions, + generatedMesh.indices, + { + activePixels: part.mask, + width: boxW, + height: boxH, + pixelSize, + topZ: part.topZ * heightScale, + } + ); + const isCarrier = part.kind === 'carrier'; + const mat = new THREE.MeshStandardMaterial({ + color: part.previewHex, + side: THREE.FrontSide, + metalness: 0, + roughness: isCarrier ? 0.3 : 0.7, + flatShading: true, + transparent: isCarrier, + opacity: isCarrier ? 0.3 : 1, + }); + + const mesh = new THREE.Mesh(geom, mat); + // Slab Z range for the preview slider. + mesh.userData.baseZ = part.baseZ; + mesh.userData.topZ = part.topZ; + // Export metadata: one 3MF object per physical filament. + mesh.userData.kromacutExportGroup = part.exportGroup; + mesh.userData.kromacutFilamentHex = part.filamentHex; + mesh.userData.kromacutMaterialKey = part.exportGroup; + mesh.userData.kromacutPartName = part.partName; + modelGroup.add(mesh); + pushPartProgress(partIdx, 1); + + if (performance.now() - lastYield > YIELD_MS) { + await new Promise((r) => requestAnimationFrame(r)); + if (token !== buildTokenRef.current) return; + lastYield = performance.now(); + } + } + } else { // Multi-head per-pixel colour: classify each pixel to its nearest // image-palette colour once, so layer bands can be split by the // per-colour blended colour at that layer. @@ -1345,6 +1511,7 @@ export default function ThreeDView({ } console.groupEnd(); } + } } else { // === STANDARD MODE === // Prepare layers @@ -1678,6 +1845,8 @@ export default function ThreeDView({ colorSliceHeights, colorOrder, swatches, + filamentSwatches, + flatPaint, pixelSize, heightScale, stepped, From 230f72383cc24c4ea179f7ded69b2d0ac24d8ff8 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:52:35 -0500 Subject: [PATCH 04/10] Add multi-head printing assets and update related documentation Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/assets/diagrams/01_window_run_diagram.svg | 72 ++++++ .../diagrams/02_nozzle_swap_schedule.svg | 117 ++++++++++ .../diagrams/03_beer_lambert_blending.svg | 51 ++++ src/assets/diagrams/04_spatial_variance.svg | 219 ++++++++++++++++++ src/assets/diagrams/05_combo_search_space.svg | 180 ++++++++++++++ src/components/AutoPaintTab.tsx | 31 +-- src/components/docs/DocsPage.tsx | 1 + src/components/docs/MarkdownRenderer.tsx | 2 +- src/docs/assets.ts | 10 + src/types/index.ts | 2 +- 10 files changed, 663 insertions(+), 22 deletions(-) create mode 100644 src/assets/diagrams/01_window_run_diagram.svg create mode 100644 src/assets/diagrams/02_nozzle_swap_schedule.svg create mode 100644 src/assets/diagrams/03_beer_lambert_blending.svg create mode 100644 src/assets/diagrams/04_spatial_variance.svg create mode 100644 src/assets/diagrams/05_combo_search_space.svg 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/04_spatial_variance.svg b/src/assets/diagrams/04_spatial_variance.svg new file mode 100644 index 0000000..5c6abd7 --- /dev/null +++ b/src/assets/diagrams/04_spatial_variance.svg @@ -0,0 +1,219 @@ + + + +Spatial Variance Optimization +Single-extruder: 16 height levels. Adjacent pixels at different levels create height cliffs → high spatial variance. +Multi-head N=3: 16 levels collapse into M=⌈16/3⌉=6 phases. → low spatial variance + +Single-extruder (N=1) + +K = 16 height levels (1 – 16 rows) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Multi-head (N=3) + +M = ⌈16/3⌉ = 6 phase levels (1 – 6 rows) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +16 height levels · 5-colour palette (repeats) · N=3 heads · M=⌈16/3⌉=6 phases · ≤3 unique colours per layer level · Kromacut · spatial variance optimization + \ 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 30dbe8a..33e80e6 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -801,26 +801,17 @@ export default function AutoPaintTab({ - + { + const v = parseInt(e.target.value, 10); + if (v >= 2) setMultiHeadCount(v); + }} + />
    -
    - - -
    )}
    diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 0da866b..54bdcf6 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -7,7 +7,6 @@ import { Check, RotateCcw, Loader2 } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { autoPaintToSliceHeights } from '../lib/autoPaint'; import { runMultiHeadLayerAnalysisColorFirst } from '../lib/multiHeadAnalysisColorFirst'; -import { runMultiHeadSpatialVarianceOptimization, type SpatialVarianceResult } from '../lib/multiHeadSpatialVariance'; import { patchedLayersToPlan, patchedLayersToSliceData, buildPerColorLayerColors } from '../lib/patchedLayersToPlan'; import type { WindowResult } from '../lib/multiHeadAnalysis'; import { @@ -137,9 +136,6 @@ export default function ThreeDControls({ const [multiHeadSearchDepth, setMultiHeadSearchDepth] = useState<'fast' | 'balanced' | 'thorough'>( persisted?.multiHeadSearchDepth ?? 'balanced' ); - const [multiHeadOptimizationMode, setMultiHeadOptimizationMode] = useState<'color-accuracy' | 'spatial-variance'>( - persisted?.multiHeadOptimizationMode ?? 'color-accuracy' - ); const [multiHeadWindows, setMultiHeadWindows] = useState([]); useEffect(() => { @@ -187,10 +183,9 @@ export default function ThreeDControls({ multiHeadMode, multiHeadCount, multiHeadSearchDepth, - multiHeadOptimizationMode, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing, multiHeadMode, multiHeadCount, multiHeadSearchDepth, multiHeadOptimizationMode]); + }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing, multiHeadMode, multiHeadCount, multiHeadSearchDepth]); useEffect(() => { savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); @@ -347,23 +342,12 @@ export default function ThreeDControls({ hex: s.hex, count: (s as { count?: number }).count, })); - if (multiHeadOptimizationMode === 'spatial-variance') { - return runMultiHeadSpatialVarianceOptimization( - filaments, autoPaintResult, swatches, - layerHeight, slicerFirstLayerHeight, multiHeadCount - ); - } return runMultiHeadLayerAnalysisColorFirst( filaments, autoPaintResult, swatches, layerHeight, slicerFirstLayerHeight, multiHeadCount ); })(); - const svResult = (multiHeadOptimizationMode === 'spatial-variance' - ? (activeResult as SpatialVarianceResult | null) - : null); - const spatialVarianceTotalHeight = svResult?.spatialVarianceTotalHeight; - const newMultiHeadWindows = activeResult?.windows ?? []; const patchedTransitionZones = activeResult && activeResult.patchedLayers.length > 0 ? patchedLayersToPlan(activeResult.patchedLayers, filaments) @@ -411,8 +395,6 @@ export default function ThreeDControls({ multiHeadMode, multiHeadCount, multiHeadSearchDepth, - multiHeadOptimizationMode, - spatialVarianceTotalHeight, multiHeadWindows: newMultiHeadWindows, patchedTransitionZones, patchedSliceData, @@ -442,8 +424,6 @@ export default function ThreeDControls({ multiHeadMode, multiHeadCount, multiHeadSearchDepth, - multiHeadOptimizationMode, - spatialVarianceTotalHeight, multiHeadWindows: newMultiHeadWindows, patchedTransitionZones, patchedSliceData, @@ -480,9 +460,7 @@ export default function ThreeDControls({ multiHeadMode, multiHeadCount, multiHeadSearchDepth, - multiHeadOptimizationMode, filtered, - // spatialVarianceTotalHeight is derived inside handleApply; not a dep ]); return ( @@ -599,8 +577,6 @@ export default function ThreeDControls({ setMultiHeadCount={setMultiHeadCount} multiHeadSearchDepth={multiHeadSearchDepth} setMultiHeadSearchDepth={setMultiHeadSearchDepth} - multiHeadOptimizationMode={multiHeadOptimizationMode} - setMultiHeadOptimizationMode={setMultiHeadOptimizationMode} /> {/* Manual Tab */} diff --git a/src/docs/assets.ts b/src/docs/assets.ts index d947a4d..f6b9ae3 100644 --- a/src/docs/assets.ts +++ b/src/docs/assets.ts @@ -3,7 +3,6 @@ import tdTestImage from '@/assets/tdTest.png'; import diagramWindowRun from '@/assets/diagrams/01_window_run_diagram.svg'; import diagramNozzleSwap from '@/assets/diagrams/02_nozzle_swap_schedule.svg'; import diagramBeerLambert from '@/assets/diagrams/03_beer_lambert_blending.svg'; -import diagramSpatialVariance from '@/assets/diagrams/04_spatial_variance.svg'; import diagramComboSearch from '@/assets/diagrams/05_combo_search_space.svg'; const DOC_ASSETS: Record = { @@ -12,7 +11,6 @@ const DOC_ASSETS: Record = { '01_window_run_diagram.svg': diagramWindowRun, '02_nozzle_swap_schedule.svg': diagramNozzleSwap, '03_beer_lambert_blending.svg': diagramBeerLambert, - '04_spatial_variance.svg': diagramSpatialVariance, '05_combo_search_space.svg': diagramComboSearch, }; diff --git a/src/lib/multiHeadSpatialVariance.ts b/src/lib/multiHeadSpatialVariance.ts deleted file mode 100644 index 3915881..0000000 --- a/src/lib/multiHeadSpatialVariance.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Multi-head spatial-variance minimization optimizer. - * - * Groups K image colours into M = ⌈K/N⌉ phases (bands), each holding up to N - * colours. All pixels whose colour is in phase j print at the same height, - * collapsing the height map from K distinct levels to M. The reduction follows - * the plan in spatial-variance-plan.txt §§3.1–3.3. - * - * Spectral-ordering proxy: colours are sorted by luminance. For natural images - * luminance is strongly correlated with spatial adjacency (the adjacency graph - * Laplacian's Fiedler vector), so luminance rank is a fast, accurate stand-in - * for the full spectral sort. - * - * Output shape: identical to ColorFirstResult so it drops straight into the - * patchedLayersToPlan / buildPerColorLayerColors render path unchanged. - */ - -import type { AutoPaintResult } from './autoPaint.ts'; -import type { Filament } from '../types/index.ts'; -import { hexToRgb, getLuminance, deltaE, type RGB } from './autoPaint.ts'; -import type { PrinterLayer, WindowResult } from './multiHeadAnalysis.ts'; -import type { ColorFirstResult } from './multiHeadAnalysisColorFirst.ts'; -import { optimizeNozzleAssignments } from './multiHeadAnalysisColorFirst.ts'; - -const FRONTLIT_TD_SCALE = 0.1; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -export interface SpatialVarianceResult extends ColorFirstResult { - /** - * Total height of the spatial-variance model in mm. - * = max(layerHeight, firstLayerHeight) + (M−1) × layerHeight - * Pass this as autoPaintTotalHeight to ThreeDView so the standard - * luminance → height mapping naturally quantises to exactly M levels. - */ - spatialVarianceTotalHeight: number; - /** Phase index [0, M) for each image-palette hex. */ - phaseOf: Map; - /** Number of phases M = ⌈K/N⌉. */ - phaseCount: number; -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** Lab ΔE to the nearest filament; returns that filament's index. */ -function nearestFilamentIndex(target: RGB, filaments: Filament[]): number { - let best = 0; - let bestDE = Infinity; - for (let i = 0; i < filaments.length; i++) { - const d = deltaE(target, hexToRgb(filaments[i].color)); - if (d < bestDE) { bestDE = d; best = i; } - } - return best; -} - -/** RGB centroid of a non-empty colour list. */ -function centroidRgb(rgbs: RGB[]): RGB { - const n = rgbs.length; - return { - r: rgbs.reduce((s, c) => s + c.r, 0) / n, - g: rgbs.reduce((s, c) => s + c.g, 0) / n, - b: rgbs.reduce((s, c) => s + c.b, 0) / n, - }; -} - -// --------------------------------------------------------------------------- -// Variance proxy (plan §3.2) — used by local-search refinement -// --------------------------------------------------------------------------- - -/** - * Compute Σ_{A, - bands: number[] -): number { - const K = colors.length; - let s = 0; - for (let a = 0; a < K; a++) { - for (let b = a + 1; b < K; b++) { - const lumDist = Math.abs(colors[a].lum - colors[b].lum); - const w = 1 / (lumDist * lumDist + 1e-4); - const d = bands[a] - bands[b]; - s += w * d * d; - } - } - return s; -} - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- - -/** - * Spatial-variance multi-head optimizer — drop-in analogue of - * runMultiHeadLayerAnalysisColorFirst for the spatial-variance objective. - * - * @param filaments Available filament set (N heads loaded simultaneously). - * @param _result AutoPaintResult (signature parity; not used by this path). - * @param imageSwatches Unique image-palette entries from the quantised image. - * @param layerHeight Printer layer height in mm. - * @param firstLayerHeight Slicer first-layer height in mm. - * @param n Number of print heads (≥ 1). - */ -export function runMultiHeadSpatialVarianceOptimization( - filaments: Filament[], - _result: AutoPaintResult, - imageSwatches: Array<{ hex: string; count?: number }>, - layerHeight: number, - firstLayerHeight: number, - n: number -): SpatialVarianceResult { - const empty: SpatialVarianceResult = { - windows: [], - colorAssignments: [], - uniqueLayerCount: 0, - patchedLayers: [], - colorLayerFilaments: new Map(), - windowRunFilaments: [], - nozzleAssignments: [], - preWindowFilaments: [], - nonWindowedRanges: [], - spatialVarianceTotalHeight: 0, - phaseOf: new Map(), - phaseCount: 0, - }; - - const N = Math.min(n, filaments.length); - if (N < 1 || imageSwatches.length === 0) return empty; - - // ------------------------------------------------------------------ - // 1. Deduplicate image colours and sort by luminance (spectral proxy). - // ------------------------------------------------------------------ - const seen = new Set(); - const uniqueColors: Array<{ hex: string; rgb: RGB; lum: number; count: number }> = []; - for (const s of imageSwatches) { - if (seen.has(s.hex)) continue; - seen.add(s.hex); - const rgb = hexToRgb(s.hex); - uniqueColors.push({ - hex: s.hex, - rgb, - lum: getLuminance(rgb) / 255, - count: s.count ?? 1, - }); - } - uniqueColors.sort((a, b) => a.lum - b.lum); - - const K = uniqueColors.length; - const M = Math.ceil(K / N); - - // ------------------------------------------------------------------ - // 2. Initial band assignment: consecutive N-sized groups of the - // luminance-sorted colour list → band index = floor(i / N). - // ------------------------------------------------------------------ - // Single-head baseline: every colour at its own unique height level. - const singleHeadBands: number[] = uniqueColors.map((_, i) => i); - const varSingleHead = varianceProxy(uniqueColors, singleHeadBands); - - // Initial multi-head assignment: consecutive N-sized luminance groups. - const bands: number[] = uniqueColors.map((_, i) => Math.floor(i / N)); - const varInitial = varianceProxy(uniqueColors, bands); - - // ------------------------------------------------------------------ - // 3. Local-search refinement (plan §3.3 tryMoveOrSwap). - // Swap pairs of colours between adjacent bands if the variance proxy - // strictly decreases. One sweep is usually sufficient for the - // luminance-proximity proxy (which already yields a near-optimal - // ordering); iterate until convergence. - // ------------------------------------------------------------------ - const bandSize = (b: number) => bands.filter(x => x === b).length; - let improved = true; - while (improved) { - improved = false; - for (let a = 0; a < K; a++) { - for (const target of [bands[a] - 1, bands[a] + 1]) { - if (target < 0 || target >= M) continue; - - // Try moving colour a into target band. - const origBands = bands.slice(); - const origSize = bandSize(bands[a]); - const targetSize = bandSize(target); - - if (targetSize < N) { - // Spare slot — just move. - bands[a] = target; - if (varianceProxy(uniqueColors, bands) < varianceProxy(uniqueColors, origBands)) { - improved = true; - } else { - bands[a] = origBands[a]; // revert - } - } else if (origSize > 1) { - // No spare slot; try swapping a with every colour in target band. - for (let b = 0; b < K; b++) { - if (bands[b] !== target) continue; - bands[a] = target; - bands[b] = origBands[a]; - if (varianceProxy(uniqueColors, bands) < varianceProxy(uniqueColors, origBands)) { - improved = true; - break; - } - // Revert swap. - bands[a] = origBands[a]; - bands[b] = origBands[b]; - } - } - } - } - } - - // ------------------------------------------------------------------ - // 4. Materialise phase groups from final band assignment. - // ------------------------------------------------------------------ - const phaseOf = new Map(); - const phaseColors: typeof uniqueColors[] = Array.from({ length: M }, () => []); - for (let i = 0; i < K; i++) { - phaseOf.set(uniqueColors[i].hex, bands[i]); - phaseColors[bands[i]].push(uniqueColors[i]); - } - - // ------------------------------------------------------------------ - // 5. Nearest-filament assignment for each image colour. - // ------------------------------------------------------------------ - const filamentFor = new Map(); - for (const c of uniqueColors) { - filamentFor.set(c.hex, nearestFilamentIndex(c.rgb, filaments)); - } - - // ------------------------------------------------------------------ - // 6. Build M printer layers (one per phase, each layerHeight thick). - // Layer 0 uses max(layerHeight, firstLayerHeight) to respect the - // slicer's minimum first-layer requirement. - // ------------------------------------------------------------------ - const effectiveFirstLayer = Math.max(layerHeight, firstLayerHeight); - const patchedLayers: PrinterLayer[] = []; - for (let j = 0; j < M; j++) { - const group = phaseColors[j]; - const repFilamentIdx = group.length > 0 - ? nearestFilamentIndex(centroidRgb(group.map(c => c.rgb)), filaments) - : 0; - const repFilament = filaments[repFilamentIdx]; - const thickness = j === 0 ? effectiveFirstLayer : layerHeight; - const startZ = j === 0 ? 0 : effectiveFirstLayer + (j - 1) * layerHeight; - patchedLayers.push({ - startZ, - thickness, - filamentIdx: repFilamentIdx, - filamentRgb: hexToRgb(repFilament.color), - td: repFilament.td * FRONTLIT_TD_SCALE, - }); - } - - // Total height used by ThreeDView to scale the luminance → height map. - // With this value, luminance snapped to layerHeight grid gives exactly M - // discrete levels matching the M phases. - const spatialVarianceTotalHeight = - patchedLayers[M - 1].startZ + patchedLayers[M - 1].thickness; - - // ------------------------------------------------------------------ - // 7. Per-colour filament-per-layer sequences. - // Colour C in phase j: - // layers 0 … j−1 → filament 0 (support; not visible in opaque model) - // layers j … M−1 → nearest filament for C - // ------------------------------------------------------------------ - const colorLayerFilaments = new Map(); - for (const [hex, phase] of phaseOf) { - const assigned = filamentFor.get(hex) ?? 0; - const seq = new Array(M); - for (let j = 0; j < M; j++) { - seq[j] = j < phase ? 0 : assigned; - } - colorLayerFilaments.set(hex, seq); - } - - // ------------------------------------------------------------------ - // 8. WindowResult entries (one per phase) for the swap-plan display. - // ------------------------------------------------------------------ - const windows: WindowResult[] = phaseColors.map((colors, j) => { - const uniqueFI = [...new Set(colors.map(c => filamentFor.get(c.hex) ?? 0))]; - return { - windowStart: j, - windowEnd: j, - windowBottomZ: patchedLayers[j].startZ, - windowTopZ: patchedLayers[j].startZ + patchedLayers[j].thickness, - currentFilaments: uniqueFI.map( - fi => filaments[fi]?.name ?? filaments[fi]?.color ?? `f${fi}` - ), - filamentIds: uniqueFI.map(fi => filaments[fi]?.id ?? `f${fi}`), - affectedSwatches: colors.reduce((s, c) => s + c.count, 0), - errorFactor: 0, - lut: [], - pixelOptimalLUTIdx: [], - }; - }); - - // ------------------------------------------------------------------ - // 9. Schedule: nozzle assignments across phase transitions. - // windowRunFilaments[j] = filament IDs active in phase j. - // optimizeNozzleAssignments minimises head swaps across M-1 transitions. - // ------------------------------------------------------------------ - const windowRunFilaments: string[][] = windows.map(w => w.filamentIds); - const nozzleAssignments = optimizeNozzleAssignments(windowRunFilaments, N); - // In SV mode every layer belongs to a phase — no non-windowed gaps. - const nonWindowedRanges: ColorFirstResult['nonWindowedRanges'] = []; - const preWindowFilaments: string[] = []; - - const varAfter = varianceProxy(uniqueColors, bands); - - // Reduction vs single-head baseline (the meaningful improvement). - const pctVsSingle = varSingleHead > 0 - ? ((varSingleHead - varAfter) / varSingleHead * 100).toFixed(1) - : '0.0'; - // Additional improvement from local search on top of the initial bands. - const pctLocalSearch = varInitial > 0 - ? ((varInitial - varAfter) / varInitial * 100).toFixed(1) - : '0.0'; - - console.group( - `[SpatialVariance] K=${K} colours → M=${M} phases × N=${N} heads` + - ` | totalHeight=${spatialVarianceTotalHeight.toFixed(3)} mm` - ); - console.log(` Single-head baseline (K=${K} heights): ${varSingleHead.toFixed(2)}`); - console.log(` Multi-head optimized (M=${M} heights): ${varAfter.toFixed(2)}` + - ` (${pctVsSingle}% reduction vs single-head)`); - if (pctLocalSearch !== '0.0') { - console.log(` Local-search refinement: ${pctLocalSearch}% additional improvement`); - } - console.log(` Swaps (M−1): ${M - 1} | Phase sizes: ${Array.from({ length: M }, (_, j) => - phaseColors[j].length - ).join(', ')}`); - console.groupEnd(); - - return { - windows, - colorAssignments: phaseColors.map((colors) => { - const map = new Map(); - colors.forEach((c, i) => map.set(c.hex, [i % N])); - return map; - }), - uniqueLayerCount: M, - patchedLayers, - colorLayerFilaments, - windowRunFilaments, - nozzleAssignments, - preWindowFilaments, - nonWindowedRanges, - spatialVarianceTotalHeight, - phaseOf, - phaseCount: M, - }; -} diff --git a/src/types/index.ts b/src/types/index.ts index ce94c14..e9ac8a3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -62,13 +62,6 @@ export interface ThreeDControlsStateShape { multiHeadMode?: boolean; multiHeadCount?: number; // any integer ≥ 2 multiHeadSearchDepth?: 'fast' | 'balanced' | 'thorough'; - multiHeadOptimizationMode?: 'color-accuracy' | 'spatial-variance'; - /** - * Total height override for spatial-variance mode (M * layerHeight). - * When set, ThreeDView uses this instead of autoPaintResult.totalHeight so - * the luminance → height mapping quantises to exactly M discrete levels. - */ - spatialVarianceTotalHeight?: number; multiHeadWindows?: WindowResult[]; /** Reordered transition zones derived from the multi-head patched layer stack. */ patchedTransitionZones?: TransitionZone[]; From e1699c5f369ff72eb1461b746fca3591112caef1 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:03:23 -0500 Subject: [PATCH 06/10] Refactor swap plan logic and improve UI feedback for color limits Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/App.tsx | 2 +- src/components/PrintInstructions.tsx | 11 +++++------ src/components/ThreeDControls.tsx | 1 - src/hooks/useSwapPlan.ts | 5 +---- src/types/index.ts | 2 +- 5 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b5cced0..380e6a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -441,7 +441,7 @@ function App(): React.ReactElement | null { filaments: builtModelState.filaments, }) ?.filter((e) => e.startLayer > 0 && e.swapCount > 0) - .map((e) => ({ + ?.map((e) => ({ layer: e.startLayer, color: e.nozzles.find((n) => n.changed)?.filamentHex, })) diff --git a/src/components/PrintInstructions.tsx b/src/components/PrintInstructions.tsx index 26fd357..0fc0888 100644 --- a/src/components/PrintInstructions.tsx +++ b/src/components/PrintInstructions.tsx @@ -41,7 +41,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' @@ -55,6 +54,11 @@ export default function PrintInstructions({
    + {tooManyColors && ( +
    + This print has {colorCount} layers — swap instructions may be slow to generate above 64. +
    + )} {/* Recommended Settings */}
    Recommended Settings
    @@ -96,11 +100,6 @@ export default function PrintInstructions({
  • After printing, flip the piece over to view the image.
  • - ) : tooManyColors ? ( -
    - Swap instructions are disabled for very large palettes ({colorCount} colors). - Reduce the image to 256 colors or fewer in 2D mode first. -
    ) : multiHeadPlan ? ( ) : multiHeadMode ? ( diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 54bdcf6..a4208cb 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -325,7 +325,6 @@ export default function ThreeDControls({ preWindowFilaments: persisted?.preWindowFilaments, nonWindowedRanges: persisted?.nonWindowedRanges, filaments, - disabled: isInstructionOverLimit, flatPaint: instructionFlatPaint, }); diff --git a/src/hooks/useSwapPlan.ts b/src/hooks/useSwapPlan.ts index a721436..d4dd012 100644 --- a/src/hooks/useSwapPlan.ts +++ b/src/hooks/useSwapPlan.ts @@ -31,7 +31,6 @@ export interface UseSwapPlanOptions { preWindowFilaments?: string[]; nonWindowedRanges?: MultiHeadRangeAssignment[]; filaments?: Filament[]; - disabled?: boolean; /** Flat Paint prints have no manual swap sequence (multi-material per layer) */ flatPaint?: boolean; } @@ -50,11 +49,10 @@ export function useSwapPlan({ windowRunFilaments, nonWindowedRanges, filaments, - disabled = false, flatPaint = false, }: UseSwapPlanOptions) { const swapPlan = useMemo(() => { - if (disabled || flatPaint) { + if (flatPaint) { return [] as SwapEntry[]; } @@ -141,7 +139,6 @@ export function useSwapPlan({ autoPaintResult, multiHeadWindows, patchedTransitionZones, - disabled, flatPaint, ]); diff --git a/src/types/index.ts b/src/types/index.ts index e9ac8a3..484732f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,7 +44,7 @@ export interface ThreeDControlsStateShape { colorOrder: number[]; filteredSwatches: Swatch[]; pixelSize: number; // mm per pixel (XY) - smoothMeshing?: boolean; // boundary-chain smoothed grid meshing + smoothMeshing?: boolean; // Smooth connected boundaries using welded grid topology filaments: Filament[]; paintMode: 'manual' | 'autopaint'; // Enhanced color matching options From 5ba2f01305a74640789cb00826eb01058da7051c Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:13:50 -0500 Subject: [PATCH 07/10] Remove unused multiHeadSpatialVariance module from test imports Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- tests/libModuleResolution.test.ts | 1 - tests/multiHeadSpatialVariance.test.ts | 452 ----------------------- tmp_implementation_plan.txt | 472 ------------------------- 3 files changed, 925 deletions(-) delete mode 100644 tests/multiHeadSpatialVariance.test.ts delete mode 100644 tmp_implementation_plan.txt diff --git a/tests/libModuleResolution.test.ts b/tests/libModuleResolution.test.ts index 4d21667..6e99420 100644 --- a/tests/libModuleResolution.test.ts +++ b/tests/libModuleResolution.test.ts @@ -19,7 +19,6 @@ const modules = [ '../src/lib/multiHeadAnalysis.ts', '../src/lib/multiHeadAnalysisColorFirst.ts', '../src/lib/multiHeadSchedule.ts', - '../src/lib/multiHeadSpatialVariance.ts', '../src/lib/patchedLayersToPlan.ts', ]; diff --git a/tests/multiHeadSpatialVariance.test.ts b/tests/multiHeadSpatialVariance.test.ts deleted file mode 100644 index 80980e9..0000000 --- a/tests/multiHeadSpatialVariance.test.ts +++ /dev/null @@ -1,452 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { generateAutoLayers } from '../src/lib/autoPaint.ts'; -import { - runMultiHeadSpatialVarianceOptimization, - type SpatialVarianceResult, -} from '../src/lib/multiHeadSpatialVariance.ts'; -import type { Filament } from '../src/types/index.ts'; - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const LAYER_HEIGHT = 0.12; -const FIRST_LAYER_HEIGHT = 0.20; - -function filament(id: string, color: string, td: number, name?: string): Filament { - return { id, color, td, name }; -} - -const BLACK = filament('black', '#000000', 1.0, 'Black'); -const DARK = filament('dark', '#333333', 1.0, 'Dark'); -const MID = filament('mid', '#888888', 1.0, 'Mid'); -const LIGHT = filament('light', '#cccccc', 1.0, 'Light'); -const WHITE = filament('white', '#ffffff', 1.0, 'White'); - -/** Dummy AutoPaintResult produced by generating 1-colour auto-layers. */ -function dummyResult() { - return generateAutoLayers([BLACK], [{ hex: '#808080' }], LAYER_HEIGHT, FIRST_LAYER_HEIGHT); -} - -/** Build K evenly-spaced greyscale swatches. */ -function greySwatches(K: number): Array<{ hex: string; count: number }> { - return Array.from({ length: K }, (_, i) => { - const v = K === 1 ? 128 : Math.round((i / (K - 1)) * 255); - const h = v.toString(16).padStart(2, '0'); - return { hex: `#${h}${h}${h}`, count: 1 }; - }); -} - -// --------------------------------------------------------------------------- -// Basic shape -// --------------------------------------------------------------------------- - -test('returns empty result for empty swatches', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.patchedLayers.length, 0); - assert.equal(r.spatialVarianceTotalHeight, 0); - assert.equal(r.phaseCount, 0); -}); - -test('returns empty result when no filaments', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.patchedLayers.length, 0); -}); - -// --------------------------------------------------------------------------- -// Phase count M = ⌈K/N⌉ -// --------------------------------------------------------------------------- - -test('K=1, N=2 → M=1 (one phase)', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(1), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.phaseCount, 1); - assert.equal(r.patchedLayers.length, 1); - assert.equal(r.windows.length, 1); -}); - -test('K=2, N=2 → M=1 (all colours fit in one phase)', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(2), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.phaseCount, 1); - assert.equal(r.patchedLayers.length, 1); -}); - -test('K=4, N=2 → M=2 phases', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.phaseCount, 2); - assert.equal(r.patchedLayers.length, 2); - assert.equal(r.windows.length, 2); -}); - -test('K=6, N=2 → M=3 phases', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.phaseCount, 3); - assert.equal(r.patchedLayers.length, 3); -}); - -test('K=5, N=2 → M=3 (ceil division)', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(5), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.phaseCount, 3); -}); - -test('K=4, N=4 → M=1 (all colours fit in one phase)', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, DARK, LIGHT, WHITE], dummyResult(), greySwatches(4), - LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 4 - ); - assert.equal(r.phaseCount, 1); - assert.equal(r.patchedLayers.length, 1); -}); - -// --------------------------------------------------------------------------- -// Layer heights -// --------------------------------------------------------------------------- - -test('layer 0 uses max(layerHeight, firstLayerHeight) as thickness', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - const effective = Math.max(LAYER_HEIGHT, FIRST_LAYER_HEIGHT); - assert.equal(r.patchedLayers[0].thickness, effective); - assert.equal(r.patchedLayers[0].startZ, 0); -}); - -test('subsequent layers use layerHeight', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (let j = 1; j < r.patchedLayers.length; j++) { - assert.equal(r.patchedLayers[j].thickness, LAYER_HEIGHT); - } -}); - -test('spatialVarianceTotalHeight = effectiveFirstLayer + (M-1)*layerHeight', () => { - const fourFilaments = [BLACK, DARK, LIGHT, WHITE]; - const effective = Math.max(LAYER_HEIGHT, FIRST_LAYER_HEIGHT); - - const cases: Array<[number, number, number, Filament[]]> = [ - [4, 2, 2, [BLACK, WHITE]], - [6, 2, 3, [BLACK, WHITE]], - [4, 4, 1, fourFilaments], - ]; - - for (const [K, N, M, fils] of cases) { - const r = runMultiHeadSpatialVarianceOptimization( - fils, dummyResult(), greySwatches(K), - LAYER_HEIGHT, FIRST_LAYER_HEIGHT, N - ); - const expected = effective + (M - 1) * LAYER_HEIGHT; - assert.ok( - Math.abs(r.spatialVarianceTotalHeight - expected) < 1e-9, - `K=${K},N=${N}: expected ${expected}, got ${r.spatialVarianceTotalHeight}` - ); - } -}); - -// --------------------------------------------------------------------------- -// Phase assignment — darkest colours go to lowest phase -// --------------------------------------------------------------------------- - -test('K=4, N=2: two darkest colours in phase 0, two lightest in phase 1', () => { - // Swatches sorted darkest→lightest: #000000, #555555, #aaaaaa, #ffffff - const swatches = [ - { hex: '#000000', count: 1 }, - { hex: '#555555', count: 1 }, - { hex: '#aaaaaa', count: 1 }, - { hex: '#ffffff', count: 1 }, - ]; - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.phaseOf.get('#000000'), 0); - assert.equal(r.phaseOf.get('#555555'), 0); - assert.equal(r.phaseOf.get('#aaaaaa'), 1); - assert.equal(r.phaseOf.get('#ffffff'), 1); -}); - -// --------------------------------------------------------------------------- -// colorLayerFilaments -// --------------------------------------------------------------------------- - -test('every image colour has a sequence of length M', () => { - const swatches = greySwatches(6); - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (const [, seq] of r.colorLayerFilaments) { - assert.equal(seq.length, r.phaseCount); - } -}); - -test('all K image colours have an entry in colorLayerFilaments', () => { - const swatches = greySwatches(6); - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (const s of swatches) { - assert.ok( - r.colorLayerFilaments.has(s.hex), - `Missing colorLayerFilaments entry for ${s.hex}` - ); - } -}); - -test('phase-0 colours use assigned filament at all M layers', () => { - // With K=4, N=2, phase-0 colours are the two darkest. - const swatches = [ - { hex: '#000000', count: 1 }, // phase 0 - { hex: '#555555', count: 1 }, // phase 0 - { hex: '#aaaaaa', count: 1 }, // phase 1 - { hex: '#ffffff', count: 1 }, // phase 1 - ]; - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ) as SpatialVarianceResult; - - // Phase-0 colours: all layers use the assigned filament (no support layers below them). - for (const hex of ['#000000', '#555555']) { - const seq = r.colorLayerFilaments.get(hex)!; - assert.ok(seq, `Missing sequence for ${hex}`); - // All M layers should be the assigned filament (phase 0 → no layers below). - for (let j = 0; j < r.phaseCount; j++) { - assert.equal(typeof seq[j], 'number'); - } - } -}); - -test('phase-1 colour uses filament 0 at layer 0, assigned filament at layer 1', () => { - const swatches = [ - { hex: '#000000', count: 1 }, // phase 0 - { hex: '#555555', count: 1 }, // phase 0 - { hex: '#aaaaaa', count: 1 }, // phase 1 - { hex: '#ffffff', count: 1 }, // phase 1 - ]; - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ) as SpatialVarianceResult; - - for (const hex of ['#aaaaaa', '#ffffff']) { - const seq = r.colorLayerFilaments.get(hex)!; - assert.ok(seq, `Missing sequence for ${hex}`); - // Layer 0 should use base filament (index 0). - assert.equal(seq[0], 0, `${hex}: expected base filament at layer 0`); - // Layer 1 should use the assigned filament. - assert.equal(typeof seq[1], 'number'); - } -}); - -// --------------------------------------------------------------------------- -// Windows -// --------------------------------------------------------------------------- - -test('windows are non-overlapping and cover all M phases', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.windows.length, r.phaseCount); - for (let j = 0; j < r.windows.length; j++) { - assert.equal(r.windows[j].windowStart, j); - assert.equal(r.windows[j].windowEnd, j); - } -}); - -test('window Z ranges are contiguous and cover total height', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - // First window starts at Z=0. - assert.ok(Math.abs(r.windows[0].windowBottomZ) < 1e-9); - // Last window top = totalHeight. - const lastW = r.windows[r.windows.length - 1]; - assert.ok( - Math.abs(lastW.windowTopZ - r.spatialVarianceTotalHeight) < 1e-9, - `last window top ${lastW.windowTopZ} ≠ totalHeight ${r.spatialVarianceTotalHeight}` - ); - // Consecutive windows share a boundary. - for (let j = 1; j < r.windows.length; j++) { - assert.ok( - Math.abs(r.windows[j].windowBottomZ - r.windows[j - 1].windowTopZ) < 1e-9, - `gap between windows ${j - 1} and ${j}` - ); - } -}); - -// --------------------------------------------------------------------------- -// N-head cap: n is capped to filaments.length -// --------------------------------------------------------------------------- - -test('n > filaments.length is clamped to filaments.length', () => { - // 2 filaments, ask for n=10 heads → effective N=2 → M=ceil(4/2)=2 - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 10 - ); - assert.equal(r.phaseCount, 2); // K=4, effective N=2 → M=2 -}); - -// --------------------------------------------------------------------------- -// Scheduling fields (windowRunFilaments, nozzleAssignments, nonWindowedRanges, -// preWindowFilaments) — added with the scheduling layer implementation. -// --------------------------------------------------------------------------- - -test('empty result has all scheduling fields as empty arrays', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), [], LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.deepEqual(r.windowRunFilaments, []); - assert.deepEqual(r.nozzleAssignments, []); - assert.deepEqual(r.preWindowFilaments, []); - assert.deepEqual(r.nonWindowedRanges, []); -}); - -test('windowRunFilaments has one entry per phase', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - // K=4, N=2 → M=2 phases - assert.equal(r.windowRunFilaments.length, r.phaseCount); -}); - -test('windowRunFilaments entries contain valid filament IDs', () => { - const filamentSet = [BLACK, DARK, MID, LIGHT, WHITE]; - const validIds = new Set(filamentSet.map(f => f.id)); - const r = runMultiHeadSpatialVarianceOptimization( - filamentSet, dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (const phaseIds of r.windowRunFilaments) { - assert.ok(phaseIds.length > 0, 'each phase must have at least one filament ID'); - for (const id of phaseIds) { - assert.ok(validIds.has(id), `unknown filament ID: ${id}`); - } - } -}); - -test('windowRunFilaments matches windows[j].filamentIds', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (let j = 0; j < r.phaseCount; j++) { - assert.deepEqual( - r.windowRunFilaments[j], - r.windows[j].filamentIds, - `phase ${j}: windowRunFilaments ≠ windows[j].filamentIds` - ); - } -}); - -test('nozzleAssignments has one entry per phase', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.equal(r.nozzleAssignments.length, r.phaseCount); -}); - -test('nozzleAssignments[j] has length N (one slot per nozzle)', () => { - const N = 2; - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, N - ); - for (let j = 0; j < r.phaseCount; j++) { - assert.equal( - r.nozzleAssignments[j].length, N, - `phase ${j}: expected ${N} nozzle slots` - ); - } -}); - -test('nozzleAssignments slots are valid run-slot indices or -1 (idle)', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (let j = 0; j < r.phaseCount; j++) { - const K = r.windowRunFilaments[j].length; - for (const slot of r.nozzleAssignments[j]) { - assert.ok( - slot === -1 || (slot >= 0 && slot < K), - `phase ${j}: slot ${slot} out of range [−1, ${K})` - ); - } - } -}); - -test('each phase has at least one active nozzle (not all idle)', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (let j = 0; j < r.phaseCount; j++) { - const activeCount = r.nozzleAssignments[j].filter(s => s !== -1).length; - assert.ok(activeCount > 0, `phase ${j}: all nozzles idle`); - } -}); - -test('every filament in windowRunFilaments[j] is assigned to exactly one nozzle', () => { - // Verifies the assignment is injective (no two nozzles share the same run-slot). - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - for (let j = 0; j < r.phaseCount; j++) { - const active = r.nozzleAssignments[j].filter(s => s !== -1); - const unique = new Set(active); - assert.equal(unique.size, active.length, `phase ${j}: duplicate nozzle→slot assignment`); - // Every run slot must be covered by exactly one active nozzle. - const K = r.windowRunFilaments[j].length; - assert.equal(active.length, K, `phase ${j}: ${K} filaments but ${active.length} active nozzles`); - } -}); - -test('nonWindowedRanges is always empty (all layers are in phases)', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.deepEqual(r.nonWindowedRanges, []); -}); - -test('preWindowFilaments is always empty', () => { - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(4), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 - ); - assert.deepEqual(r.preWindowFilaments, []); -}); - -test('nozzle swap count is minimal for a monotone ordering (0 or 1 swap)', () => { - // With 2 heads and 3 phases, the optimizer should find a schedule with - // at most 1 nozzle swap: the idle nozzle at phase 0 can carry its filament - // into phase 1 for free if the filament reappears there. - // We just verify the result doesn't exceed the theoretical maximum (N * (M-1)). - const N = 2; - const r = runMultiHeadSpatialVarianceOptimization( - [BLACK, WHITE], dummyResult(), greySwatches(6), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, N - ); - const M = r.phaseCount; // 3 - - let totalSwaps = 0; - let prev: number[] = []; - for (let j = 0; j < M; j++) { - const cur = r.nozzleAssignments[j]; - const runs = r.windowRunFilaments[j]; - if (j > 0) { - const prevRuns = r.windowRunFilaments[j - 1]; - const curFilIds = cur.map(s => s === -1 ? prev[cur.indexOf(s)] : runs[s]); - const prevFilIds = prev.map(s => s === -1 ? '' : prevRuns[s] ?? ''); - for (let k = 0; k < N; k++) { - if (curFilIds[k] !== prevFilIds[k]) totalSwaps++; - } - } - prev = cur; - } - // Upper bound: every nozzle could change at every transition. - assert.ok(totalSwaps <= N * (M - 1), `totalSwaps ${totalSwaps} exceeds max ${N * (M - 1)}`); -}); diff --git a/tmp_implementation_plan.txt b/tmp_implementation_plan.txt deleted file mode 100644 index 97b7334..0000000 --- a/tmp_implementation_plan.txt +++ /dev/null @@ -1,472 +0,0 @@ -Multi-Head Reordered Layer Shipping Plan -========================================== -Goal: Take the multi-head iterative analysis result and produce a 3MF that a -slicer can execute, including additional filament swaps and correctly ordered -material assignments within each reordered window. - -Bird's-eye steps (this document expands each): - 1. Expose patchedLayers from ColorFirstResult - 2. patchedLayers → new transition plan (TransitionZone[]) - 3. Swap plan integration - 4. 3D mesh generation with reordered layers - 5. 3MF material assignment - -Current data flow (for reference): - ThreeDControls → ThreeDControlsStateShape → App - ↓ ↓ - ThreeDView useAppHandlers - (mesh build) (3MF export) - -After this work, the patched result must reach both ThreeDView and -useAppHandlers so geometry and export agree on the reordered ordering. - - -=========================================================================== -STEP 1 — Expose patchedLayers from ColorFirstResult -=========================================================================== - -File: src/lib/multiHeadAnalysisColorFirst.ts - -WHY ---- -The iterative loop in runMultiHeadLayerAnalysisColorFirst patches layers[] in- -place via applyComboToLayers. After all iterations that array is the authoritative -record of what the printer will actually do. Currently it is discarded. Everything -downstream (transition plan, mesh, export) needs it. - -CHANGES -------- -1a. Add patchedLayers to ColorFirstResult: - - export interface ColorFirstResult { - windows: WindowResult[]; - colorAssignments: Map[]; - uniqueLayerCount: number; - patchedLayers: PrinterLayer[]; // <-- new - } - -1b. At the end of runMultiHeadLayerAnalysisColorFirst, before the return, - capture a shallow copy of the mutated array so callers cannot accidentally - alias the internal buffer: - - return { - windows: selectedWindows, - colorAssignments: selectedAssignments, - uniqueLayerCount: pixels.length, - patchedLayers: layers.slice(), // <-- new - }; - -1c. Update the two empty-result literals that are returned on early exit: - - const empty: ColorFirstResult = { - windows: [], - colorAssignments: [], - uniqueLayerCount: 0, - patchedLayers: [], // <-- new - }; - -1d. Update tests: - tests/multiHeadAnalysisColorFirst.test.ts - - The two deepEqual checks for the empty return now need patchedLayers: [] - - Add one test: patchedLayers.length > 0 for a normal run, and that its - filamentIdx values are within range [0, filaments.length) - -EDGE CASES ----------- -- If no windows were selected (all errorFactors below threshold), patchedLayers - equals the original unexpanded stack — still correct, just unmodified. -- layers.slice() is O(N) on printer layer count, typically 50-500. Fine. - - -=========================================================================== -STEP 2 — patchedLayers → new TransitionZone[] -=========================================================================== - -File: src/lib/patchedLayersToPlan.ts (new file) - -WHY ---- -The rest of the pipeline (mesh builder, swap plan, export) all speak the -language of TransitionZone[], which is the shape already used by AutoPaintResult. -Converting patchedLayers to that shape lets downstream code remain unchanged. - -TYPES NEEDED (already defined in autoPaint.ts, import them) - TransitionZone { filamentId, filamentColor, filamentTd, - startHeight, endHeight, idealThickness, actualThickness } - PrinterLayer { filamentIdx, filamentRgb, td, thickness, startZ } - Filament { id, color, td, ... } - -FUNCTION SIGNATURE ------------------- -import { buildColorRuns } from './multiHeadAnalysisColorFirst'; -import type { TransitionZone } from './autoPaint'; -import type { PrinterLayer } from './multiHeadAnalysis'; -import type { Filament } from '../types'; - -export function patchedLayersToPlan( - layers: PrinterLayer[], - filaments: Filament[] -): TransitionZone[] - -ALGORITHM ---------- -1. Call buildColorRuns(layers) → ColorRun[] - Each run = { filamentIdx, startLayerIdx, endLayerIdx } - -2. For each run: - a. filament = filaments[run.filamentIdx] (safe: applyComboToLayers only - ever writes valid indices from the original filaments array) - b. startHeight = layers[run.startLayerIdx].startZ - c. endHeight = layers[run.endLayerIdx].startZ - + layers[run.endLayerIdx].thickness - d. thickness = endHeight - startHeight - e. Emit TransitionZone: - { - filamentId: filament.id, - filamentColor: filament.color, - filamentTd: filament.td, - startHeight, - endHeight, - idealThickness: thickness, - actualThickness: thickness, - } - -3. Return the array of TransitionZone. - -NOTE on idealThickness vs actualThickness: for the multi-head result there is -no compression step — the printer-layer heights are exact. Both fields carry the -same value. Callers that display "compression ratio" should be aware. - -TESTS ------ -tests/patchedLayersToPlan.test.ts (new file) -- Zone count equals run count from buildColorRuns on the same layers. -- Zones are contiguous: zones[i].endHeight === zones[i+1].startHeight. -- filamentId and filamentColor round-trip back to the correct filament. -- Heights are non-negative and monotonically increasing. -- Zero-length input returns []. - - -=========================================================================== -STEP 3 — Swap plan integration -=========================================================================== - -Files: - src/hooks/useSwapPlan.ts (modify) - src/types/index.ts (modify) - src/components/ThreeDControls.tsx (modify) - -WHY ---- -useSwapPlan currently generates swap instructions from autoPaintResult.layers. -After multi-head reordering the effective layer plan has additional swaps -inside what were previously single-filament zones. We need those extra swaps -to appear in the printed instructions the user reads. - -CHANGES -------- - -3a. Add patchedTransitionZones to ThreeDControlsStateShape (types/index.ts): - - interface ThreeDControlsStateShape { - ... - multiHeadWindows?: WindowResult[]; - patchedTransitionZones?: TransitionZone[]; // <-- new - ... - } - -3b. Propagate from ThreeDControls.tsx: - - Import patchedLayersToPlan from lib/patchedLayersToPlan. - - In handleApply, after runMultiHeadLayerAnalysisColorFirst: - - const cfResult = runMultiHeadLayerAnalysisColorFirst(...); - const newMultiHeadWindows = cfResult.windows; - const patchedTransitionZones = - cfResult.patchedLayers.length > 0 - ? patchedLayersToPlan(cfResult.patchedLayers, filaments) - : undefined; - - - Include patchedTransitionZones in both onChange({ ... }) calls. - -3c. Add patchedTransitionZones to UseSwapPlanOptions (useSwapPlan.ts): - - interface UseSwapPlanOptions { - ... - patchedTransitionZones?: TransitionZone[]; // <-- new - } - -3d. In the useMemo body of useSwapPlan, when paintMode === 'autopaint': - - Use patchedTransitionZones in preference to autoPaintResult.transitionZones - when it is present. - - The existing zone → swap-entry logic already iterates TransitionZone[] - in startHeight order, so no structural change is needed — just swap the - source array. - - Concretely, replace: - autoPaintResult.layers.forEach((layer, idx) => { ... }) - with logic that runs over (patchedTransitionZones ?? autoPaintResult.transitionZones) - converting each zone to a SwapEntry using the same height → layer-number - formula that already exists in the hook. - -SWAP COUNT IMPACT ------------------ -Original auto-paint with N filaments → N-1 swaps. -After multi-head reordering with W windows, each window of N runs adds up to -N-1 extra swaps within the window. Total worst-case swaps = original + W*(N-1). -For a typical 4-head model with 2 windows: 3 + 2*3 = 9 swaps. Still manageable. - -EDGE CASES ----------- -- If patchedLayers is empty (analysis ran but found nothing), patchedTransitionZones - is undefined and useSwapPlan falls back to the original autoPaintResult zones. -- Zone heights from patchedLayersToPlan may be slightly different from the - autoPaintResult zones because they are derived from discrete printer-layer - boundaries rather than the ideal float heights. The layer-number calculation - in useSwapPlan already rounds, so this is safe. - - -=========================================================================== -STEP 4 — 3D mesh generation with reordered layers -=========================================================================== - -Files: - src/lib/patchedLayersToPlan.ts (add second export) - src/types/index.ts (extend ThreeDControlsStateShape) - src/components/ThreeDControls.tsx (propagate new field) - src/components/ThreeDView.tsx (consume new field) - -WHY ---- -ThreeDView builds a mesh per color slice using colorOrder (swatch indices) and -colorSliceHeights (thicknesses). After multi-head reordering, some of those -slices have the wrong filament color, and slices within windows may need to be -split. The cleanest solution: derive a replacement colorOrder + colorSliceHeights -+ swatches triple from the patched run sequence, and feed it to ThreeDView -alongside the original data. - -GEOMETRY DOES NOT CHANGE. Only the per-slice material color changes. The pixel -height map (which pixels are active at which Z) stays identical because it is -derived from image luminance, not from filament colors. - -NEW EXPORT in patchedLayersToPlan.ts -------------------------------------- -export interface PatchedSliceData { - colorOrder: number[]; // 0..N-1 for N runs (identity mapping) - colorSliceHeights: number[]; // thickness of each run in mm - swatches: { hex: string; a: number }[]; // filament color per run -} - -export function patchedLayersToSliceData( - layers: PrinterLayer[], - filaments: Filament[], - firstLayerHeight: number -): PatchedSliceData - -ALGORITHM ---------- -1. runs = buildColorRuns(layers) -2. For each run i: - a. hex = '#' + filaments[run.filamentIdx].color (ensure # prefix) - b. thickness: - if i === 0: max(run thickness, firstLayerHeight) — matches ThreeDView's - existing first-layer logic - else: layers[run.startLayerIdx..run.endLayerIdx] thickness sum - = layers[run.endLayerIdx].startZ + layers[run.endLayerIdx].thickness - - layers[run.startLayerIdx].startZ -3. colorOrder = [0, 1, 2, ..., runs.length-1] (identity; each run is its own slice) -4. colorSliceHeights = indexed by run index (not by swatchIdx like the original) -5. swatches = [{hex, a:255}, ...] one per run - -STATE FIELD ------------ -Add to ThreeDControlsStateShape (types/index.ts): - - patchedSliceData?: PatchedSliceData; // <-- new - -Propagate in ThreeDControls.tsx handleApply: - - const patchedSliceData = - cfResult.patchedLayers.length > 0 - ? patchedLayersToSliceData(cfResult.patchedLayers, filaments, slicerFirstLayerHeight) - : undefined; - -Include in both onChange calls. - -THREEVIEW CHANGES ------------------ -ThreeDView.tsx already receives swatches, colorOrder, colorSliceHeights as -props. It also receives the full ThreeDControlsStateShape (via the parent's -onChange output). The cleanest approach: add patchedSliceData as a prop. - -In the autopaint mesh build block (currently keyed on autoPaintResult being -set), add a branch: - - if (paintMode === 'autopaint' && autoPaintResult && patchedSliceData) { - // Use patchedSliceData.colorOrder / colorSliceHeights / swatches - // instead of the autoPaintSliceData equivalents. - // Geometry (pixelHeightMap) is identical — only per-slice color changes. - } - -Because the geometry (pixelHeightMap) is unchanged, the only difference in the -mesh loop is: - - colorOrder comes from patchedSliceData.colorOrder - - colorSliceHeights comes from patchedSliceData.colorSliceHeights - - swatches comes from patchedSliceData.swatches - - filamentSwatches is not needed (swatches already carry the correct - physical filament colors) - -EDGE CASES ----------- -- If patchedSliceData is undefined (multi-head analysis found nothing or was - not run), ThreeDView falls back to the original autoPaintSliceData path. - No regression. -- Slice count may differ from original colorOrder.length because one original - auto-paint zone may now be represented as multiple printer-layer runs after - a window is applied. This is intentional. -- The first layer thickness clamping (max(h, slicerFirstLayerHeight)) must be - preserved in patchedLayersToSliceData for the layered mesh builder to produce - a watertight base. - - -=========================================================================== -STEP 5 — 3MF material assignment -=========================================================================== - -Files: - src/hooks/useAppHandlers.ts (modify) - src/lib/export3mf.ts (no change needed) - -WHY ---- -export3mf.ts already has layerFilamentColors?: string[] in Export3MFOptions. -This array is indexed by mesh-object index and overrides the per-object material -color. Currently nothing populates it for multi-head models. - -After step 4, each mesh object corresponds to one run from patchedSliceData. -layerFilamentColors must therefore have one hex string per run, in the same -order as patchedSliceData.colorOrder. - -CHANGES -------- - -5a. In useAppHandlers.ts, in the export-3MF handler, after the threeObject - is ready, check if patchedSliceData is in scope (passed through state): - - const layerFilamentColors: string[] | undefined = - patchedSliceData - ? patchedSliceData.swatches.map((s) => s.hex) - : undefined; - -5b. Pass layerFilamentColors into exportObjectTo3MFBlob: - - const blob = await exportObjectTo3MFBlob( - threeObject, - { layerFilamentColors, ...progressReporter }, - (meta) => updateZipStep(meta.percent) - ); - -HOW THE 3MF USES IT --------------------- -export3mf.ts line ~229: - const overrideHex = options?.layerFilamentColors?.[i]; -This replaces the per-object color with the run's actual filament color, so -the slicer's material list reflects the reordered ordering rather than the -original auto-paint ordering. - -The slicer reads the per-object extruder assignment from the tag. The export already writes a 1-based colorIdx per component -(line ~715). That colorIdx is derived from the mesh object's material, which is -now overridden by layerFilamentColors. Verify that the material-to-extruder -mapping in the generated 3MF config remains correct after the override — it -should because the override only changes the color string, not the material -index itself. - -EDGE CASES ----------- -- If patchedSliceData is undefined, layerFilamentColors is undefined and the - existing export path runs unchanged. -- The filament colour list in the 3MF's project settings (filament_colour array) - is derived from the materials present. With the reordered layers, the colour - list may change order or include duplicates vs the original. Verify that - BambuStudio / PrusaSlicer handle this gracefully. -- Some slicers require the number of distinct filament entries to match the - printer's loaded filament count. If a window re-uses a filament that is - already in the list, de-duplicate before writing filament_colour. - - -=========================================================================== -DATA FLOW AFTER ALL STEPS -=========================================================================== - -ThreeDControls.handleApply - └─ runMultiHeadLayerAnalysisColorFirst(...) - └─ ColorFirstResult { windows, colorAssignments, uniqueLayerCount, - patchedLayers } - ├─ patchedLayersToPlan(patchedLayers, filaments) - │ └─ TransitionZone[] → stored as patchedTransitionZones - └─ patchedLayersToSliceData(patchedLayers, filaments, firstLayerH) - └─ PatchedSliceData → stored as patchedSliceData - -onChange({ ..., patchedTransitionZones, patchedSliceData }) - ├─ App state - ├─ ThreeDView ← receives patchedSliceData → drives mesh geometry colours - ├─ useSwapPlan ← receives patchedTransitionZones → prints correct swap steps - └─ useAppHandlers (export) ← derives layerFilamentColors from patchedSliceData - - -=========================================================================== -TESTING PLAN -=========================================================================== - -Unit tests ----------- -- patchedLayersToPlan: zone continuity, filament round-trip, monotone heights -- patchedLayersToSliceData: slice count = run count, first-layer height clamp, - identity colorOrder, hex format -- ColorFirstResult.patchedLayers: non-empty after a run that finds windows, - filamentIdx in valid range, length matches printer layer count - -Integration / visual --------------------- -- Load a 4-filament image, enable multi-head mode, click Build 3D Model. - Verify the 3D preview shows the reordered filament colours in the windows. -- Export the 3MF. Open in BambuStudio / PrusaSlicer and confirm: - a. The number of colour swaps in the slicer preview matches the swap plan. - b. The per-layer material assignment matches the console log from - runMultiHeadLayerAnalysisColorFirst. -- Verify fallback: with multi-head mode off, the mesh and export are - byte-for-byte identical to a build with the existing code. - -Regression ----------- -- All existing tests continue to pass (npm test). -- The standard (non-multi-head) autopaint path is gated behind - `patchedSliceData !== undefined` checks and must not be affected. - - -=========================================================================== -OPEN QUESTIONS / RISKS -=========================================================================== - -Q1. Slicer extruder-count mismatch - Some slicers reject a 3MF whose filament list has more entries than the - printer profile allows. After multi-head reordering, the effective number - of distinct filaments is still ≤ N (the head count). Verify this holds - and add a de-duplication pass if needed. - -Q2. Filament swap feasibility - The reordered plan may require physically swapping filaments mid-print at - heights that the printer cannot pause at (e.g., inside a bridging span). - This is a UX / documentation issue, not a code bug, but worth noting in - the UI. - -Q3. colorSliceHeights indexing convention - ThreeDView expects colorSliceHeights indexed by swatch index (not by - position). PatchedSliceData uses an identity colorOrder so index == position, - but double-check every callsite in ThreeDView that does - colorSliceHeights[colorOrder[i]] vs colorSliceHeights[i]. - -Q4. smoothMeshing with patched slices - The marching-squares path in ThreeDView may have separate assumptions about - how many slices there are. Confirm it works with a higher-than-usual run - count (e.g., 20 runs instead of 4 swatches). From 2754d135280e393eb05acd3129a36ac81dff19bc Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:29:36 -0500 Subject: [PATCH 08/10] Add tests for multi-head pipeline edge cases and fix two vacuous tests Covers computeColorOptimalAssignments, optimizeNozzleAssignments, buildMultiHeadSchedule edge cases, and patchedLayersToPlan boundary conditions. Renames two misidentified tests so they reflect the path they actually exercise. Co-Authored-By: Claude Sonnet 4.6 --- tests/multiHeadAnalysisColorFirst.test.ts | 251 +++++++++++++++++++++- tests/multiHeadSchedule.test.ts | 51 +++++ tests/patchedLayersToPlan.test.ts | 38 ++++ 3 files changed, 332 insertions(+), 8 deletions(-) diff --git a/tests/multiHeadAnalysisColorFirst.test.ts b/tests/multiHeadAnalysisColorFirst.test.ts index a1af365..08b8771 100644 --- a/tests/multiHeadAnalysisColorFirst.test.ts +++ b/tests/multiHeadAnalysisColorFirst.test.ts @@ -11,8 +11,11 @@ import { buildPixelDataColorFirst, buildColorRuns, analyzeMultiHeadWindowsColorFirst, + computeColorOptimalAssignments, + optimizeNozzleAssignments, runMultiHeadLayerAnalysisColorFirst, } from '../src/lib/multiHeadAnalysisColorFirst.ts'; +import type { WindowFilament } from '../src/lib/multiHeadAnalysis.ts'; import type { Filament } from '../src/types/index.ts'; const LAYER_HEIGHT = 0.12; @@ -194,19 +197,18 @@ test('analyzeMultiHeadWindowsColorFirst — returns empty for insufficient data' ); }); -test('analyzeMultiHeadWindowsColorFirst — LUT indices in pixelOptimalLUTIdx are valid or -1', () => { +test('analyzeMultiHeadWindowsColorFirst — lut and pixelOptimalLUTIdx are empty (populated by caller)', () => { + // analyzeMultiHeadWindowsColorFirst intentionally leaves lut:[] and pixelOptimalLUTIdx:[] + // on each WindowResult — the full pipeline (runMultiHeadLayerAnalysisColorFirst) fills these + // in after window selection. Asserting empty here documents that contract explicitly. const swatches = gradient(20); const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); const windows = analyzeMultiHeadWindowsColorFirst( FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 ); for (const w of windows) { - for (const idx of w.pixelOptimalLUTIdx) { - assert.ok( - idx === -1 || (idx >= 0 && idx < w.lut.length), - `pixelOptimalLUTIdx ${idx} out of range [0, ${w.lut.length})` - ); - } + assert.deepEqual(w.lut, [], `expected lut to be empty on raw window`); + assert.deepEqual(w.pixelOptimalLUTIdx, [], `expected pixelOptimalLUTIdx to be empty on raw window`); } }); @@ -366,7 +368,7 @@ test('runMultiHeadLayerAnalysisColorFirst — at least two colours differ somewh // patchedLayers // --------------------------------------------------------------------------- -test('runMultiHeadLayerAnalysisColorFirst — patchedLayers is empty when no windows are found', () => { +test('runMultiHeadLayerAnalysisColorFirst — patchedLayers is empty when fewer than 2 filaments provided', () => { const result = generateAutoLayers([BLACK, WHITE], gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT); const { patchedLayers } = runMultiHeadLayerAnalysisColorFirst( [BLACK], result, gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 @@ -374,6 +376,18 @@ test('runMultiHeadLayerAnalysisColorFirst — patchedLayers is empty when no win assert.equal(patchedLayers.length, 0); }); +test('runMultiHeadLayerAnalysisColorFirst — patchedLayers is empty when window loop finds no valid windows', () => { + // With only 2 filaments and N=2, buildColorRuns produces 2 runs. The only possible window + // [run0, run1] starts at layer 0 (the opaque foundation) and is always skipped. + // This exercises the "no windows after sliding" path, distinct from the N<2 early-return. + const result = generateAutoLayers([BLACK, WHITE], gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const { windows, patchedLayers } = runMultiHeadLayerAnalysisColorFirst( + [BLACK, WHITE], result, gradient(10), LAYER_HEIGHT, FIRST_LAYER_HEIGHT, 2 + ); + assert.equal(windows.length, 0, 'expected no windows when only window spans foundation'); + assert.equal(patchedLayers.length, 0); +}); + test('runMultiHeadLayerAnalysisColorFirst — patchedLayers is non-empty when windows are found', () => { const swatches = gradient(200); const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); @@ -422,3 +436,224 @@ test('runMultiHeadLayerAnalysisColorFirst — patchedLayers startZ values are mo ); } }); + +// --------------------------------------------------------------------------- +// analyzeMultiHeadWindowsColorFirst — layer count guard +// --------------------------------------------------------------------------- + +test('analyzeMultiHeadWindowsColorFirst — returns empty when layers.length < N + 1', () => { + const swatches = gradient(4); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + // N = min(n, filaments.length). Use n = layers.length so N = min(layers.length, 4). + // The guard fires when layers.length < N + 1. With N = layers.length (when layers.length <= 4) + // the guard is layers.length < layers.length + 1 which is always true. + // When layers.length > 4, N = 4; skip the test — the guard isn't reachable with this fixture. + if (layers.length > 4) return; + const windows = analyzeMultiHeadWindowsColorFirst( + FOUR_FILAMENTS, result, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT, layers.length + ); + assert.equal(windows.length, 0, 'should return [] when layers.length < N + 1'); +}); + +// --------------------------------------------------------------------------- +// computeColorOptimalAssignments — direct unit tests +// --------------------------------------------------------------------------- + +test('computeColorOptimalAssignments — errorFactor is non-negative', () => { + const swatches = gradient(40, 5); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const colorAtLayer = buildColorStack(layers); + const runs = buildColorRuns(layers); + const pixels = buildPixelDataColorFirst( + swatches, layers, colorAtLayer, result.transitionZones, result.totalHeight, FIRST_LAYER_HEIGHT + ); + if (runs.length < 3) return; // need at least 3 runs to pick a non-foundation window + + const N = 2; + const windowRuns = runs.slice(1, 1 + N); // skip run 0 (foundation) + const wEnd = windowRuns[N - 1].endLayerIdx; + const uniqueIndices = [...new Set(windowRuns.map((r) => r.filamentIdx))]; + const FRONTLIT_TD_SCALE = 0.1; + const windowFilaments: WindowFilament[] = uniqueIndices.map((fi) => ({ + rgb: colorAtLayer[0], // use actual layer color as proxy — value doesn't affect the invariant + td: FOUR_FILAMENTS[fi].td * FRONTLIT_TD_SCALE, + })); + + const { errorFactor } = computeColorOptimalAssignments( + windowRuns, wEnd, layers, colorAtLayer[windowRuns[0].startLayerIdx - 1], windowFilaments, pixels + ); + assert.ok(errorFactor >= -1e-9, `errorFactor should be >= 0, got ${errorFactor}`); +}); + +test('computeColorOptimalAssignments — affectedCount matches pixels in and above the window', () => { + const swatches = gradient(40, 5); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const colorAtLayer = buildColorStack(layers); + const runs = buildColorRuns(layers); + const pixels = buildPixelDataColorFirst( + swatches, layers, colorAtLayer, result.transitionZones, result.totalHeight, FIRST_LAYER_HEIGHT + ); + if (runs.length < 3) return; + + const N = 2; + const windowRuns = runs.slice(1, 1 + N); + const wStart = windowRuns[0].startLayerIdx; + const wEnd = windowRuns[N - 1].endLayerIdx; + const uniqueIndices = [...new Set(windowRuns.map((r) => r.filamentIdx))]; + const FRONTLIT_TD_SCALE = 0.1; + const windowFilaments: WindowFilament[] = uniqueIndices.map((fi) => ({ + rgb: colorAtLayer[wStart - 1], + td: FOUR_FILAMENTS[fi].td * FRONTLIT_TD_SCALE, + })); + + const { affectedCount } = computeColorOptimalAssignments( + windowRuns, wEnd, layers, colorAtLayer[wStart - 1], windowFilaments, pixels + ); + const expectedCount = pixels + .filter((p) => p.layerIdx >= wStart) + .reduce((s, p) => s + p.count, 0); + assert.equal(affectedCount, expectedCount); +}); + +test('computeColorOptimalAssignments — non-null assignments have length equal to windowRuns.length', () => { + const swatches = gradient(40, 5); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const colorAtLayer = buildColorStack(layers); + const runs = buildColorRuns(layers); + const pixels = buildPixelDataColorFirst( + swatches, layers, colorAtLayer, result.transitionZones, result.totalHeight, FIRST_LAYER_HEIGHT + ); + if (runs.length < 3) return; + + const N = 2; + const windowRuns = runs.slice(1, 1 + N); + const wStart = windowRuns[0].startLayerIdx; + const uniqueIndices = [...new Set(windowRuns.map((r) => r.filamentIdx))]; + const FRONTLIT_TD_SCALE = 0.1; + const windowFilaments: WindowFilament[] = uniqueIndices.map((fi) => ({ + rgb: colorAtLayer[wStart - 1], + td: FOUR_FILAMENTS[fi].td * FRONTLIT_TD_SCALE, + })); + + const { assignments } = computeColorOptimalAssignments( + windowRuns, windowRuns[N - 1].endLayerIdx, layers, + colorAtLayer[wStart - 1], windowFilaments, pixels + ); + for (let i = 0; i < assignments.length; i++) { + const a = assignments[i]; + if (a !== null) { + assert.equal(a.length, N, `assignments[${i}] has wrong length`); + for (const slot of a) { + assert.ok(slot >= 0 && slot < windowFilaments.length, + `assignments[${i}] slot ${slot} out of range [0, ${windowFilaments.length})`); + } + } + } +}); + +test('computeColorOptimalAssignments — pixels outside the window have null assignments', () => { + const swatches = gradient(40, 5); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const colorAtLayer = buildColorStack(layers); + const runs = buildColorRuns(layers); + const pixels = buildPixelDataColorFirst( + swatches, layers, colorAtLayer, result.transitionZones, result.totalHeight, FIRST_LAYER_HEIGHT + ); + if (runs.length < 3) return; + + const N = 2; + const windowRuns = runs.slice(1, 1 + N); + const wStart = windowRuns[0].startLayerIdx; + const uniqueIndices = [...new Set(windowRuns.map((r) => r.filamentIdx))]; + const FRONTLIT_TD_SCALE = 0.1; + const windowFilaments: WindowFilament[] = uniqueIndices.map((fi) => ({ + rgb: colorAtLayer[wStart - 1], + td: FOUR_FILAMENTS[fi].td * FRONTLIT_TD_SCALE, + })); + + const { assignments } = computeColorOptimalAssignments( + windowRuns, windowRuns[N - 1].endLayerIdx, layers, + colorAtLayer[wStart - 1], windowFilaments, pixels + ); + for (let i = 0; i < pixels.length; i++) { + if (pixels[i].layerIdx < wStart) { + assert.equal(assignments[i], null, + `pixel at layerIdx ${pixels[i].layerIdx} (before window start ${wStart}) should have null assignment`); + } + } +}); + +// --------------------------------------------------------------------------- +// optimizeNozzleAssignments — direct unit tests +// --------------------------------------------------------------------------- + +test('optimizeNozzleAssignments — returns empty for empty input', () => { + assert.deepEqual(optimizeNozzleAssignments([], 2), []); +}); + +test('optimizeNozzleAssignments — output length equals number of windows', () => { + assert.equal(optimizeNozzleAssignments([['A', 'B'], ['C', 'D'], ['E', 'F']], 2).length, 3); +}); + +test('optimizeNozzleAssignments — single window assigns every run to exactly one nozzle', () => { + const result = optimizeNozzleAssignments([['A', 'B']], 2); + assert.equal(result.length, 1); + const assgn = result[0]; + assert.equal(assgn.length, 2); // one slot per nozzle + const active = assgn.filter((r) => r !== -1); + assert.equal(active.length, 2, 'both nozzles must be active when K = N'); + assert.ok(assgn.includes(0), 'run slot 0 must be assigned'); + assert.ok(assgn.includes(1), 'run slot 1 must be assigned'); +}); + +test('optimizeNozzleAssignments — each run slot appears exactly once per window', () => { + const windows = [['A', 'B', 'C'], ['D', 'E'], ['F', 'G', 'H']]; + const result = optimizeNozzleAssignments(windows, 3); + for (let w = 0; w < windows.length; w++) { + const K = windows[w].length; + const assigned = result[w].filter((r) => r !== -1); + assert.equal(new Set(assigned).size, K, + `window ${w}: each of ${K} run slots must appear exactly once`); + } +}); + +test('optimizeNozzleAssignments — slot indices are valid or -1', () => { + const windows = [['A', 'B'], ['C'], ['D', 'E', 'F']]; + const result = optimizeNozzleAssignments(windows, 3); + for (let w = 0; w < windows.length; w++) { + const K = windows[w].length; + for (const r of result[w]) { + assert.ok(r === -1 || (r >= 0 && r < K), + `window ${w}: slot ${r} is out of range [0, ${K})`); + } + } +}); + +test('optimizeNozzleAssignments — repeated identical windows produce zero swaps after window 0', () => { + // Same two filaments every window — optimal schedule never changes nozzle load. + const windows = [['A', 'B'], ['A', 'B'], ['A', 'B']]; + const result = optimizeNozzleAssignments(windows, 2); + // Verify nozzle carry-forward: for windows 1 and 2, each nozzle gets the same + // run index as window 0 (or remains idle with the same filament), meaning 0 swaps. + // Since K = N = 2 and filaments repeat, the same injection is optimal every time. + assert.equal(result[1][0], result[0][0], 'nozzle 1 should keep the same run assignment'); + assert.equal(result[1][1], result[0][1], 'nozzle 2 should keep the same run assignment'); + assert.equal(result[2][0], result[0][0]); + assert.equal(result[2][1], result[0][1]); +}); + +test('optimizeNozzleAssignments — K < N windows leave some nozzles idle', () => { + // Window with only 1 run and N=2 nozzles: exactly one nozzle active, one idle. + const result = optimizeNozzleAssignments([['A']], 2); + const assgn = result[0]; + const active = assgn.filter((r) => r !== -1); + const idle = assgn.filter((r) => r === -1); + assert.equal(active.length, 1, 'exactly one nozzle should be active'); + assert.equal(idle.length, 1, 'exactly one nozzle should be idle'); +}); +}); diff --git a/tests/multiHeadSchedule.test.ts b/tests/multiHeadSchedule.test.ts index b1ed8de..b46c5a3 100644 --- a/tests/multiHeadSchedule.test.ts +++ b/tests/multiHeadSchedule.test.ts @@ -114,3 +114,54 @@ test('buildMultiHeadSchedule: non-windowed ranges participate and sort by start // head 1 stays 'A', head 2 'B' -> 'D' assert.equal(events![1].swapCount, 1); }); + +test('buildMultiHeadSchedule returns null when windows and nonWindowedRanges are both empty', () => { + assert.equal( + buildMultiHeadSchedule({ + multiHeadWindows: [], + nozzleAssignments: [], + windowRunFilaments: [], + filaments: FILAMENTS, + }), + null, + 'empty raw event list should produce null' + ); +}); + +test('buildMultiHeadSchedule: idle nozzle (-1) carries previous filament across windows', () => { + // Window 0: N1→A, N2→B. Window 1: N1→idle (keeps A), N2→C. + const events = buildMultiHeadSchedule({ + multiHeadWindows: [win(0), win(4)], + nozzleAssignments: [ + [0, 1], // window 0: both active + [-1, 0], // window 1: N1 idle, N2 takes run 0 (C) + ], + windowRunFilaments: [ + ['A', 'B'], + ['C'], + ], + filaments: FILAMENTS, + }); + + assert.ok(events, 'expected a schedule'); + const swap = events!.at(-1)!; + assert.equal(swap.swapCount, 1, 'only N2 changed'); + assert.deepEqual( + swap.nozzles.map((n) => [n.filamentId, n.changed]), + [['A', false], ['C', true]] + ); +}); + +test('buildMultiHeadSchedule: unknown filament ID uses #888888 fallback hex', () => { + const events = buildMultiHeadSchedule({ + multiHeadWindows: [win(0)], + nozzleAssignments: [[0, 1]], + windowRunFilaments: [['A', 'GHOST']], + filaments: FILAMENTS, // GHOST not in FILAMENTS + }); + + assert.ok(events); + const unknownNozzle = events![0].nozzles.find((n) => n.filamentId === 'GHOST'); + assert.ok(unknownNozzle, 'expected a nozzle entry for the unknown filament'); + assert.equal(unknownNozzle!.filamentHex, '#888888'); +}); diff --git a/tests/patchedLayersToPlan.test.ts b/tests/patchedLayersToPlan.test.ts index b42716f..5e9516d 100644 --- a/tests/patchedLayersToPlan.test.ts +++ b/tests/patchedLayersToPlan.test.ts @@ -310,3 +310,41 @@ test('buildPerColorLayerColors — at least two colours differ in their blended } assert.ok(foundDivergence, 'expected at least one layer where colours diverge'); }); + +// --------------------------------------------------------------------------- +// patchedLayersToPlan — edge cases +// --------------------------------------------------------------------------- + +test('patchedLayersToPlan — single-layer input produces exactly one zone', () => { + const swatches = gradient(2); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const allLayers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + if (allLayers.length === 0) return; + + const singleLayer = allLayers.slice(0, 1); + const zones = patchedLayersToPlan(singleLayer, FOUR_FILAMENTS); + + assert.equal(zones.length, 1, 'one layer must produce exactly one zone'); + assert.ok(zones[0].endHeight > zones[0].startHeight, 'zone must have positive thickness'); + assert.ok(zones[0].startHeight >= 0); +}); + +test('patchedLayersToPlan — out-of-bounds filamentIdx falls back gracefully', () => { + const swatches = gradient(4); + const result = generateAutoLayers(FOUR_FILAMENTS, swatches, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + const layers = expandZonesToPrinterLayers(result, FOUR_FILAMENTS, LAYER_HEIGHT, FIRST_LAYER_HEIGHT); + if (layers.length === 0) return; + + // Patch a layer to use an index beyond the filaments array. + const patched = layers.map((l, i) => + i === 0 ? { ...l, filamentIdx: 99 } : l + ); + const zones = patchedLayersToPlan(patched, FOUR_FILAMENTS); + + assert.ok(zones.length >= 1); + // The zone whose run covers layer 0 should fall back, not throw. + const fallbackZone = zones[0]; + assert.equal(fallbackZone.filamentId, 'f99', 'expected fallback id f'); + assert.equal(fallbackZone.filamentColor, '#000000', 'expected fallback color #000000'); + assert.equal(fallbackZone.filamentTd, 0, 'expected fallback td 0'); +}); From 1561f563dbee88e97c6a01670c40f7be5ca1e52d Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:37:18 -0500 Subject: [PATCH 09/10] update with pr suggestions. Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/App.tsx | 15 +++ src/components/PrintInstructions.tsx | 2 +- src/components/ThreeDControls.tsx | 35 ++++--- src/hooks/useMultiHeadWorker.ts | 115 ++++++++++++++++++++++ src/hooks/useSwapPlan.ts | 2 +- src/lib/multiHeadAnalysisColorFirst.ts | 17 +++- src/lib/multiHeadSchedule.ts | 11 ++- src/workers/multiHead.worker.ts | 51 ++++++++++ tests/multiHeadAnalysisColorFirst.test.ts | 13 ++- 9 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useMultiHeadWorker.ts create mode 100644 src/workers/multiHead.worker.ts diff --git a/src/App.tsx b/src/App.tsx index 380e6a9..c64e396 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -81,6 +81,9 @@ type AutoPaintPersisted = Pick< | 'heightDithering' | 'ditherLineWidth' | 'flatPaint' + | 'multiHeadMode' + | 'multiHeadCount' + | 'multiHeadSearchDepth' >; const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { @@ -108,6 +111,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; @@ -246,6 +252,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 @@ -265,6 +274,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, @@ -277,6 +289,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". diff --git a/src/components/PrintInstructions.tsx b/src/components/PrintInstructions.tsx index 0fc0888..32c8f26 100644 --- a/src/components/PrintInstructions.tsx +++ b/src/components/PrintInstructions.tsx @@ -245,7 +245,7 @@ function HeadSchedule({ events }: { events: MultiHeadScheduleEvent[] }) { className="inline-block w-3.5 h-3.5 rounded border border-border flex-shrink-0" style={{ background: n.filamentHex }} /> - {n.filamentHex} + {n.filamentName}
    ))}
    diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index a4208cb..62f2a42 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -6,7 +6,6 @@ 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 { runMultiHeadLayerAnalysisColorFirst } from '../lib/multiHeadAnalysisColorFirst'; import { patchedLayersToPlan, patchedLayersToSliceData, buildPerColorLayerColors } from '../lib/patchedLayersToPlan'; import type { WindowResult } from '../lib/multiHeadAnalysis'; import { @@ -19,6 +18,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 { Swatch, ThreeDControlsStateShape } from '../types'; import PrintSettingsCard from './PrintSettingsCard'; import PrintInstructions from './PrintInstructions'; @@ -241,6 +241,12 @@ export default function ThreeDControls({ multiHeadCount, }); + // --- Multi-head analysis (runs in Web Worker to avoid blocking the UI) --- + const { + isComputing: isMultiHeadComputing, + run: runMultiHead, + } = useMultiHeadWorker(); + // Reset multi-head windows whenever a new autopaint result arrives useEffect(() => { setMultiHeadWindows([]); @@ -329,23 +335,28 @@ export default function ThreeDControls({ }); // --- Apply handler --- - const handleApply = useCallback(() => { + const handleApply = useCallback(async () => { if (!onChange) return; // Run the appropriate multi-head optimizer based on the selected mode. - const activeResult = (() => { - if (!multiHeadMode || paintMode !== 'autopaint' || !autoPaintResult) return null; + 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, })); - return runMultiHeadLayerAnalysisColorFirst( - filaments, autoPaintResult, swatches, - layerHeight, slicerFirstLayerHeight, multiHeadCount - ); - })(); + 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 @@ -459,7 +470,7 @@ export default function ThreeDControls({ multiHeadMode, multiHeadCount, multiHeadSearchDepth, - filtered, + runMultiHead, ]); return ( @@ -469,10 +480,10 @@ export default function ThreeDControls({