From 8adbfc512dd66caba36149f4413a0e703f4d162f Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 1 Jun 2026 13:20:31 +0300 Subject: [PATCH 01/26] Prepare v3.1.0 development cycle --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- public/version.json | 4 ++-- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edffcde..77ab23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Kromacut are documented in this file. +## v3.1.0 - unreleased + +### Added + +### Changed + +### Fixed + ## v3.0.0 - 2026-06-01 ### Added diff --git a/package-lock.json b/package-lock.json index e60579f..e26282c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kromacut", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kromacut", - "version": "3.0.0", + "version": "3.1.0", "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 06dac15..aa392eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kromacut", "private": true, - "version": "3.0.0", + "version": "3.1.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/public/version.json b/public/version.json index e51b2fb..851edb9 100644 --- a/public/version.json +++ b/public/version.json @@ -1,5 +1,5 @@ { - "version": "3.0.0", + "version": "3.1.0", "download_url": "https://github.com/vycdev/Kromacut/releases/latest", - "release_notes": "Major release with AGPL licensing, in-app docs, image resize, smooth meshing improvements, and export fixes" + "release_notes": "Upcoming release with new features and improvements" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f6dc91e..99efa6e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1907,7 +1907,7 @@ dependencies = [ [[package]] name = "kromacut" -version = "3.0.0" +version = "3.1.0" dependencies = [ "log", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 83e6f1a..3be2eaa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kromacut" -version = "3.0.0" +version = "3.1.0" description = "Kromacut - Multi-color lithophane 3D print generator" authors = ["vycdev"] license = "AGPL-3.0-only" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 39c2ef7..3405864 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Kromacut", - "version": "3.0.0", + "version": "3.1.0", "identifier": "com.kromacut.lithophane", "build": { "frontendDist": "../dist", From aa91eb6f877c867c977145d878040c77744a957c Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Mon, 8 Jun 2026 22:18:06 +0300 Subject: [PATCH 02/26] Add settings dialog theme preferences --- CHANGELOG.md | 2 + index.html | 1 + src/components/Header.tsx | 173 ++++++++++++++++++++++++++---- src/docs/settings-and-controls.md | 6 +- src/lib/theme.ts | 85 +++++++++++++++ src/main.tsx | 8 +- src/vite-env.d.ts | 2 + vite.config.ts | 8 ++ 8 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 src/lib/theme.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ab23c..9d39485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to Kromacut are documented in this file. ### Changed +- **Header settings dialog** - Replaced the standalone theme toggle with a centered settings dialog that contains compact System, Dark, and Light theme options plus the current app version. + ### Fixed ## v3.0.0 - 2026-06-01 diff --git a/index.html b/index.html index a3de29a..10f08e3 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3b7e7e1..6d57827 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,28 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { ArrowLeft, BookOpen, Image, Github, Heart, Moon, Sun, MessageCircle } from 'lucide-react'; +import { + ArrowLeft, + BookOpen, + Image, + Github, + Heart, + Moon, + Sun, + MessageCircle, + Settings, + X, + Monitor, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + applyResolvedTheme, + applyThemeMode, + getStoredThemeMode, + saveThemeMode, + subscribeToSystemTheme, + THEME_STORAGE_KEY, + type ThemeMode, +} from '@/lib/theme'; import logo from '../assets/logo.png'; interface Props { @@ -10,22 +32,52 @@ interface Props { onToggleDocs: () => void; } +const appVersion = __APP_VERSION__; + export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onToggleDocs }) => { - const [isDark, setIsDark] = React.useState(() => { - return document.documentElement.classList.contains('dark'); - }); - - const toggleTheme = () => { - const root = document.documentElement; - if (isDark) { - root.classList.remove('dark'); - localStorage.setItem('theme', 'light'); - setIsDark(false); - } else { - root.classList.add('dark'); - localStorage.setItem('theme', 'dark'); - setIsDark(true); + const [themeMode, setThemeMode] = React.useState(() => getStoredThemeMode()); + const [settingsOpen, setSettingsOpen] = React.useState(false); + const settingsTitleId = React.useId(); + + React.useEffect(() => { + if (!settingsOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSettingsOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [settingsOpen]); + + React.useEffect(() => { + applyThemeMode(themeMode); + + if (themeMode !== 'system') { + return; } + + return subscribeToSystemTheme((resolvedTheme) => { + applyResolvedTheme(resolvedTheme); + }); + }, [themeMode]); + + React.useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === THEME_STORAGE_KEY) { + setThemeMode(getStoredThemeMode()); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, []); + + const setTheme = (nextThemeMode: ThemeMode) => { + saveThemeMode(nextThemeMode); + setThemeMode(nextThemeMode); }; return ( @@ -126,13 +178,98 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT + {settingsOpen && ( +
setSettingsOpen(false)} + > +
event.stopPropagation()} + > +
+

+ Settings +

+ +
+ +
+
Theme
+
+ + + +
+
+ +
+ Kromacut + v{appVersion} +
+
+
+ )} ); }; diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md index 0981f0d..865f6a1 100644 --- a/src/docs/settings-and-controls.md +++ b/src/docs/settings-and-controls.md @@ -18,9 +18,11 @@ This page collects controls that affect the whole app or are easy to miss. | Discord | Opens the community link. | | GitHub | Opens the project page. | | Support me | Opens the support link. | -| Theme button | Switches between dark and light mode. | +| Settings | Opens the settings dialog, including the theme selector. | -The theme choice is saved for later sessions. +The theme selector offers **System**, **Dark**, and **Light**. **System** follows the operating system or browser color-scheme preference and updates when that preference changes. The theme choice is saved for later sessions. + +The settings dialog also shows the current Kromacut version. ## Workspace Modes diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..18d2e54 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,85 @@ +export type ThemeMode = 'system' | 'dark' | 'light'; +export type ResolvedTheme = 'dark' | 'light'; + +export const THEME_STORAGE_KEY = 'theme'; + +const DEFAULT_THEME_MODE: ThemeMode = 'dark'; +const SYSTEM_THEME_QUERY = '(prefers-color-scheme: dark)'; + +const getSystemThemeQuery = (): MediaQueryList | null => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return null; + } + + return window.matchMedia(SYSTEM_THEME_QUERY); +}; + +export const isThemeMode = (value: string | null): value is ThemeMode => { + return value === 'system' || value === 'dark' || value === 'light'; +}; + +export const getStoredThemeMode = (): ThemeMode => { + if (typeof localStorage === 'undefined') { + return DEFAULT_THEME_MODE; + } + + try { + const storedTheme = localStorage.getItem(THEME_STORAGE_KEY); + return isThemeMode(storedTheme) ? storedTheme : DEFAULT_THEME_MODE; + } catch { + return DEFAULT_THEME_MODE; + } +}; + +export const saveThemeMode = (themeMode: ThemeMode) => { + try { + localStorage.setItem(THEME_STORAGE_KEY, themeMode); + } catch { + // Theme changes should still apply for the current session if storage is blocked. + } +}; + +export const getSystemTheme = (): ResolvedTheme => { + return getSystemThemeQuery()?.matches ? 'dark' : 'light'; +}; + +export const resolveThemeMode = (themeMode: ThemeMode): ResolvedTheme => { + return themeMode === 'system' ? getSystemTheme() : themeMode; +}; + +export const applyResolvedTheme = (resolvedTheme: ResolvedTheme) => { + const isDark = resolvedTheme === 'dark'; + + document.documentElement.classList.toggle('dark', isDark); + document.documentElement.style.colorScheme = resolvedTheme; + + const themeColorMeta = document.querySelector('meta[name="theme-color"]'); + if (themeColorMeta) { + themeColorMeta.content = isDark ? '#0a0a0a' : '#ffffff'; + } +}; + +export const applyThemeMode = (themeMode: ThemeMode): ResolvedTheme => { + const resolvedTheme = resolveThemeMode(themeMode); + applyResolvedTheme(resolvedTheme); + return resolvedTheme; +}; + +export const subscribeToSystemTheme = (onChange: (resolvedTheme: ResolvedTheme) => void) => { + const mediaQuery = getSystemThemeQuery(); + if (!mediaQuery) { + return () => {}; + } + + const handleChange = (event: MediaQueryListEvent) => { + onChange(event.matches ? 'dark' : 'light'); + }; + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } + + mediaQuery.addListener(handleChange); + return () => mediaQuery.removeListener(handleChange); +}; diff --git a/src/main.tsx b/src/main.tsx index b9f5601..ada1df5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,12 +2,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { applyThemeMode, getStoredThemeMode } from './lib/theme'; -// Apply saved theme preference, default to dark -const savedTheme = localStorage.getItem('theme'); -if (savedTheme !== 'light') { - document.documentElement.classList.add('dark'); -} +// Apply the saved theme preference before React paints. +applyThemeMode(getStoredThemeMode()); // Keep browser tabs compact while the static HTML title remains descriptive for crawlers and previews. document.title = 'Kromacut'; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..dbb4c62 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare const __APP_VERSION__: string; diff --git a/vite.config.ts b/vite.config.ts index ff59791..af23ae0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,11 +2,19 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf8') +) as { version: string }; // https://vite.dev/config/ export default defineConfig({ base: '/', plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + }, resolve: { alias: { '@': path.resolve(__dirname, './src'), From 9ce27911366fe075c14a09a5625b2a7ca5a7fcfd Mon Sep 17 00:00:00 2001 From: Brice Johnson <{ID}+{username}@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:21:29 -0500 Subject: [PATCH 03/26] Add ortho/perspective camera toggle to 3D view Adds a pill button (top-left, below model dimensions) that switches the Three.js camera between PerspectiveCamera and OrthographicCamera without rebuilding the scene. OrbitControls.object is swapped in-place; position and orientation are preserved across the switch. The resize handler and auto-framing both branch on camera type. Co-Authored-By: Claude Sonnet 4.6 --- src/components/ThreeDView.tsx | 76 ++++++++++++++++++++++++++--------- src/hooks/useThreeScene.ts | 56 ++++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 24 deletions(-) diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index df542aa..b1c6d61 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -14,7 +14,7 @@ import { layeredBuildScanProgress, progressInSpan, } from '../lib/progress'; -import { Layers } from 'lucide-react'; +import { Layers, Box } from 'lucide-react'; import ProgressOverlay from './ProgressOverlay'; interface ThreeDViewProps { @@ -323,10 +323,15 @@ export default function ThreeDView({ xPercent: number; } | null>(null); const previewTrackRef = useRef(null); - const { cameraRef, controlsRef, modelGroupRef, materialRef, requestRender } = useThreeScene( + const { cameraRef, controlsRef, modelGroupRef, materialRef, requestRender, switchCamera } = useThreeScene( mountRef, setIsBuilding ); + const [isOrtho, setIsOrtho] = useState(false); + + useEffect(() => { + switchCamera(isOrtho); + }, [isOrtho, switchCamera]); const progressRef = useRef(0); const progressLastUpdateRef = useRef(0); @@ -1511,17 +1516,34 @@ export default function ThreeDView({ if (camera && controls) { const sphere = new THREE.Sphere(); box.getBoundingSphere(sphere); - // ... same framing logic ... - const fov = (camera.fov * Math.PI) / 180; - const distance = sphere.radius / Math.sin(fov / 2); const dir = new THREE.Vector3(0.9, 0.8, 1).normalize(); - const camPos = sphere.center - .clone() - .add(dir.multiplyScalar(distance * 1.35)); - camera.position.copy(camPos); - controls.target.copy(sphere.center); - camera.near = Math.max(0.01, sphere.radius * 0.01); - camera.far = sphere.radius * 20; + if (camera instanceof THREE.PerspectiveCamera) { + const fov = (camera.fov * Math.PI) / 180; + const distance = sphere.radius / Math.sin(fov / 2); + const camPos = sphere.center + .clone() + .add(dir.multiplyScalar(distance * 1.35)); + camera.position.copy(camPos); + controls.target.copy(sphere.center); + camera.near = Math.max(0.01, sphere.radius * 0.01); + camera.far = sphere.radius * 20; + } else if (camera instanceof THREE.OrthographicCamera) { + const distance = sphere.radius * 2.5; + const camPos = sphere.center + .clone() + .add(dir.multiplyScalar(distance)); + camera.position.copy(camPos); + controls.target.copy(sphere.center); + camera.near = 0.01; + camera.far = sphere.radius * 20; + // Expand frustum to fit the sphere + const viewH = sphere.radius * 2.5; + const aspect = (camera.right - camera.left) / (camera.top - camera.bottom); + camera.top = viewH / 2; + camera.bottom = -viewH / 2; + camera.left = -(viewH * aspect) / 2; + camera.right = (viewH * aspect) / 2; + } camera.updateProjectionMatrix(); controls.update(); } @@ -1646,15 +1668,29 @@ export default function ThreeDView({ progress={buildProgress} /> )} - {modelDimensions && ( -
+ {modelDimensions && ( +
+ Model: {modelDimensions.width.toFixed(1)}×{modelDimensions.height.toFixed(1)}× + {modelDimensions.depth.toFixed(1)} mm +
+ )} +
- )} + + {isOrtho ? 'Ortho' : 'Persp'} + + {/* Layer Preview Slider */} {!isBuilding && maxModelHeight > 0 && previewHeight !== null && (
diff --git a/src/hooks/useThreeScene.ts b/src/hooks/useThreeScene.ts index 097430d..16f9325 100644 --- a/src/hooks/useThreeScene.ts +++ b/src/hooks/useThreeScene.ts @@ -9,12 +9,14 @@ export function useThreeScene( const rafRef = useRef(null); const rendererRef = useRef(null); const sceneRef = useRef(null); - const cameraRef = useRef(null); + const cameraRef = useRef(null); + const perspCameraRef = useRef(null); const controlsRef = useRef(null); const modelGroupRef = useRef(null); const materialRef = useRef(null); const requestRenderRef = useRef<(() => void) | null>(null); + const switchCameraRef = useRef<((isOrtho: boolean) => void) | null>(null); useEffect(() => { const el = mountRef.current; @@ -36,6 +38,7 @@ export function useThreeScene( const camera = new THREE.PerspectiveCamera(45, el.clientWidth / el.clientHeight, 0.1, 1000); camera.position.set(0, 0.9, 1.8); + perspCameraRef.current = camera; cameraRef.current = camera; const controls = new OrbitControls(camera, renderer.domElement); @@ -93,13 +96,55 @@ export function useThreeScene( }; requestRenderRef.current = requestRender; + const switchCamera = (isOrtho: boolean) => { + const cam = cameraRef.current; + const persp = perspCameraRef.current; + const ctrl = controlsRef.current; + if (!cam || !persp || !ctrl || !el) return; + const aspect = el.clientWidth / el.clientHeight; + + if (isOrtho && cam instanceof THREE.PerspectiveCamera) { + const target = ctrl.target.clone(); + const dist = cam.position.distanceTo(target); + const fovRad = (cam.fov * Math.PI) / 180; + const viewH = 2 * dist * Math.tan(fovRad / 2); + const viewW = viewH * aspect; + const ortho = new THREE.OrthographicCamera( + -viewW / 2, viewW / 2, viewH / 2, -viewH / 2, 0.01, 2000 + ); + ortho.position.copy(cam.position); + ortho.quaternion.copy(cam.quaternion); + ortho.updateProjectionMatrix(); + cameraRef.current = ortho; + ctrl.object = ortho as unknown as THREE.Camera; + } else if (!isOrtho && cam instanceof THREE.OrthographicCamera) { + persp.position.copy(cam.position); + persp.quaternion.copy(cam.quaternion); + persp.aspect = aspect; + persp.updateProjectionMatrix(); + cameraRef.current = persp; + ctrl.object = persp as unknown as THREE.Camera; + } + ctrl.update(); + requestRender(); + }; + switchCameraRef.current = switchCamera; + const resize = () => { if (!el || !cameraRef.current || !rendererRef.current) return; const w = el.clientWidth; const h = el.clientHeight; rendererRef.current.setSize(w, h); - cameraRef.current!.aspect = w / h; - cameraRef.current!.updateProjectionMatrix(); + const cam = cameraRef.current; + if (cam instanceof THREE.PerspectiveCamera) { + cam.aspect = w / h; + } else if (cam instanceof THREE.OrthographicCamera) { + const viewH = cam.top - cam.bottom; + const viewW = viewH * (w / h); + cam.left = -viewW / 2; + cam.right = viewW / 2; + } + cam.updateProjectionMatrix(); requestRender(); }; const ro = new ResizeObserver(resize); @@ -124,7 +169,7 @@ export function useThreeScene( const animate = () => { if (controlsRef.current) controlsRef.current.update(); - renderer.render(scene, camera); + if (cameraRef.current) renderer.render(scene, cameraRef.current); rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); @@ -142,9 +187,11 @@ export function useThreeScene( rendererRef.current = null; sceneRef.current = null; cameraRef.current = null; + perspCameraRef.current = null; controlsRef.current = null; modelGroupRef.current = null; materialRef.current = null; + switchCameraRef.current = null; setIsBuilding(false); }; }, [mountRef, setIsBuilding]); @@ -157,6 +204,7 @@ export function useThreeScene( modelGroupRef, materialRef, requestRender: () => requestRenderRef.current?.(), + switchCamera: (isOrtho: boolean) => switchCameraRef.current?.(isOrtho), } as const; } From 7179d79bbb5202d6eaaff5c292e8e2e94d5c454a Mon Sep 17 00:00:00 2001 From: Brice Johnson <{ID}+{username}@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:44:58 -0500 Subject: [PATCH 04/26] Move camera toggle into PreviewActions toolbar with state-aware icon Lifts isOrtho state to App.tsx and passes it down to both ThreeDView (to drive switchCamera) and PreviewActions (to render the button). The toggle is now the first icon-button in the top-right toolbar when in 3D mode, matching the size="icon" square style of undo/redo/download. Icon shows Camera in perspective mode and Box in ortho mode. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 4 ++++ src/components/PreviewActions.tsx | 17 +++++++++++++++ src/components/ThreeDView.tsx | 36 ++++++++++--------------------- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 865a675..fcdbd65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -198,6 +198,7 @@ function App(): React.ReactElement | null { // UI mode toggles (2D / 3D) - UI only for now const [mode, setMode] = useState<'2d' | '3d'>('2d'); const [docsOpen, setDocsOpen] = useState(() => parseDocsHash(window.location.hash) !== null); + const [isOrtho, setIsOrtho] = useState(false); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ @@ -671,6 +672,7 @@ function App(): React.ReactElement | null { heightDithering={threeDState.heightDithering} ditherLineWidth={threeDState.ditherLineWidth} smoothMeshing={threeDState.smoothMeshing} + isOrtho={isOrtho} /> {exportingSTL && ( setIsOrtho((v) => !v)} />
diff --git a/src/components/PreviewActions.tsx b/src/components/PreviewActions.tsx index 86c020a..cd838ab 100644 --- a/src/components/PreviewActions.tsx +++ b/src/components/PreviewActions.tsx @@ -14,6 +14,8 @@ import { Trash2, FileBox, FileType, + Box, + Camera, } from 'lucide-react'; export interface PreviewActionsProps { @@ -36,6 +38,8 @@ export interface PreviewActionsProps { onExportImage: () => Promise; onExportStl: () => Promise; onExport3MF: () => Promise; + isOrtho?: boolean; + onToggleCamera?: () => void; } export const PreviewActions: React.FC = ({ @@ -58,9 +62,22 @@ export const PreviewActions: React.FC = ({ onExportImage, onExportStl, onExport3MF, + isOrtho = false, + onToggleCamera, }) => { return (
+ {mode === '3d' && onToggleCamera && ( + + )} -
+ Model: {modelDimensions.width.toFixed(1)}×{modelDimensions.height.toFixed(1)}× + {modelDimensions.depth.toFixed(1)} mm + + )} {/* Layer Preview Slider */} {!isBuilding && maxModelHeight > 0 && previewHeight !== null && (
From 07e21f988bf2317cc251868f7d30e0cc65fa1a6a Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Tue, 9 Jun 2026 16:39:59 +0300 Subject: [PATCH 05/26] Add SEO-friendly docs URLs --- CHANGELOG.md | 1 + package.json | 2 +- scripts/generate-docs-seo-pages.mjs | 395 +++++++++++++++++++++++ src/App.tsx | 43 +-- src/components/docs/DocsPage.tsx | 27 +- src/components/docs/MarkdownRenderer.tsx | 6 +- src/docs/settings-and-controls.md | 2 + src/lib/docs/navigation.ts | 35 +- src/lib/seo.ts | 99 ++++++ src/main.tsx | 5 +- 10 files changed, 568 insertions(+), 47 deletions(-) create mode 100644 scripts/generate-docs-seo-pages.mjs create mode 100644 src/lib/seo.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d39485..bfcae76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to Kromacut are documented in this file. ### Changed - **Header settings dialog** - Replaced the standalone theme toggle with a centered settings dialog that contains compact System, Dark, and Light theme options plus the current app version. +- **SEO-friendly docs URLs** - Documentation now uses real `/docs/...` URLs with per-page metadata, generated static HTML pages, a sitemap, and robots.txt output. ### Fixed diff --git a/package.json b/package.json index aa392eb..9939a14 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/generate-docs-seo-pages.mjs", "test": "node --no-warnings --experimental-strip-types tests/run-tests.ts", "test:e2e": "playwright test --grep @smoke", "test:e2e:matrix": "playwright test --grep @matrix", diff --git a/scripts/generate-docs-seo-pages.mjs b/scripts/generate-docs-seo-pages.mjs new file mode 100644 index 0000000..324095e --- /dev/null +++ b/scripts/generate-docs-seo-pages.mjs @@ -0,0 +1,395 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const docsDir = path.join(rootDir, 'src', 'docs'); +const distDir = path.join(rootDir, 'dist'); +const distIndexPath = path.join(distDir, 'index.html'); +const siteUrl = 'https://kromacut.com'; +const socialImageUrl = `${siteUrl}/android-chrome-512x512.png`; + +function escapeHtml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function slugify(value) { + return value + .toLowerCase() + .trim() + .replace(/['"]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function createSlugger() { + const seen = new Map(); + return (value) => { + const base = slugify(value) || 'section'; + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return count === 0 ? base : `${base}-${count + 1}`; + }; +} + +function parseFrontmatter(raw) { + const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (!normalized.startsWith('---\n')) { + return { attributes: {}, body: normalized.trim() }; + } + + const end = normalized.indexOf('\n---', 4); + if (end === -1) { + return { attributes: {}, body: normalized.trim() }; + } + + const attributes = {}; + normalized + .slice(4, end) + .split('\n') + .forEach((line) => { + const separator = line.indexOf(':'); + if (separator === -1) return; + const key = line.slice(0, separator).trim(); + const value = line + .slice(separator + 1) + .trim() + .replace(/^["']|["']$/g, ''); + if (key) attributes[key] = value; + }); + + return { + attributes, + body: normalized.slice(end + 4).trim(), + }; +} + +function parseDocs() { + return readdirSync(docsDir) + .filter((file) => file.endsWith('.md')) + .map((file) => { + const raw = readFileSync(path.join(docsDir, file), 'utf8'); + const { attributes, body } = parseFrontmatter(raw); + const title = attributes.title ?? body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? 'Untitled'; + const slug = attributes.slug ?? slugify(title); + const order = Number.parseInt(attributes.order ?? '999', 10); + return { + file, + body, + title, + slug, + description: attributes.description ?? `${title} documentation for Kromacut.`, + order: Number.isFinite(order) ? order : 999, + }; + }) + .sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); +} + +function findBuiltAsset(prefix) { + const assetsDir = path.join(distDir, 'assets'); + if (!existsSync(assetsDir)) return undefined; + const match = readdirSync(assetsDir).find((file) => file.startsWith(prefix)); + return match ? `/assets/${match}` : undefined; +} + +function resolveDocImage(src) { + const clean = src + .trim() + .replace(/^\.?\//, '') + .split(/\s+/)[0] + .replace(/^["']|["']$/g, ''); + + if (clean.includes('td-test.png')) { + return findBuiltAsset('tdTest-') ?? clean; + } + if (clean.includes('kromacut-logo.png')) { + return findBuiltAsset('logo-') ?? clean; + } + return clean; +} + +function resolveDocHref(href, currentDocSlug, docsBySlug) { + const trimmed = href.trim(); + if (!trimmed) return '#'; + if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed; + if (trimmed.startsWith('#')) return trimmed; + + const [docPart, ...headingParts] = trimmed.split('#'); + const docSlug = docPart + .replace(/^\.?\//, '') + .replace(/\.md$/i, '') + .replace(/^docs\//, ''); + const heading = headingParts.join('#'); + + if (!docSlug) { + return heading ? `#${encodeURIComponent(heading)}` : `/docs/${currentDocSlug}`; + } + if (!docsBySlug.has(docSlug)) return '#'; + + return `/docs/${encodeURIComponent(docSlug)}${heading ? `#${encodeURIComponent(heading)}` : ''}`; +} + +function renderInline(markdown, currentDocSlug, docsBySlug) { + let html = escapeHtml(markdown); + + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, rawSrc) => { + const [srcPart, titlePart] = rawSrc.trim().split(/\s+["']/); + const title = titlePart ? titlePart.replace(/["']$/, '') : ''; + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''; + return `${escapeHtml(alt)}`; + }); + + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => { + const resolved = resolveDocHref(href, currentDocSlug, docsBySlug); + const externalAttrs = /^(https?:|mailto:)/i.test(resolved) + ? ' target="_blank" rel="noopener noreferrer"' + : ''; + return `${renderInline(label, currentDocSlug, docsBySlug)}`; + }); + + html = html.replace(/`([^`]+)`/g, '$1'); + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + html = html.replace(/__([^_]+)__/g, '$1'); + html = html.replace(/\*([^*]+)\*/g, '$1'); + html = html.replace(/_([^_]+)_/g, '$1'); + + return html; +} + +function isBlockStart(line) { + const trimmed = line.trim(); + return ( + !trimmed || + /^#{1,6}\s+/.test(trimmed) || + /^([-*+]|\d+[.)])\s+/.test(trimmed) || + /^>\s?/.test(trimmed) || + /^```/.test(trimmed) || + /^-{3,}$|^\*{3,}$|^_{3,}$/.test(trimmed) || + (trimmed.includes('|') && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)) + ); +} + +function splitTableRow(line) { + return line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); +} + +function renderMarkdown(doc, docsBySlug) { + const lines = doc.body.split('\n'); + const slugger = createSlugger(); + const html = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + const trimmed = line.trim(); + + if (!trimmed) { + index++; + continue; + } + + if (trimmed.startsWith('```')) { + const codeLines = []; + index++; + while (index < lines.length && !lines[index].trim().startsWith('```')) { + codeLines.push(lines[index]); + index++; + } + if (index < lines.length) index++; + html.push(`
${escapeHtml(codeLines.join('\n'))}
`); + continue; + } + + const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*$/); + if (heading) { + const depth = heading[1].length; + const text = heading[2].trim(); + html.push( + `${renderInline(text, doc.slug, docsBySlug)}` + ); + index++; + continue; + } + + if (/^-{3,}$|^\*{3,}$|^_{3,}$/.test(trimmed)) { + html.push('
'); + index++; + continue; + } + + if (trimmed.startsWith('>')) { + const parts = []; + while (index < lines.length && lines[index].trim().startsWith('>')) { + parts.push(lines[index].replace(/^\s*>\s?/, '')); + index++; + } + html.push(`
${renderMarkdown({ ...doc, body: parts.join('\n') }, docsBySlug)}
`); + continue; + } + + if ( + line.includes('|') && + lines[index + 1] && + /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1]) + ) { + const headers = splitTableRow(line); + const rows = []; + index += 2; + while (index < lines.length && lines[index].includes('|') && lines[index].trim()) { + rows.push(splitTableRow(lines[index])); + index++; + } + html.push( + `${headers + .map((cell) => ``) + .join('')}${rows + .map( + (row) => + `${row + .map((cell) => ``) + .join('')}` + ) + .join('')}
${renderInline(cell, doc.slug, docsBySlug)}
${renderInline(cell, doc.slug, docsBySlug)}
` + ); + continue; + } + + const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.+)$/); + if (listMatch) { + const ordered = /^\d/.test(listMatch[2]); + const tag = ordered ? 'ol' : 'ul'; + const items = []; + while (index < lines.length) { + const itemMatch = lines[index].match(/^(\s*)([-*+]|\d+[.)])\s+(.+)$/); + if (!itemMatch || /^\d/.test(itemMatch[2]) !== ordered) break; + items.push(`
  • ${renderInline(itemMatch[3], doc.slug, docsBySlug)}
  • `); + index++; + } + html.push(`<${tag}>${items.join('')}`); + continue; + } + + const paragraphLines = [trimmed]; + index++; + while (index < lines.length && !isBlockStart(lines[index])) { + paragraphLines.push(lines[index].trim()); + index++; + } + html.push(`

    ${renderInline(paragraphLines.join(' '), doc.slug, docsBySlug)}

    `); + } + + return html.join('\n'); +} + +function replaceOrInsertHeadTag(html, selector, replacement) { + if (selector.test(html)) return html.replace(selector, replacement); + return html.replace('', ` ${replacement}\n `); +} + +function updateMeta(html, attribute, key, content) { + const escaped = escapeHtml(content); + const pattern = new RegExp(`]*${attribute}="${key}"[^>]*>`, 's'); + return replaceOrInsertHeadTag(html, pattern, ``); +} + +function updateDocHead(template, doc) { + const title = `${doc.title} | Kromacut Docs`; + const url = `${siteUrl}/docs/${doc.slug}`; + let html = template.replace(/.*?<\/title>/, `<title>${escapeHtml(title)}`); + + html = updateMeta(html, 'name', 'description', doc.description); + html = html.replace( + //, + `` + ); + html = updateMeta(html, 'property', 'og:title', title); + html = updateMeta(html, 'property', 'og:description', doc.description); + html = updateMeta(html, 'property', 'og:type', 'article'); + html = updateMeta(html, 'property', 'og:url', url); + html = updateMeta(html, 'property', 'og:image', socialImageUrl); + html = updateMeta(html, 'property', 'og:image:secure_url', socialImageUrl); + html = updateMeta(html, 'name', 'twitter:title', title); + html = updateMeta(html, 'name', 'twitter:description', doc.description); + html = updateMeta(html, 'name', 'twitter:image', socialImageUrl); + + return html; +} + +function renderStaticRoot(doc, docs, docsBySlug) { + const nav = docs + .map((entry) => `
  • ${escapeHtml(entry.title)}
  • `) + .join(''); + const article = renderMarkdown(doc, docsBySlug); + + return `
    +
    + +
    + ${article} +
    +
    +
    `; +} + +function generateDocPage(template, doc, docs, docsBySlug) { + return updateDocHead(template, doc).replace( + /
    <\/div>/, + renderStaticRoot(doc, docs, docsBySlug) + ); +} + +function writeDocPage(template, doc, docs, docsBySlug, slug = doc.slug) { + const outputDir = path.join(distDir, 'docs', slug); + const docPageHtml = generateDocPage(template, doc, docs, docsBySlug); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(path.join(outputDir, 'index.html'), docPageHtml); + + if (slug) { + writeFileSync(path.join(distDir, 'docs', `${slug}.html`), docPageHtml); + } +} + +function writeSitemap(docs) { + const urls = ['/', ...docs.map((doc) => `/docs/${doc.slug}`)]; + const body = urls + .map((url) => ` ${siteUrl}${url === '/' ? '/' : url}`) + .join('\n'); + writeFileSync( + path.join(distDir, 'sitemap.xml'), + `\n\n${body}\n\n` + ); +} + +function writeRobots() { + writeFileSync( + path.join(distDir, 'robots.txt'), + `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n` + ); +} + +if (!existsSync(distIndexPath)) { + throw new Error('dist/index.html was not found. Run this script after vite build.'); +} + +const docs = parseDocs(); +const docsBySlug = new Map(docs.map((doc) => [doc.slug, doc])); +const template = readFileSync(distIndexPath, 'utf8'); +const overviewDoc = docsBySlug.get('overview') ?? docs[0]; + +docs.forEach((doc) => writeDocPage(template, doc, docs, docsBySlug)); +if (overviewDoc) writeDocPage(template, overviewDoc, docs, docsBySlug, ''); +writeSitemap(docs); +writeRobots(); diff --git a/src/App.tsx b/src/App.tsx index 865a675..d0fbffd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,8 @@ import { UpdateChecker } from './components/UpdateChecker'; import ProgressOverlay from './components/ProgressOverlay'; import DocsPage from './components/docs/DocsPage'; import { defaultDocSlug } from './docs'; -import { buildDocsHash, parseDocsHash } from './lib/docs/navigation'; +import { buildDocsPath, parseDocsLocation } from './lib/docs/navigation'; +import { applyHomeSeo } from './lib/seo'; import { AlertDialog, AlertDialogContent, @@ -197,7 +198,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(() => parseDocsHash(window.location.hash) !== null); + const [docsOpen, setDocsOpen] = useState(() => parseDocsLocation(window.location) !== null); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ @@ -279,22 +280,28 @@ function App(): React.ReactElement | null { }, [imageSrc]); useEffect(() => { - const syncDocsHash = () => { - const target = parseDocsHash(window.location.hash); + const syncDocsLocation = () => { + const target = parseDocsLocation(window.location); setDocsOpen(target !== null); }; - window.addEventListener('hashchange', syncDocsHash); - return () => window.removeEventListener('hashchange', syncDocsHash); + 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 (parseDocsHash(window.location.hash)) { - window.history.pushState( - null, - '', - `${window.location.pathname}${window.location.search}` - ); + if (parseDocsLocation(window.location)) { + window.history.pushState(null, '', '/'); } }; @@ -305,8 +312,8 @@ function App(): React.ReactElement | null { } setDocsOpen(true); - if (!parseDocsHash(window.location.hash)) { - window.history.pushState(null, '', buildDocsHash(defaultDocSlug)); + if (!parseDocsLocation(window.location)) { + window.history.pushState(null, '', buildDocsPath(defaultDocSlug)); } }; @@ -428,12 +435,8 @@ function App(): React.ReactElement | null { invalidate(); setImage(tdTestImg, true); setMode('2d'); - if (parseDocsHash(window.location.hash)) { - window.history.pushState( - null, - '', - `${window.location.pathname}${window.location.search}` - ); + if (parseDocsLocation(window.location)) { + window.history.pushState(null, '', '/'); } setDocsOpen(false); }} diff --git a/src/components/docs/DocsPage.tsx b/src/components/docs/DocsPage.tsx index 20b4acd..7d57ef6 100644 --- a/src/components/docs/DocsPage.tsx +++ b/src/components/docs/DocsPage.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { docs, defaultDocSlug } from '@/docs'; import type { DocLinkTarget, DocRecord, TocEntry } from '@/types/docs'; -import { buildDocsHash, parseDocsHash } from '@/lib/docs/navigation'; +import { applyDocSeo } from '@/lib/seo'; +import { buildDocsPath, parseDocsLocation } from '@/lib/docs/navigation'; import MarkdownRenderer from './MarkdownRenderer'; function getInitialTarget(): DocLinkTarget { if (typeof window === 'undefined') return { docSlug: defaultDocSlug }; - return parseDocsHash(window.location.hash) ?? { docSlug: defaultDocSlug }; + return parseDocsLocation(window.location) ?? { docSlug: defaultDocSlug }; } function findDoc(slug: string): DocRecord { @@ -62,22 +63,30 @@ export default function DocsPage() { setActiveDocSlug(nextDoc.meta.slug); setPendingHeading(target.headingSlug); setActiveHeading(target.headingSlug); - window.history.pushState(null, '', buildDocsHash(nextDoc.meta.slug, target.headingSlug)); + window.history.pushState(null, '', buildDocsPath(nextDoc.meta.slug, target.headingSlug)); }, []); useEffect(() => { - const onHashChange = () => { - const target = parseDocsHash(window.location.hash); + const onLocationChange = () => { + const target = parseDocsLocation(window.location); if (!target) return; const nextDoc = findDoc(target.docSlug); setActiveDocSlug(nextDoc.meta.slug); setPendingHeading(target.headingSlug); setActiveHeading(target.headingSlug); }; - window.addEventListener('hashchange', onHashChange); - return () => window.removeEventListener('hashchange', onHashChange); + window.addEventListener('hashchange', onLocationChange); + window.addEventListener('popstate', onLocationChange); + return () => { + window.removeEventListener('hashchange', onLocationChange); + window.removeEventListener('popstate', onLocationChange); + }; }, []); + useEffect(() => { + applyDocSeo(activeDoc); + }, [activeDoc]); + useEffect(() => { const scrollElement = scrollRef.current; if (!scrollElement) return; @@ -162,7 +171,7 @@ export default function DocsPage() { return (
  • { event.preventDefault(); navigate({ docSlug: doc.meta.slug }); @@ -227,7 +236,7 @@ export default function DocsPage() { return ( { event.preventDefault(); navigate({ diff --git a/src/components/docs/MarkdownRenderer.tsx b/src/components/docs/MarkdownRenderer.tsx index 6d50109..094b936 100644 --- a/src/components/docs/MarkdownRenderer.tsx +++ b/src/components/docs/MarkdownRenderer.tsx @@ -7,7 +7,7 @@ import type { MarkdownRendererProps, } from '@/types/docs'; import { resolveDocAsset } from '@/docs/assets'; -import { buildDocsHash, isSafeExternalHref, resolveDocHref } from '@/lib/docs/navigation'; +import { buildDocsPath, isSafeExternalHref, resolveDocHref } from '@/lib/docs/navigation'; function linkClassName() { return 'font-semibold text-primary underline underline-offset-4 hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm'; @@ -67,7 +67,7 @@ function renderInlineNodes( return ( { event.preventDefault(); props.onNavigate(target); @@ -132,7 +132,7 @@ function renderHeading( <> {renderInlineNodes(block.children, props, `heading-${block.id}`)} { event.preventDefault(); props.onNavigate(target); diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md index 865f6a1..01948d1 100644 --- a/src/docs/settings-and-controls.md +++ b/src/docs/settings-and-controls.md @@ -30,6 +30,8 @@ Use the **2D** and **3D** buttons to switch between image preparation and model The vertical splitter between the controls panel and preview can be dragged. Make the left panel wider when working with detailed settings, or make the preview wider when inspecting the image or model. +Documentation pages use shareable `/docs/...` links. Opening one of those links takes you directly to the matching guide. + ## Saved Print Settings Kromacut remembers print settings such as **Pixel Size (XY)**, **Layer Height**, **First Layer Height**, and **Smooth Meshing** in the browser. diff --git a/src/lib/docs/navigation.ts b/src/lib/docs/navigation.ts index 8830fab..e88323a 100644 --- a/src/lib/docs/navigation.ts +++ b/src/lib/docs/navigation.ts @@ -1,5 +1,7 @@ import type { DocLinkTarget, DocRecord } from '@/types/docs'; +const DOCS_PATH_PREFIX = '/docs'; + function cleanDocSlug(value: string): string { return value .trim() @@ -22,24 +24,27 @@ function safeDecodeURIComponent(value: string): string | null { } } -export function buildDocsHash(docSlug: string, headingSlug?: string): string { - const encodedDoc = encodeURIComponent(docSlug); +export function buildDocsPath(docSlug: string, headingSlug?: string): string { + const encodedDoc = encodeURIComponent(cleanDocSlug(docSlug)); const encodedHeading = headingSlug ? `#${encodeURIComponent(headingSlug)}` : ''; - return `#docs/${encodedDoc}${encodedHeading}`; + return `${DOCS_PATH_PREFIX}/${encodedDoc}${encodedHeading}`; } -export function parseDocsHash(hash: string): DocLinkTarget | null { - const raw = hash.replace(/^#/, ''); - if (!raw.startsWith('docs/')) return null; +export function parseDocsPath(pathname: string, hash = ''): DocLinkTarget | null { + const normalizedPath = pathname.replace(/\/+$/, '') || '/'; + if (normalizedPath !== DOCS_PATH_PREFIX && !normalizedPath.startsWith(`${DOCS_PATH_PREFIX}/`)) { + return null; + } - const { docPart, headingPart } = splitDocAndHeading(raw.slice('docs/'.length)); - const decodedDocPart = safeDecodeURIComponent(docPart); - if (decodedDocPart === null) return null; + const rawDocSlug = normalizedPath.slice(DOCS_PATH_PREFIX.length).replace(/^\/+/, ''); + const decodedDocSlug = rawDocSlug ? safeDecodeURIComponent(rawDocSlug) : 'overview'; + if (decodedDocSlug === null) return null; - const decodedHeading = headingPart ? safeDecodeURIComponent(headingPart) : undefined; + const rawHeading = hash.replace(/^#/, ''); + const decodedHeading = rawHeading ? safeDecodeURIComponent(rawHeading) : undefined; if (decodedHeading === null) return null; - const docSlug = cleanDocSlug(decodedDocPart); + const docSlug = cleanDocSlug(decodedDocSlug); if (!docSlug) return null; return { @@ -48,6 +53,14 @@ export function parseDocsHash(hash: string): DocLinkTarget | null { }; } +export function parseDocsLocation(location: Pick): DocLinkTarget | null { + return parseDocsPath(location.pathname, location.hash); +} + +export function isDocsPath(pathname: string): boolean { + return parseDocsPath(pathname) !== null; +} + export function resolveDocHref( href: string, currentDocSlug: string, diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..ddef712 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,99 @@ +import type { DocRecord } from '@/types/docs'; + +export const SITE_URL = 'https://kromacut.com'; +export const SITE_NAME = 'Kromacut'; +export const SOCIAL_IMAGE_URL = `${SITE_URL}/android-chrome-512x512.png`; + +const HOME_TITLE = 'Kromacut - Free Image-to-3D Color Layer Print Generator'; +const HOME_DESCRIPTION = + 'Turn 2D images into color-layered 3D prints for free with Kromacut. Reduce palettes, plan filament swaps, preview layers, and export STL or 3MF models.'; + +function absoluteUrl(pathname: string): string { + return new URL(pathname, SITE_URL).toString(); +} + +export function docPath(docSlug: string): string { + return `/docs/${encodeURIComponent(docSlug)}`; +} + +export function docUrl(docSlug: string): string { + return absoluteUrl(docPath(docSlug)); +} + +function findOrCreateMeta(attribute: 'name' | 'property', key: string): HTMLMetaElement { + let meta = document.querySelector(`meta[${attribute}="${key}"]`); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute(attribute, key); + document.head.appendChild(meta); + } + return meta; +} + +function setMeta(attribute: 'name' | 'property', key: string, content: string) { + findOrCreateMeta(attribute, key).content = content; +} + +function findOrCreateCanonical(): HTMLLinkElement { + let link = document.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement('link'); + link.rel = 'canonical'; + document.head.appendChild(link); + } + return link; +} + +function applySeo({ + title, + description, + url, + type = 'website', +}: { + title: string; + description: string; + url: string; + type?: string; +}) { + document.title = title; + setMeta('name', 'description', description); + findOrCreateCanonical().href = url; + + setMeta('property', 'og:title', title); + setMeta('property', 'og:description', description); + setMeta('property', 'og:type', type); + setMeta('property', 'og:url', url); + setMeta('property', 'og:site_name', SITE_NAME); + setMeta('property', 'og:image', SOCIAL_IMAGE_URL); + setMeta('property', 'og:image:secure_url', SOCIAL_IMAGE_URL); + + setMeta('name', 'twitter:card', 'summary'); + setMeta('name', 'twitter:title', title); + setMeta('name', 'twitter:description', description); + setMeta('name', 'twitter:image', SOCIAL_IMAGE_URL); +} + +export function applyHomeSeo() { + applySeo({ + title: HOME_TITLE, + description: HOME_DESCRIPTION, + url: absoluteUrl('/'), + }); +} + +export function docSeoTitle(doc: DocRecord): string { + return `${doc.meta.title} | Kromacut Docs`; +} + +export function docSeoDescription(doc: DocRecord): string { + return doc.meta.description ?? `${doc.meta.title} documentation for Kromacut.`; +} + +export function applyDocSeo(doc: DocRecord) { + applySeo({ + title: docSeoTitle(doc), + description: docSeoDescription(doc), + url: docUrl(doc.meta.slug), + type: 'article', + }); +} diff --git a/src/main.tsx b/src/main.tsx index ada1df5..1bbaa06 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,12 +3,11 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; import { applyThemeMode, getStoredThemeMode } from './lib/theme'; +import { applyHomeSeo } from './lib/seo'; // Apply the saved theme preference before React paints. applyThemeMode(getStoredThemeMode()); - -// Keep browser tabs compact while the static HTML title remains descriptive for crawlers and previews. -document.title = 'Kromacut'; +applyHomeSeo(); createRoot(document.getElementById('root')!).render( From 950d51517257fa10e8dd4f6e3bba1c57200081c5 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Tue, 9 Jun 2026 17:03:35 +0300 Subject: [PATCH 06/26] Add desktop update settings --- CHANGELOG.md | 2 + src/components/Header.tsx | 161 +++++++++++++++++++++++++++++- src/components/UpdateChecker.tsx | 58 +++++++---- src/docs/settings-and-controls.md | 4 +- src/lib/desktopUpdates.ts | 26 +++++ src/lib/updatePreferences.ts | 57 +++++++++++ 6 files changed, 287 insertions(+), 21 deletions(-) create mode 100644 src/lib/desktopUpdates.ts create mode 100644 src/lib/updatePreferences.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcae76..52daf94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to Kromacut are documented in this file. ### Added +- **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup. + ### Changed - **Header settings dialog** - Replaced the standalone theme toggle with a centered settings dialog that contains compact System, Dark, and Light theme options plus the current app version. diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6d57827..6cdcd5c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,19 +1,31 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import { + AlertCircle, ArrowLeft, BookOpen, + CheckCircle2, + Download, Image, Github, Heart, + Loader2, Moon, Sun, MessageCircle, + RefreshCw, Settings, X, Monitor, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { Switch } from '@/components/ui/switch'; +import { + checkForDesktopUpdates, + isDesktopUpdateSupported, + openDesktopReleasesPage, + type VersionInfo, +} from '@/lib/desktopUpdates'; import { applyResolvedTheme, applyThemeMode, @@ -23,6 +35,11 @@ import { THEME_STORAGE_KEY, type ThemeMode, } from '@/lib/theme'; +import { + getUpdateCheckOnStartup, + saveUpdateCheckOnStartup, + subscribeToUpdateCheckOnStartup, +} from '@/lib/updatePreferences'; import logo from '../assets/logo.png'; interface Props { @@ -33,11 +50,18 @@ interface Props { } const appVersion = __APP_VERSION__; +type UpdateCheckStatus = 'idle' | 'checking' | 'available' | 'current' | 'error'; export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onToggleDocs }) => { const [themeMode, setThemeMode] = React.useState(() => getStoredThemeMode()); const [settingsOpen, setSettingsOpen] = React.useState(false); + const [checkOnStartup, setCheckOnStartup] = React.useState(() => getUpdateCheckOnStartup()); + const [updateStatus, setUpdateStatus] = React.useState('idle'); + const [availableUpdate, setAvailableUpdate] = React.useState(null); + const [updateError, setUpdateError] = React.useState(''); const settingsTitleId = React.useId(); + const updateStartupSwitchId = React.useId(); + const isDesktopApp = isDesktopUpdateSupported(); React.useEffect(() => { if (!settingsOpen) return; @@ -75,11 +99,56 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT return () => window.removeEventListener('storage', handleStorageChange); }, []); + React.useEffect(() => { + if (!isDesktopUpdateSupported()) return; + + return subscribeToUpdateCheckOnStartup(setCheckOnStartup); + }, []); + + React.useEffect(() => { + if (settingsOpen) return; + + setUpdateStatus('idle'); + setAvailableUpdate(null); + setUpdateError(''); + }, [settingsOpen]); + const setTheme = (nextThemeMode: ThemeMode) => { saveThemeMode(nextThemeMode); setThemeMode(nextThemeMode); }; + const setStartupUpdateChecks = (enabled: boolean) => { + saveUpdateCheckOnStartup(enabled); + setCheckOnStartup(enabled); + }; + + const handleCheckForUpdates = async () => { + setUpdateStatus('checking'); + setAvailableUpdate(null); + setUpdateError(''); + + try { + const updateInfo = await checkForDesktopUpdates(); + setAvailableUpdate(updateInfo); + setUpdateStatus(updateInfo ? 'available' : 'current'); + } catch (error) { + console.error('Failed to check for updates:', error); + setUpdateError('Could not check for updates. Try again later.'); + setUpdateStatus('error'); + } + }; + + const handleDownloadUpdate = async () => { + try { + await openDesktopReleasesPage(); + } catch (error) { + console.error('Failed to open releases page:', error); + setUpdateError('Could not open the download page.'); + setUpdateStatus('error'); + } + }; + return (
    @@ -195,7 +264,7 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT role="dialog" aria-modal="true" aria-labelledby={settingsTitleId} - className="w-[min(92vw,34rem)] rounded-lg border border-border bg-popover p-5 text-popover-foreground shadow-2xl" + className="max-h-[min(90vh,42rem)] w-[min(92vw,36rem)] overflow-y-auto rounded-lg border border-border bg-popover p-5 text-popover-foreground shadow-2xl" onClick={(event) => event.stopPropagation()} >
    @@ -263,6 +332,96 @@ export const Header: React.FC = ({ onLoadTest, docsOpen, onBackToApp, onT
    + {isDesktopApp && ( +
    +
    +
    + Updates +
    + +
    + +
    +
    + + +
    +
    + +
    + {updateStatus === 'available' && availableUpdate && ( +
    +
    + +
    +
    + Version {availableUpdate.version} is available +
    + {availableUpdate.release_notes && ( +
    + {availableUpdate.release_notes} +
    + )} +
    + +
    +
    + )} + + {updateStatus === 'current' && ( +
    + + Kromacut is up to date. +
    + )} + + {updateStatus === 'error' && ( +
    + + {updateError} +
    + )} +
    +
    + )} +
    Kromacut v{appVersion} diff --git a/src/components/UpdateChecker.tsx b/src/components/UpdateChecker.tsx index e040643..ba5a65a 100644 --- a/src/components/UpdateChecker.tsx +++ b/src/components/UpdateChecker.tsx @@ -9,38 +9,55 @@ import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Download, X } from 'lucide-react'; -import { invoke, isTauri } from '@tauri-apps/api/core'; - -interface VersionInfo { - version: string; - download_url?: string; - release_notes?: string; -} +import { + checkForDesktopUpdates, + isDesktopUpdateSupported, + openDesktopReleasesPage, + type VersionInfo, +} from '@/lib/desktopUpdates'; +import { + getUpdateCheckOnStartup, + subscribeToUpdateCheckOnStartup, +} from '@/lib/updatePreferences'; export function UpdateChecker() { const [updateAvailable, setUpdateAvailable] = useState(null); const [dismissed, setDismissed] = useState(false); const [checking, setChecking] = useState(false); + const [checkOnStartup, setCheckOnStartup] = useState(() => getUpdateCheckOnStartup()); useEffect(() => { - // Only check for updates in Tauri environment - if (!isTauri()) return; + if (!isDesktopUpdateSupported()) return; + + return subscribeToUpdateCheckOnStartup(setCheckOnStartup); + }, []); + + useEffect(() => { + if (!checkOnStartup) { + setUpdateAvailable(null); + setChecking(false); + } + }, [checkOnStartup]); + + useEffect(() => { + if (!isDesktopUpdateSupported() || !checkOnStartup) return; + + let active = true; const checkForUpdates = async () => { setChecking(true); try { - const currentVersion = await invoke('get_app_version'); - const updateInfo = await invoke('check_for_updates', { - currentVersion, - }); + const updateInfo = await checkForDesktopUpdates(); - if (updateInfo) { + if (active) { setUpdateAvailable(updateInfo); } } catch (error) { console.error('Failed to check for updates:', error); } finally { - setChecking(false); + if (active) { + setChecking(false); + } } }; @@ -50,16 +67,19 @@ export function UpdateChecker() { // Check periodically (every 4 hours) const interval = setInterval(checkForUpdates, 4 * 60 * 60 * 1000); - return () => clearInterval(interval); - }, []); + return () => { + active = false; + clearInterval(interval); + }; + }, [checkOnStartup]); - if (!isTauri() || !updateAvailable || dismissed || checking) { + if (!isDesktopUpdateSupported() || !checkOnStartup || !updateAvailable || dismissed || checking) { return null; } const handleDownload = async () => { try { - await invoke('open_releases_page'); + await openDesktopReleasesPage(); } catch (error) { console.error('Failed to open releases page:', error); } diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md index 01948d1..319ab05 100644 --- a/src/docs/settings-and-controls.md +++ b/src/docs/settings-and-controls.md @@ -18,7 +18,7 @@ This page collects controls that affect the whole app or are easy to miss. | Discord | Opens the community link. | | GitHub | Opens the project page. | | Support me | Opens the support link. | -| Settings | Opens the settings dialog, including the theme selector. | +| Settings | Opens the settings dialog, including theme and desktop update controls. | The theme selector offers **System**, **Dark**, and **Light**. **System** follows the operating system or browser color-scheme preference and updates when that preference changes. The theme choice is saved for later sessions. @@ -68,4 +68,6 @@ Use profile import and export to move calibrated filaments between browsers or s In the desktop app, Kromacut can show an update notice when a newer version is available. The notice lets you open the download page or dismiss the reminder. +Open **Settings** to check for updates manually. The desktop settings also include **Check on startup**, which controls whether Kromacut checks for updates when the app opens. This is enabled by default, and manual checks still work when it is off. + Next: [Troubleshooting](troubleshooting). diff --git a/src/lib/desktopUpdates.ts b/src/lib/desktopUpdates.ts new file mode 100644 index 0000000..27b4f90 --- /dev/null +++ b/src/lib/desktopUpdates.ts @@ -0,0 +1,26 @@ +import { invoke, isTauri } from '@tauri-apps/api/core'; + +export interface VersionInfo { + version: string; + download_url?: string; + release_notes?: string; +} + +export function isDesktopUpdateSupported(): boolean { + return isTauri(); +} + +export async function checkForDesktopUpdates(): Promise { + if (!isDesktopUpdateSupported()) { + return null; + } + + const currentVersion = await invoke('get_app_version'); + return invoke('check_for_updates', { + currentVersion, + }); +} + +export async function openDesktopReleasesPage(): Promise { + await invoke('open_releases_page'); +} diff --git a/src/lib/updatePreferences.ts b/src/lib/updatePreferences.ts new file mode 100644 index 0000000..49094fc --- /dev/null +++ b/src/lib/updatePreferences.ts @@ -0,0 +1,57 @@ +export const UPDATE_CHECK_ON_STARTUP_STORAGE_KEY = 'kromacut:update-check-on-startup'; +export const UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT = 'kromacut:update-check-on-startup-changed'; + +const DEFAULT_CHECK_ON_STARTUP = true; + +export function getUpdateCheckOnStartup(): boolean { + if (typeof window === 'undefined') { + return DEFAULT_CHECK_ON_STARTUP; + } + + try { + return window.localStorage.getItem(UPDATE_CHECK_ON_STARTUP_STORAGE_KEY) !== 'false'; + } catch { + return DEFAULT_CHECK_ON_STARTUP; + } +} + +export function saveUpdateCheckOnStartup(enabled: boolean) { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(UPDATE_CHECK_ON_STARTUP_STORAGE_KEY, String(enabled)); + } catch { + // The in-session preference should still update if storage is blocked. + } + + window.dispatchEvent( + new CustomEvent(UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT, { detail: enabled }) + ); +} + +export function subscribeToUpdateCheckOnStartup(onChange: (enabled: boolean) => void) { + if (typeof window === 'undefined') { + return () => {}; + } + + const handlePreferenceChange = (event: Event) => { + const detail = (event as CustomEvent).detail; + onChange(typeof detail === 'boolean' ? detail : getUpdateCheckOnStartup()); + }; + + const handleStorageChange = (event: StorageEvent) => { + if (event.key === UPDATE_CHECK_ON_STARTUP_STORAGE_KEY) { + onChange(getUpdateCheckOnStartup()); + } + }; + + window.addEventListener(UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT, handlePreferenceChange); + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener(UPDATE_CHECK_ON_STARTUP_CHANGED_EVENT, handlePreferenceChange); + window.removeEventListener('storage', handleStorageChange); + }; +} From 573223211127d6788180884726e82c4d0e27c51e Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Tue, 9 Jun 2026 17:29:05 +0300 Subject: [PATCH 07/26] Add new screenshot of autopaint 3d mode --- content/autopaint-3d-mode.png | Bin 0 -> 238614 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 content/autopaint-3d-mode.png diff --git a/content/autopaint-3d-mode.png b/content/autopaint-3d-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..3c9c16ce80ef4e9ccb5fa38bdcedd5e700d90ddc GIT binary patch literal 238614 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

    w_QVYqZ+F}37y>?QvxC?MQ;fTR^LeeY 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 z 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 NXR<0XSyFnQ{|{lhR#pH2 literal 0 HcmV?d00001 From 857588b4cc13e6d7d975c0d8052f243b9b9d9d43 Mon Sep 17 00:00:00 2001 From: Brice Johnson <{ID}+{username}@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:15:31 -0500 Subject: [PATCH 08/26] Add next-best-color suggestion with blend-aware scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces nextBestColor() which recommends the single most impactful filament to add, scored by how much it reduces blend-aware reachable error across all image swatches. Scoring uses segment geometry in Lab space: each candidate C is evaluated by the gain from new C↔filament blend lines (distToSegment), not just direct ΔE. The baseline also accounts for existing filament↔filament blend lines, so the metric correctly values isolated candidates whose longer segments sweep previously unreachable regions of Lab space. Candidate pool is restricted to the p75 most underserved swatches (by blend-aware reachable error) to reduce O(S² × F) to O(0.25S² × F). Adds "Suggest next filament" button in AutoPaintTab with a result card showing hex swatch, recommended TD, improvement %, pixels captured, and isolation score. "Add to filaments" names the entry Kromacut-Suggestion-NN. Co-Authored-By: Claude Sonnet 4.6 --- src/components/AutoPaintTab.tsx | 88 ++++++++++ src/components/ThreeDControls.tsx | 4 +- src/hooks/useFilaments.ts | 8 + src/lib/nextBestColor.ts | 268 ++++++++++++++++++++++++++++++ tests/nextBestColor.test.ts | 251 ++++++++++++++++++++++++++++ 5 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/lib/nextBestColor.ts create mode 100644 tests/nextBestColor.test.ts diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 26b8d46..cb58072 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -21,6 +21,8 @@ import type { CalibrationResult } from '../lib/calibration'; import FilamentRow from './FilamentRow'; import { FilamentCalibrationWizard } from './FilamentCalibrationWizard'; import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration'; +import { nextBestColor } from '../lib/nextBestColor'; +import type { NextBestColorResult } from '../lib/nextBestColor'; interface AutoPaintSliceData { virtualSwatches: Swatch[]; @@ -33,6 +35,7 @@ interface AutoPaintTabProps { // Filament state filaments: Filament[]; addFilament: () => void; + addFilamentWithProps: (props: { color: string; td: number; name: string }) => void; removeFilament: (id: string) => void; updateFilament: (id: string, updates: Partial>) => void; @@ -65,6 +68,7 @@ interface AutoPaintTabProps { // Image colors filteredCount: number; + imageSwatches: Array<{ hex: string; count?: number }>; // Enhanced matching options enhancedColorMatch: boolean; @@ -88,6 +92,7 @@ interface AutoPaintTabProps { export default function AutoPaintTab({ filaments, addFilament, + addFilamentWithProps, removeFilament, updateFilament, profiles, @@ -114,6 +119,7 @@ export default function AutoPaintTab({ calibrationLayerHeight, setCalibrationLayerHeight: _setCalibrationLayerHeight, filteredCount, + imageSwatches, enhancedColorMatch, setEnhancedColorMatch, allowRepeatedSwaps, @@ -129,6 +135,8 @@ export default function AutoPaintTab({ regionWeightingMode, setRegionWeightingMode, }: AutoPaintTabProps) { + const [nextBestResult, setNextBestResult] = React.useState(null); + const suggestionCountRef = React.useRef(0); const [localDitherLineWidth, setLocalDitherLineWidth] = React.useState( ditherLineWidth.toString() ); @@ -821,6 +829,86 @@ export default function AutoPaintTab({ )}

    )} + + {/* Next-best-color suggestion */} + {autoPaintResult && imageSwatches.length > 0 && ( +
    + + {nextBestResult?.candidate && ( +
    +
    + + + {nextBestResult.candidate.hex.toUpperCase()} + + + +{nextBestResult.candidate.improvementPct.toFixed(1)}% + +
    +
    + + TD:{' '} + + {nextBestResult.candidate.td.toFixed(2)} + + + + Captures:{' '} + + {( + (nextBestResult.candidate.pixelsCaptured / + nextBestResult.totalPixels) * + 100 + ).toFixed(1)} + % + + + + Isolation:{' '} + + {nextBestResult.candidate.isolationScore.toFixed(2)} + + +
    + +
    + )} + {nextBestResult && !nextBestResult.candidate && ( +

    + Current filament set already covers all image colors well. +

    + )} +
    + )}
    diff --git a/src/components/ThreeDControls.tsx b/src/components/ThreeDControls.tsx index 7c9331a..27204a4 100644 --- a/src/components/ThreeDControls.tsx +++ b/src/components/ThreeDControls.tsx @@ -42,7 +42,7 @@ interface ThreeDControlsProps { export default function ThreeDControls({ swatches, imageDimensions, onChange, onSettingsChange, persisted }: ThreeDControlsProps) { // --- Filaments --- - const { filaments, setFilaments, addFilament, removeFilament, updateFilament } = useFilaments({ + const { filaments, setFilaments, addFilament, addFilamentWithProps, removeFilament, updateFilament } = useFilaments({ initial: persisted?.filaments?.length ? persisted.filaments : undefined, }); @@ -342,6 +342,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on ) => { + setFilaments((prev) => [ + ...prev, + { id: Math.random().toString(36).substring(2, 9), ...props }, + ]); + }, []); + const removeFilament = useCallback((id: string) => { setFilaments((prev) => prev.filter((f) => f.id !== id)); }, []); @@ -33,6 +40,7 @@ export function useFilaments(options: UseFilamentsOptions = {}) { filaments, setFilaments, addFilament, + addFilamentWithProps, removeFilament, updateFilament, }; diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts new file mode 100644 index 0000000..7a14d8f --- /dev/null +++ b/src/lib/nextBestColor.ts @@ -0,0 +1,268 @@ +import type { Filament } from '../types/index.ts'; + +// --------------------------------------------------------------------------- +// Minimal color math — inlined to avoid pulling in autoPaint's optimizer dep. +// --------------------------------------------------------------------------- + +interface RGB { r: number; g: number; b: number } +interface Lab { L: number; a: number; b: number } + +function hexToRgb(hex: string): RGB { + const h = hex.replace(/^#/, ''); + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + }; +} + +// IEC 61966-2-1 sRGB linearisation thresholds and coefficients. +const SRGB_LINEARISE_THRESHOLD = 0.04045; +const SRGB_LINEARISE_SCALE = 12.92; +const SRGB_LINEARISE_OFFSET = 0.055; +const SRGB_LINEARISE_DENOM = 1.055; +const SRGB_LINEARISE_GAMMA = 2.4; + +// IEC 61966-2-1 sRGB → CIE XYZ (D65) matrix row coefficients. +const M_RX = 0.4124564, M_GX = 0.3575761, M_BX = 0.1804375; +const M_RY = 0.2126729, M_GY = 0.7151522, M_BY = 0.0721750; +const M_RZ = 0.0193339, M_GZ = 0.1191920, M_BZ = 0.9503041; + +// CIE standard illuminant D65 tristimulus values (normalises XYZ to [0,1]). +const D65_X = 0.95047; +const D65_Y = 1.00000; +const D65_Z = 1.08883; + +// CIE L*a*b* cube-root approximation thresholds and coefficients (CIE 1976). +const LAB_EPSILON = 0.008856; // (6/29)³ +const LAB_KAPPA = 7.787; // (29/6)² / 3 — slope of the linear segment +const LAB_DELTA_16 = 16 / 116; // y-intercept of the linear segment + +function rgbToLab(rgb: RGB): Lab { + const linearise = (v: number) => { + const s = v / 255; + return s <= SRGB_LINEARISE_THRESHOLD + ? s / SRGB_LINEARISE_SCALE + : Math.pow((s + SRGB_LINEARISE_OFFSET) / SRGB_LINEARISE_DENOM, SRGB_LINEARISE_GAMMA); + }; + const r = linearise(rgb.r), g = linearise(rgb.g), b = linearise(rgb.b); + + const fx = (r * M_RX + g * M_GX + b * M_BX) / D65_X; + const fy = (r * M_RY + g * M_GY + b * M_BY) / D65_Y; + const fz = (r * M_RZ + g * M_GZ + b * M_BZ) / D65_Z; + + const f = (t: number) => t > LAB_EPSILON ? Math.cbrt(t) : LAB_KAPPA * t + LAB_DELTA_16; + return { L: 116 * f(fy) - 16, a: 500 * (f(fx) - f(fy)), b: 200 * (f(fy) - f(fz)) }; +} + +function deltaELab(a: Lab, b: Lab): number { + return Math.sqrt((a.L - b.L) ** 2 + (a.a - b.a) ** 2 + (a.b - b.b) ** 2); +} + +/** + * CIE76 distance from Lab point P to the nearest point on segment A↔B. + * In Beer-Lambert blending, any mix of filaments A and B lies on the straight + * line between them in Lab space, so this gives the minimum ΔE achievable by + * blending A and B at any ratio. + */ +function distToSegment(P: Lab, A: Lab, B: Lab): number { + const ABL = B.L - A.L, ABa = B.a - A.a, ABb = B.b - A.b; + const APL = P.L - A.L, APa = P.a - A.a, APb = P.b - A.b; + const lenSq = ABL * ABL + ABa * ABa + ABb * ABb; + const t = lenSq > 0 ? Math.max(0, Math.min(1, (APL * ABL + APa * ABa + APb * ABb) / lenSq)) : 0; + const dL = APL - t * ABL, da = APa - t * ABa, db = APb - t * ABb; + return Math.sqrt(dL * dL + da * da + db * db); +} + +export interface ColorCandidate { + hex: string; + /** Recommended starting TD, derived from the nearest existing filament by ΔE. */ + td: number; + /** + * % reduction in blend-aware weighted-average ΔE vs current filament set. + * The baseline already accounts for existing filament↔filament blend lines, + * so this reflects how much the new candidate genuinely adds. + */ + improvementPct: number; + /** Number of image pixels whose blend-aware error improves with this candidate. */ + pixelsCaptured: number; + /** 0–1: nearest-filament ΔE normalised to [0,1] across viable candidates. Informational only — ranking uses blend-aware gain directly. */ + isolationScore: number; +} + +export interface NextBestColorResult { + candidate: ColorCandidate | null; + /** Blend-aware weighted-average ΔE across all image pixels before adding anything. */ + baselineAvgDeltaE: number; + totalPixels: number; +} + +/** + * Given the current filament set and image swatches, returns the single image + * color whose addition as a new filament would most reduce the blend-aware + * weighted-average ΔE between the rendered print and the target image. + * + * Blend-aware metric: the "reachable error" for a swatch is the minimum ΔE + * achievable by any filament point or any blend along a filament↔filament + * segment in Lab space. Adding candidate C introduces new C↔filament segments, + * so isolated candidates are naturally rewarded — they create long new segments + * that sweep previously unreachable regions of Lab space. + * + * currentReachable(swatch) = min over all filament points and F↔F segments + * newReachable(swatch, C) = min(currentReachable, ΔE(swatch,C), min_F dist(swatch, C↔F)) + * gain(C) = Σ_i max(0, currentReachable_i − newReachable_i) × count_i + */ +export function nextBestColor( + filaments: Filament[], + imageSwatches: Array<{ hex: string; count?: number }> +): NextBestColorResult { + const empty: NextBestColorResult = { candidate: null, baselineAvgDeltaE: 0, totalPixels: 0 }; + + if (filaments.length === 0 || imageSwatches.length === 0) return empty; + + // Pre-compute Lab values for filaments and swatches. + const filamentLabs: Lab[] = filaments.map((f) => rgbToLab(hexToRgb(f.color))); + const swatchLabs: Lab[] = imageSwatches.map((s) => rgbToLab(hexToRgb(s.hex))); + const counts: number[] = imageSwatches.map((s) => s.count ?? 1); + + // ------------------------------------------------------------------------- + // Baseline: blend-aware reachable error for every swatch. + // Accounts for direct filament points and all existing filament↔filament + // blend lines (Beer-Lambert linear interpolation in Lab space). + // ------------------------------------------------------------------------- + const currentReachable: number[] = swatchLabs.map((sLab) => { + let best = Infinity; + for (const fLab of filamentLabs) { + best = Math.min(best, deltaELab(sLab, fLab)); + } + for (let fi = 0; fi < filamentLabs.length; fi++) { + for (let fj = fi + 1; fj < filamentLabs.length; fj++) { + best = Math.min(best, distToSegment(sLab, filamentLabs[fi], filamentLabs[fj])); + } + } + return best; + }); + + const totalPixels = counts.reduce((s, c) => s + c, 0); + const baselineTotal = currentReachable.reduce((s, e, i) => s + e * counts[i], 0); + const baselineAvgDeltaE = totalPixels > 0 ? baselineTotal / totalPixels : 0; + + if (baselineAvgDeltaE === 0) return { candidate: null, baselineAvgDeltaE: 0, totalPixels }; + + // ------------------------------------------------------------------------- + // Score each candidate. + // Restrict the candidate pool to the most underserved swatches — those at + // or above the 75th percentile of blend-aware reachable error — so the + // O(candidates × swatches × filaments) inner loop only runs for colors + // that genuinely lack coverage. Also skip near-duplicates of existing + // filaments regardless of percentile rank. + // ------------------------------------------------------------------------- + const COVERAGE_THRESHOLD = 3.0; // ΔE — skip near-duplicates of existing filaments + + const sortedReachable = [...currentReachable].sort((a, b) => a - b); + const p75Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.75)]; + + interface CandidateScore { idx: number; gain: number; nearestFilamentDE: number } + const scores: CandidateScore[] = []; + + for (let c = 0; c < swatchLabs.length; c++) { + if (currentReachable[c] < p75Threshold) continue; + + let nearestFilamentDE = Infinity; + for (const fLab of filamentLabs) { + nearestFilamentDE = Math.min(nearestFilamentDE, deltaELab(swatchLabs[c], fLab)); + } + if (nearestFilamentDE < COVERAGE_THRESHOLD) continue; + + // For each swatch, compute the new reachable error if candidate C is added. + // This includes direct distance to C and segments from C to every existing filament. + let gain = 0; + for (let i = 0; i < swatchLabs.length; i++) { + let newReachable = currentReachable[i]; + newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], swatchLabs[c])); + for (const fLab of filamentLabs) { + newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], swatchLabs[c], fLab)); + } + const improvement = currentReachable[i] - newReachable; + if (improvement > 0) gain += improvement * counts[i]; + } + + scores.push({ idx: c, gain, nearestFilamentDE }); + } + + if (scores.length === 0) return { candidate: null, baselineAvgDeltaE, totalPixels }; + + // Isolation score is informational — the blend-aware gain metric already + // rewards isolated candidates (longer C↔F segments cover more Lab space). + const maxIsolation = Math.max(...scores.map((s) => s.nearestFilamentDE)); + + // Pick winner by blend-aware gain. + let bestGain = -1; + let bestIdx = -1; + for (const s of scores) { + if (s.gain > bestGain) { bestGain = s.gain; bestIdx = s.idx; } + } + + if (bestIdx === -1) return { candidate: null, baselineAvgDeltaE, totalPixels }; + + // ------------------------------------------------------------------------- + // Build the result for the winning candidate. + // ------------------------------------------------------------------------- + const candLab = swatchLabs[bestIdx]; + + // Pixel capture: swatches whose blend-aware reachable error improves with C. + let pixelsCaptured = 0; + for (let i = 0; i < swatchLabs.length; i++) { + let newReachable = currentReachable[i]; + newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], candLab)); + for (const fLab of filamentLabs) { + newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], candLab, fLab)); + } + if (newReachable < currentReachable[i]) pixelsCaptured += counts[i]; + } + + // TD: borrow from nearest existing filament by ΔE. + let nearestFilamentIdx = 0; + let nearestDE = Infinity; + for (let fi = 0; fi < filamentLabs.length; fi++) { + const de = deltaELab(candLab, filamentLabs[fi]); + if (de < nearestDE) { nearestDE = de; nearestFilamentIdx = fi; } + } + const recommendedTd = filaments[nearestFilamentIdx].td; + + const winnerScore = scores.find((s) => s.idx === bestIdx)!; + const isolationScore = winnerScore.nearestFilamentDE / maxIsolation; + const improvementPct = (bestGain / baselineTotal) * 100; + + console.group( + `[NextBestColor] ${filaments.length} filament${filaments.length !== 1 ? 's' : ''} → ` + + `${imageSwatches.length} image colors | ${totalPixels.toLocaleString()} px` + ); + console.log(` Baseline avg ΔE: ${baselineAvgDeltaE.toFixed(2)} (blend-aware)`); + console.log( + ` Suggestion: ${imageSwatches[bestIdx].hex.toUpperCase()} TD ${recommendedTd.toFixed(2)}` + ); + console.log( + ` Accuracy gain: +${improvementPct.toFixed(1)}% ` + + `(${pixelsCaptured.toLocaleString()} px / ` + + `${((pixelsCaptured / totalPixels) * 100).toFixed(1)}% of image improve)` + ); + console.log( + ` Isolation: ${isolationScore.toFixed(3)} ` + + `(nearest filament ΔE ${winnerScore.nearestFilamentDE.toFixed(1)} / ` + + `max ${maxIsolation.toFixed(1)})` + ); + console.groupEnd(); + + return { + candidate: { + hex: imageSwatches[bestIdx].hex, + td: recommendedTd, + improvementPct, + pixelsCaptured, + isolationScore, + }, + baselineAvgDeltaE, + totalPixels, + }; +} diff --git a/tests/nextBestColor.test.ts b/tests/nextBestColor.test.ts new file mode 100644 index 0000000..8616ffb --- /dev/null +++ b/tests/nextBestColor.test.ts @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { nextBestColor } from '../src/lib/nextBestColor.ts'; +import type { Filament } from '../src/types/index.ts'; + +function filament(id: string, color: string, td: number): Filament { + return { id, color, td }; +} + +const BLACK = filament('black', '#000000', 1.0); +const WHITE = filament('white', '#ffffff', 2.0); +const RED = filament('red', '#ff0000', 1.5); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +test('returns null candidate for empty filaments', () => { + const r = nextBestColor([], [{ hex: '#808080', count: 100 }]); + assert.equal(r.candidate, null); + assert.equal(r.baselineAvgDeltaE, 0); +}); + +test('returns null candidate for empty swatches', () => { + const r = nextBestColor([BLACK, WHITE], []); + assert.equal(r.candidate, null); +}); + +test('returns null candidate when all swatches are already covered', () => { + // Swatch exactly matches an existing filament — ΔE < 3, so it is skipped. + const r = nextBestColor([BLACK], [{ hex: '#000000', count: 100 }]); + assert.equal(r.candidate, null); +}); + +// --------------------------------------------------------------------------- +// Basic ranking +// --------------------------------------------------------------------------- + +test('identifies the most impactful missing color', () => { + // Black filament only. Image has black (covered) and white (uncovered). + // White is the only viable candidate. + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 100 }, + { hex: '#ffffff', count: 100 }, + ] + ); + assert.ok(r.candidate !== null, 'expected a candidate'); + assert.equal(r.candidate.hex, '#ffffff'); +}); + +test('blend-aware: far candidate wins when its segment covers the common color territory', () => { + // With only BLACK and an achromatic image, near-white creates a segment + // (#eeeeee↔BLACK) that spans the full L-axis, passing through #888888's Lab + // position — so adding it captures those pixels via blending. Blend-aware + // scoring correctly prefers the longer segment even at count=1. + const r = nextBestColor( + [BLACK], + [ + { hex: '#888888', count: 1000 }, // common mid-grey + { hex: '#eeeeee', count: 1 }, // rare near-white + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.hex, '#eeeeee'); +}); + +test('pixel count weighting: common color beats rare one when blend segments diverge', () => { + // BLACK + WHITE already cover the L-axis via blending. + // Two chromatic candidates in opposite hue directions: their C↔filament + // segments do not pass near each other, so pixel count dominates. + // Four covered greys anchor p75 below both chromatic candidates. + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#606060', count: 1 }, // grey — on L-axis blend, covered + { hex: '#808080', count: 1 }, // grey — covered + { hex: '#a0a0a0', count: 1 }, // grey — covered + { hex: '#c0c0c0', count: 1 }, // grey — covered + { hex: '#ff6666', count: 1000 }, // desaturated red — common + { hex: '#00ffff', count: 1 }, // cyan — opposite hue, rare + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.hex, '#ff6666'); +}); + +// --------------------------------------------------------------------------- +// Improvement percentage +// --------------------------------------------------------------------------- + +test('improvementPct is > 0 and ≤ 100', () => { + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 50 }, + { hex: '#ffffff', count: 50 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.improvementPct > 0, `expected > 0, got ${r.candidate.improvementPct}`); + assert.ok(r.candidate.improvementPct <= 100, `expected ≤ 100, got ${r.candidate.improvementPct}`); +}); + +// --------------------------------------------------------------------------- +// Isolation score +// --------------------------------------------------------------------------- + +test('isolationScore is in [0, 1]', () => { + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 50 }, + { hex: '#ffffff', count: 50 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.isolationScore >= 0 && r.candidate.isolationScore <= 1, + `expected 0–1, got ${r.candidate.isolationScore}`); +}); + +test('single viable candidate always gets isolationScore 1.0', () => { + // Only one candidate passes the coverage threshold, so it is the most isolated by definition. + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 10 }, // covered — skipped + { hex: '#ffffff', count: 90 }, // sole viable candidate + ] + ); + assert.ok(r.candidate !== null); + assert.ok( + Math.abs(r.candidate.isolationScore - 1.0) < 1e-9, + `expected 1.0, got ${r.candidate.isolationScore}` + ); +}); + +test('blend-aware: both candidates produce a candidate with valid isolation score', () => { + // BLACK filament only. + // #444444 (dark grey) — close to BLACK, very common → its C↔BLACK segment covers mid-tones + // #ffffff (white) — far from BLACK, rare → its C↔BLACK segment covers more Lab space + // The blend-aware metric naturally weighs segment length; winner depends on pixel counts. + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 1 }, // covered + { hex: '#444444', count: 500 }, // common, moderate isolation + { hex: '#ffffff', count: 1 }, // rare, maximum isolation + ] + ); + assert.ok(r.candidate !== null); + assert.ok(typeof r.candidate.isolationScore === 'number'); + assert.ok(r.candidate.isolationScore > 0); +}); + +test('adding exact best candidate as a second filament gives near-zero further improvement', () => { + const swatches = [ + { hex: '#000000', count: 50 }, + { hex: '#aaaaaa', count: 50 }, + ]; + const first = nextBestColor([BLACK], swatches); + assert.ok(first.candidate !== null); + // Now add the winner as a filament and re-run. + const newFilament = filament('new', first.candidate.hex, first.candidate.td); + const second = nextBestColor([BLACK, newFilament], swatches); + // Candidate should be null or have negligible improvement. + if (second.candidate !== null) { + assert.ok( + second.candidate.improvementPct < first.candidate.improvementPct, + 'second best should improve less than the first' + ); + } +}); + +// --------------------------------------------------------------------------- +// pixelsCaptured +// --------------------------------------------------------------------------- + +test('pixelsCaptured is > 0 for a valid candidate', () => { + const r = nextBestColor( + [BLACK], + [ + { hex: '#000000', count: 100 }, + { hex: '#cccccc', count: 80 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.pixelsCaptured > 0); +}); + +test('pixelsCaptured does not exceed totalPixels', () => { + const swatches = [ + { hex: '#333333', count: 40 }, + { hex: '#999999', count: 60 }, + ]; + const r = nextBestColor([BLACK], swatches); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.pixelsCaptured <= r.totalPixels); +}); + +// --------------------------------------------------------------------------- +// TD recommendation +// --------------------------------------------------------------------------- + +test('recommended TD comes from the nearest existing filament', () => { + // BLACK (td=1.0) and WHITE (td=2.0). Candidate is mid-grey — closer to BLACK. + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#000000', count: 100 }, + { hex: '#333333', count: 100 }, // dark grey — nearest filament is BLACK + { hex: '#ffffff', count: 100 }, + ] + ); + assert.ok(r.candidate !== null); + // Mid-grey candidate is closer to black → td should be 1.0 (BLACK's td) + assert.equal(r.candidate.td, BLACK.td); +}); + +test('light candidate gets WHITE td when WHITE is nearest', () => { + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#000000', count: 100 }, + { hex: '#ffffff', count: 100 }, + { hex: '#dddddd', count: 200 }, // light — nearest filament is WHITE + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.td, WHITE.td); +}); + +// --------------------------------------------------------------------------- +// totalPixels and baselineAvgDeltaE +// --------------------------------------------------------------------------- + +test('totalPixels equals sum of swatch counts', () => { + const swatches = [ + { hex: '#ff0000', count: 30 }, + { hex: '#00ff00', count: 70 }, + ]; + const r = nextBestColor([RED], swatches); + assert.equal(r.totalPixels, 100); +}); + +test('baselineAvgDeltaE is 0 when all swatches exactly match filaments', () => { + // Single swatch that exactly matches the filament — ΔE ≈ 0. + const r = nextBestColor([RED], [{ hex: '#ff0000', count: 50 }]); + assert.ok(r.baselineAvgDeltaE < 1, `expected near 0, got ${r.baselineAvgDeltaE}`); +}); From 8ce287a8c5471303868d2b18776d532b474bb8cb Mon Sep 17 00:00:00 2001 From: Brice Johnson <{ID}+{username}@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:44:07 -0500 Subject: [PATCH 09/26] Add extrapolation-based candidate generation to next-best-color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of only testing image swatch colors as candidates, also generate extrapolated Lab positions by solving blend(C, F, t) = S for each underserved swatch S and existing filament F at t ∈ {0.3, 0.5, 0.7}. These candidates represent colors that, when blended with an existing filament, land exactly on an uncovered swatch — often better than the swatch color itself because they extend the new C↔F segment further into uncovered Lab space. Also adds labToHex (Lab→XYZ→linear RGB→sRGB with gamut clamping) and deduplicates the candidate pool by hex before scoring. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/nextBestColor.ts | 182 +++++++++++++++++++++++++++------------ 1 file changed, 128 insertions(+), 54 deletions(-) diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index 7a14d8f..9f55f62 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -28,15 +28,25 @@ const M_RX = 0.4124564, M_GX = 0.3575761, M_BX = 0.1804375; const M_RY = 0.2126729, M_GY = 0.7151522, M_BY = 0.0721750; const M_RZ = 0.0193339, M_GZ = 0.1191920, M_BZ = 0.9503041; +// CIE XYZ (D65) → sRGB inverse matrix row coefficients. +const M_INV_XR = 3.2404542, M_INV_YR = -1.5371385, M_INV_ZR = -0.4985314; +const M_INV_XG = -0.9692660, M_INV_YG = 1.8760108, M_INV_ZG = 0.0415560; +const M_INV_XB = 0.0556434, M_INV_YB = -0.2040259, M_INV_ZB = 1.0572252; + // CIE standard illuminant D65 tristimulus values (normalises XYZ to [0,1]). const D65_X = 0.95047; const D65_Y = 1.00000; const D65_Z = 1.08883; // CIE L*a*b* cube-root approximation thresholds and coefficients (CIE 1976). -const LAB_EPSILON = 0.008856; // (6/29)³ -const LAB_KAPPA = 7.787; // (29/6)² / 3 — slope of the linear segment -const LAB_DELTA_16 = 16 / 116; // y-intercept of the linear segment +const LAB_EPSILON = 0.008856; // (6/29)³ +const LAB_KAPPA = 7.787; // (29/6)² / 3 — slope of the linear segment +const LAB_DELTA_16 = 16 / 116; // y-intercept of the linear segment +const LAB_INV_CBRT = 6 / 29; // cbrt(LAB_EPSILON) — Lab→XYZ cube-root threshold + +// Threshold for linear RGB values in the sRGB de-linearisation step. +// Derived from SRGB_LINEARISE_THRESHOLD / SRGB_LINEARISE_SCALE ≈ 0.0031308. +const SRGB_LINEAR_THRESHOLD = SRGB_LINEARISE_THRESHOLD / SRGB_LINEARISE_SCALE; function rgbToLab(rgb: RGB): Lab { const linearise = (v: number) => { @@ -55,6 +65,33 @@ function rgbToLab(rgb: RGB): Lab { return { L: 116 * f(fy) - 16, a: 500 * (f(fx) - f(fy)), b: 200 * (f(fy) - f(fz)) }; } +function labToHex(lab: Lab): string { + // Lab → XYZ (D65) + const fy = (lab.L + 16) / 116; + const fx = lab.a / 500 + fy; + const fz = fy - lab.b / 200; + const invF = (f: number) => f > LAB_INV_CBRT ? f * f * f : (f - LAB_DELTA_16) / LAB_KAPPA; + const X = D65_X * invF(fx); + const Y = D65_Y * invF(fy); + const Z = D65_Z * invF(fz); + + // XYZ → linear sRGB (clamp to [0,1] to handle out-of-gamut Lab values) + const rl = Math.max(0, Math.min(1, M_INV_XR * X + M_INV_YR * Y + M_INV_ZR * Z)); + const gl = Math.max(0, Math.min(1, M_INV_XG * X + M_INV_YG * Y + M_INV_ZG * Z)); + const bl = Math.max(0, Math.min(1, M_INV_XB * X + M_INV_YB * Y + M_INV_ZB * Z)); + + // Linear → sRGB + const delinearise = (c: number) => + c <= SRGB_LINEAR_THRESHOLD + ? c * SRGB_LINEARISE_SCALE + : SRGB_LINEARISE_DENOM * Math.pow(c, 1 / SRGB_LINEARISE_GAMMA) - SRGB_LINEARISE_OFFSET; + + const r = Math.round(delinearise(rl) * 255); + const g = Math.round(delinearise(gl) * 255); + const b = Math.round(delinearise(bl) * 255); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + function deltaELab(a: Lab, b: Lab): number { return Math.sqrt((a.L - b.L) ** 2 + (a.a - b.a) ** 2 + (a.b - b.b) ** 2); } @@ -74,14 +111,32 @@ function distToSegment(P: Lab, A: Lab, B: Lab): number { return Math.sqrt(dL * dL + da * da + db * db); } +/** + * Solves blend(C, anchor, t) = target for C in Lab space. + * Returns the color that, when blended with `anchor` at ratio `t`, produces `target`. + */ +function extrapolateLab(target: Lab, anchor: Lab, t: number): Lab { + return { + L: (target.L - (1 - t) * anchor.L) / t, + a: (target.a - (1 - t) * anchor.a) / t, + b: (target.b - (1 - t) * anchor.b) / t, + }; +} + +// Blend ratios used when extrapolating candidate colors from underserved swatches. +// t=0.7 → candidate is close to the swatch (gentle extrapolation) +// t=0.5 → candidate is the reflection of the filament through the swatch +// t=0.3 → more aggressive; may push out of gamut but gets clamped to valid hex +const EXTRAP_BLEND_RATIOS = [0.3, 0.5, 0.7]; + export interface ColorCandidate { hex: string; /** Recommended starting TD, derived from the nearest existing filament by ΔE. */ td: number; /** * % reduction in blend-aware weighted-average ΔE vs current filament set. - * The baseline already accounts for existing filament↔filament blend lines, - * so this reflects how much the new candidate genuinely adds. + * The baseline accounts for existing filament↔filament blend lines, so this + * reflects genuine new coverage added by the candidate. */ improvementPct: number; /** Number of image pixels whose blend-aware error improves with this candidate. */ @@ -98,19 +153,21 @@ export interface NextBestColorResult { } /** - * Given the current filament set and image swatches, returns the single image - * color whose addition as a new filament would most reduce the blend-aware - * weighted-average ΔE between the rendered print and the target image. + * Given the current filament set and image swatches, returns the single color + * whose addition as a new filament would most reduce the blend-aware weighted- + * average ΔE between the rendered print and the target image. * - * Blend-aware metric: the "reachable error" for a swatch is the minimum ΔE - * achievable by any filament point or any blend along a filament↔filament - * segment in Lab space. Adding candidate C introduces new C↔filament segments, - * so isolated candidates are naturally rewarded — they create long new segments - * that sweep previously unreachable regions of Lab space. + * Candidate generation (two sources): + * 1. Swatch colors in the p75 most underserved (by blend-aware reachable error). + * 2. Extrapolated colors: for each underserved swatch S and filament F, solve + * blend(C, F, t) = S for C at t ∈ {0.3, 0.5, 0.7}. These are colors that, + * when blended with an existing filament, hit the underserved swatch exactly — + * often better than the swatch color itself because they pull the blend line + * further into uncovered Lab space. * - * currentReachable(swatch) = min over all filament points and F↔F segments - * newReachable(swatch, C) = min(currentReachable, ΔE(swatch,C), min_F dist(swatch, C↔F)) - * gain(C) = Σ_i max(0, currentReachable_i − newReachable_i) × count_i + * Scoring: for each candidate C, gain = Σ_i max(0, currentReachable_i − + * newReachable_i) × count_i, where newReachable_i is the minimum ΔE achievable + * from swatch i via any existing blend line or any new C↔filament segment. */ export function nextBestColor( filaments: Filament[], @@ -150,73 +207,92 @@ export function nextBestColor( if (baselineAvgDeltaE === 0) return { candidate: null, baselineAvgDeltaE: 0, totalPixels }; // ------------------------------------------------------------------------- - // Score each candidate. - // Restrict the candidate pool to the most underserved swatches — those at - // or above the 75th percentile of blend-aware reachable error — so the - // O(candidates × swatches × filaments) inner loop only runs for colors - // that genuinely lack coverage. Also skip near-duplicates of existing - // filaments regardless of percentile rank. + // Build candidate pool. + // For each p75-underserved swatch, include the swatch color itself plus + // extrapolated Lab positions derived from each filament at each blend ratio. // ------------------------------------------------------------------------- const COVERAGE_THRESHOLD = 3.0; // ΔE — skip near-duplicates of existing filaments const sortedReachable = [...currentReachable].sort((a, b) => a - b); const p75Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.75)]; - interface CandidateScore { idx: number; gain: number; nearestFilamentDE: number } - const scores: CandidateScore[] = []; + interface LabCandidate { lab: Lab; hex: string } + const seen = new Set(); + const pool: LabCandidate[] = []; + + const addCandidate = (lab: Lab, hex: string) => { + if (seen.has(hex)) return; + // Skip if this color is already well-covered by an existing filament. + for (const fLab of filamentLabs) { + if (deltaELab(lab, fLab) < COVERAGE_THRESHOLD) return; + } + seen.add(hex); + pool.push({ lab, hex }); + }; for (let c = 0; c < swatchLabs.length; c++) { if (currentReachable[c] < p75Threshold) continue; + // The swatch color itself. + addCandidate(swatchLabs[c], imageSwatches[c].hex); + + // Extrapolated: color that, blended with each filament at ratio t, hits this swatch. + for (const fLab of filamentLabs) { + for (const t of EXTRAP_BLEND_RATIOS) { + const extrapLab = extrapolateLab(swatchLabs[c], fLab, t); + addCandidate(extrapLab, labToHex(extrapLab)); + } + } + } + + if (pool.length === 0) return { candidate: null, baselineAvgDeltaE, totalPixels }; + + // ------------------------------------------------------------------------- + // Score every candidate. + // ------------------------------------------------------------------------- + interface CandidateScore { lab: Lab; hex: string; gain: number; nearestFilamentDE: number } + const scores: CandidateScore[] = []; + + for (const { lab, hex } of pool) { let nearestFilamentDE = Infinity; for (const fLab of filamentLabs) { - nearestFilamentDE = Math.min(nearestFilamentDE, deltaELab(swatchLabs[c], fLab)); + nearestFilamentDE = Math.min(nearestFilamentDE, deltaELab(lab, fLab)); } - if (nearestFilamentDE < COVERAGE_THRESHOLD) continue; - // For each swatch, compute the new reachable error if candidate C is added. - // This includes direct distance to C and segments from C to every existing filament. let gain = 0; for (let i = 0; i < swatchLabs.length; i++) { let newReachable = currentReachable[i]; - newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], swatchLabs[c])); + newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], lab)); for (const fLab of filamentLabs) { - newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], swatchLabs[c], fLab)); + newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], lab, fLab)); } const improvement = currentReachable[i] - newReachable; if (improvement > 0) gain += improvement * counts[i]; } - scores.push({ idx: c, gain, nearestFilamentDE }); + scores.push({ lab, hex, gain, nearestFilamentDE }); } if (scores.length === 0) return { candidate: null, baselineAvgDeltaE, totalPixels }; - // Isolation score is informational — the blend-aware gain metric already - // rewards isolated candidates (longer C↔F segments cover more Lab space). + // Isolation score is informational — blend-aware gain already rewards isolated + // candidates (longer C↔F segments cover more Lab space). const maxIsolation = Math.max(...scores.map((s) => s.nearestFilamentDE)); // Pick winner by blend-aware gain. - let bestGain = -1; - let bestIdx = -1; - for (const s of scores) { - if (s.gain > bestGain) { bestGain = s.gain; bestIdx = s.idx; } - } - - if (bestIdx === -1) return { candidate: null, baselineAvgDeltaE, totalPixels }; + const winner = scores.reduce((best, s) => s.gain > best.gain ? s : best, scores[0]); // ------------------------------------------------------------------------- // Build the result for the winning candidate. // ------------------------------------------------------------------------- - const candLab = swatchLabs[bestIdx]; - // Pixel capture: swatches whose blend-aware reachable error improves with C. + // Pixel capture: swatches whose blend-aware reachable error improves with the winner. let pixelsCaptured = 0; for (let i = 0; i < swatchLabs.length; i++) { let newReachable = currentReachable[i]; - newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], candLab)); + newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], winner.lab)); for (const fLab of filamentLabs) { - newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], candLab, fLab)); + newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], winner.lab, fLab)); } if (newReachable < currentReachable[i]) pixelsCaptured += counts[i]; } @@ -225,23 +301,21 @@ export function nextBestColor( let nearestFilamentIdx = 0; let nearestDE = Infinity; for (let fi = 0; fi < filamentLabs.length; fi++) { - const de = deltaELab(candLab, filamentLabs[fi]); + const de = deltaELab(winner.lab, filamentLabs[fi]); if (de < nearestDE) { nearestDE = de; nearestFilamentIdx = fi; } } const recommendedTd = filaments[nearestFilamentIdx].td; - const winnerScore = scores.find((s) => s.idx === bestIdx)!; - const isolationScore = winnerScore.nearestFilamentDE / maxIsolation; - const improvementPct = (bestGain / baselineTotal) * 100; + const isolationScore = winner.nearestFilamentDE / maxIsolation; + const improvementPct = (winner.gain / baselineTotal) * 100; console.group( `[NextBestColor] ${filaments.length} filament${filaments.length !== 1 ? 's' : ''} → ` + - `${imageSwatches.length} image colors | ${totalPixels.toLocaleString()} px` + `${imageSwatches.length} image colors | ${totalPixels.toLocaleString()} px | ` + + `${pool.length} candidates (${scores.length} scored)` ); console.log(` Baseline avg ΔE: ${baselineAvgDeltaE.toFixed(2)} (blend-aware)`); - console.log( - ` Suggestion: ${imageSwatches[bestIdx].hex.toUpperCase()} TD ${recommendedTd.toFixed(2)}` - ); + console.log(` Suggestion: ${winner.hex.toUpperCase()} TD ${recommendedTd.toFixed(2)}`); console.log( ` Accuracy gain: +${improvementPct.toFixed(1)}% ` + `(${pixelsCaptured.toLocaleString()} px / ` + @@ -249,14 +323,14 @@ export function nextBestColor( ); console.log( ` Isolation: ${isolationScore.toFixed(3)} ` + - `(nearest filament ΔE ${winnerScore.nearestFilamentDE.toFixed(1)} / ` + + `(nearest filament ΔE ${winner.nearestFilamentDE.toFixed(1)} / ` + `max ${maxIsolation.toFixed(1)})` ); console.groupEnd(); return { candidate: { - hex: imageSwatches[bestIdx].hex, + hex: winner.hex, td: recommendedTd, improvementPct, pixelsCaptured, From e234c5511c02e475e5f385bb68c859dca51c846c Mon Sep 17 00:00:00 2001 From: Brice Johnson <{ID}+{username}@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:51:51 -0500 Subject: [PATCH 10/26] Fix phantom-color bug in extrapolated candidate scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit labToHex clamps out-of-gamut Lab values (e.g. aggressive t=0.3 extrapolations), so the raw extrapolated Lab and the returned hex could represent very different colors. Scoring used the raw Lab (scoring a phantom) while the UI displayed the clamped hex — causing near-black colors like #080000 to always win regardless of the filament set. Fix: round-trip all candidates through hex→Lab so scoring and the coverage threshold always operate on the actual representable color. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/nextBestColor.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index 9f55f62..6df1b84 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -220,9 +220,13 @@ export function nextBestColor( const seen = new Set(); const pool: LabCandidate[] = []; - const addCandidate = (lab: Lab, hex: string) => { + const addCandidate = (hex: string) => { if (seen.has(hex)) return; - // Skip if this color is already well-covered by an existing filament. + // Round-trip through hex so the Lab used for scoring and filtering always + // matches the actual representable color. labToHex clamps out-of-gamut + // extrapolations, so using the raw extrapolated Lab would score a phantom + // color and return an inconsistent hex (the original bug). + const lab = rgbToLab(hexToRgb(hex)); for (const fLab of filamentLabs) { if (deltaELab(lab, fLab) < COVERAGE_THRESHOLD) return; } @@ -234,13 +238,12 @@ export function nextBestColor( if (currentReachable[c] < p75Threshold) continue; // The swatch color itself. - addCandidate(swatchLabs[c], imageSwatches[c].hex); + addCandidate(imageSwatches[c].hex); // Extrapolated: color that, blended with each filament at ratio t, hits this swatch. for (const fLab of filamentLabs) { for (const t of EXTRAP_BLEND_RATIOS) { - const extrapLab = extrapolateLab(swatchLabs[c], fLab, t); - addCandidate(extrapLab, labToHex(extrapLab)); + addCandidate(labToHex(extrapolateLab(swatchLabs[c], fLab, t))); } } } From 302c501df7006cc7ca6785d7053915c605a1c90f Mon Sep 17 00:00:00 2001 From: Brice Johnson <{ID}+{username}@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:58:23 -0500 Subject: [PATCH 11/26] Fix camera clipping planes not carried over on toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When switching perspective→ortho, the ortho camera was created with a hardcoded far=2000, ignoring any near/far the perspective camera had accumulated via auto-framing. Switching back ortho→perspective also left the perspective camera with stale near/far values. Fix: copy near/far from the active camera onto the incoming camera before updateProjectionMatrix(), so large models framed in one mode remain fully visible after toggling. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useThreeScene.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useThreeScene.ts b/src/hooks/useThreeScene.ts index 16f9325..c1afe52 100644 --- a/src/hooks/useThreeScene.ts +++ b/src/hooks/useThreeScene.ts @@ -110,7 +110,7 @@ export function useThreeScene( const viewH = 2 * dist * Math.tan(fovRad / 2); const viewW = viewH * aspect; const ortho = new THREE.OrthographicCamera( - -viewW / 2, viewW / 2, viewH / 2, -viewH / 2, 0.01, 2000 + -viewW / 2, viewW / 2, viewH / 2, -viewH / 2, cam.near, cam.far ); ortho.position.copy(cam.position); ortho.quaternion.copy(cam.quaternion); @@ -120,6 +120,8 @@ export function useThreeScene( } else if (!isOrtho && cam instanceof THREE.OrthographicCamera) { persp.position.copy(cam.position); persp.quaternion.copy(cam.quaternion); + persp.near = cam.near; + persp.far = cam.far; persp.aspect = aspect; persp.updateProjectionMatrix(); cameraRef.current = persp; From 7c59823c8939b0baadc3801778a2c5eecf97be4d Mon Sep 17 00:00:00 2001 From: Brice Johnson <{ID}+{username}@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:00:49 -0500 Subject: [PATCH 12/26] Add changelog entry and docs for orthographic camera toggle Per repo guidance: adds an unreleased v3.1.0 changelog entry and a Preview Controls section in 3d-mode.md describing the new perspective/orthographic toggle button. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 ++ src/docs/3d-mode.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d39485..d29326a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to Kromacut are documented in this file. ### Added +- **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. + ### Changed - **Header settings dialog** - Replaced the standalone theme toggle with a centered settings dialog that contains compact System, Dark, and Light theme options plus the current app version. diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index ff80fbc..ba34760 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -100,6 +100,14 @@ After Auto-paint computes a result, Kromacut can show: Low confidence usually means you should calibrate filaments, add a missing filament color, or loosen a restrictive max height. +## Preview Controls + +The toolbar in the top-right corner of the 3D preview contains controls for the active view: + +- **Camera toggle** — switches between perspective and orthographic projection. Perspective gives a natural depth effect; orthographic removes foreshortening and is useful for checking layer alignment. The button icon reflects the current mode, and the camera position is preserved when toggling. +- **Undo / Redo** — steps through changes to the 3D settings. +- **Download** — exports the current model as a .stl or a .3mf. + ## Layer Preview After building, the bottom **Layer Preview** bar lets you show only a height range of the model. Drag the lower and upper handles to inspect how the print builds. From 069c51a795e759c2a581fd084ee744833d103c80 Mon Sep 17 00:00:00 2001 From: Bjohnson131 <1939015+bjohnson131@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:11:08 -0500 Subject: [PATCH 13/26] Add changelog entry and docs for orthographic camera toggle Per repo guidance: adds an unreleased v3.1.0 changelog entry and a Preview Controls section in 3d-mode.md describing the new perspective/orthographic toggle button. Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/lib/nextBestColor.ts | 51 +++++++++++---- tests/nextBestColor.test.ts | 125 ++++++++++++++++++++++++++++++++---- 2 files changed, 150 insertions(+), 26 deletions(-) diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index 6df1b84..b3abd84 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -96,6 +96,13 @@ function deltaELab(a: Lab, b: Lab): number { return Math.sqrt((a.L - b.L) ** 2 + (a.a - b.a) ** 2 + (a.b - b.b) ** 2); } +/** Convert a hex color string to its CIE L*a*b* representation. */ +export function hexToLab(hex: string): Lab { + return rgbToLab(hexToRgb(hex)); +} + +export { labToHex }; + /** * CIE76 distance from Lab point P to the nearest point on segment A↔B. * In Beer-Lambert blending, any mix of filaments A and B lies on the straight @@ -201,7 +208,15 @@ export function nextBestColor( }); const totalPixels = counts.reduce((s, c) => s + c, 0); - const baselineTotal = currentReachable.reduce((s, e, i) => s + e * counts[i], 0); + + // Residual error below 1 ΔE is below the just-noticeable difference and is + // treated as fully covered. This absorbs Lab↔hex quantisation noise so that + // blend-line midpoints (which land within ~0.5 ΔE of their segment) don't + // show up as a meaningful gap. + const COVERAGE_FLOOR = 1.0; + const effectiveReachable = currentReachable.map(e => e >= COVERAGE_FLOOR ? e : 0); + + const baselineTotal = effectiveReachable.reduce((s, e, i) => s + e * counts[i], 0); const baselineAvgDeltaE = totalPixels > 0 ? baselineTotal / totalPixels : 0; if (baselineAvgDeltaE === 0) return { candidate: null, baselineAvgDeltaE: 0, totalPixels }; @@ -213,8 +228,10 @@ export function nextBestColor( // ------------------------------------------------------------------------- const COVERAGE_THRESHOLD = 3.0; // ΔE — skip near-duplicates of existing filaments - const sortedReachable = [...currentReachable].sort((a, b) => a - b); + const sortedReachable = [...effectiveReachable].sort((a, b) => a - b); const p75Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.75)]; + const p90Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.90)]; + const maxReachable = sortedReachable[sortedReachable.length - 1]; interface LabCandidate { lab: Lab; hex: string } const seen = new Set(); @@ -235,7 +252,7 @@ export function nextBestColor( }; for (let c = 0; c < swatchLabs.length; c++) { - if (currentReachable[c] < p75Threshold) continue; + if (effectiveReachable[c] < p75Threshold) continue; // The swatch color itself. addCandidate(imageSwatches[c].hex); @@ -253,7 +270,7 @@ export function nextBestColor( // ------------------------------------------------------------------------- // Score every candidate. // ------------------------------------------------------------------------- - interface CandidateScore { lab: Lab; hex: string; gain: number; nearestFilamentDE: number } + interface CandidateScore { lab: Lab; hex: string; weightedGain: number; rawGain: number; nearestFilamentDE: number } const scores: CandidateScore[] = []; for (const { lab, hex } of pool) { @@ -262,18 +279,25 @@ export function nextBestColor( nearestFilamentDE = Math.min(nearestFilamentDE, deltaELab(lab, fLab)); } - let gain = 0; + let weightedGain = 0; + let rawGain = 0; for (let i = 0; i < swatchLabs.length; i++) { let newReachable = currentReachable[i]; newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], lab)); for (const fLab of filamentLabs) { newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], lab, fLab)); } - const improvement = currentReachable[i] - newReachable; - if (improvement > 0) gain += improvement * counts[i]; + const improvement = effectiveReachable[i] - newReachable; + if (improvement > 0) { + const w = effectiveReachable[i] >= maxReachable ? 3.0 + : effectiveReachable[i] >= p90Threshold ? 2.0 + : 1.0; + weightedGain += improvement * counts[i] * w; + rawGain += improvement * counts[i]; + } } - scores.push({ lab, hex, gain, nearestFilamentDE }); + scores.push({ lab, hex, weightedGain, rawGain, nearestFilamentDE }); } if (scores.length === 0) return { candidate: null, baselineAvgDeltaE, totalPixels }; @@ -282,8 +306,8 @@ export function nextBestColor( // candidates (longer C↔F segments cover more Lab space). const maxIsolation = Math.max(...scores.map((s) => s.nearestFilamentDE)); - // Pick winner by blend-aware gain. - const winner = scores.reduce((best, s) => s.gain > best.gain ? s : best, scores[0]); + // Rank by weighted gain; report improvement from unweighted gain so improvementPct ∈ [0,100]. + const winner = scores.reduce((best, s) => s.weightedGain > best.weightedGain ? s : best, scores[0]); // ------------------------------------------------------------------------- // Build the result for the winning candidate. @@ -292,12 +316,13 @@ export function nextBestColor( // Pixel capture: swatches whose blend-aware reachable error improves with the winner. let pixelsCaptured = 0; for (let i = 0; i < swatchLabs.length; i++) { - let newReachable = currentReachable[i]; + if (effectiveReachable[i] === 0) continue; + let newReachable = effectiveReachable[i]; newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], winner.lab)); for (const fLab of filamentLabs) { newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], winner.lab, fLab)); } - if (newReachable < currentReachable[i]) pixelsCaptured += counts[i]; + if (newReachable < effectiveReachable[i]) pixelsCaptured += counts[i]; } // TD: borrow from nearest existing filament by ΔE. @@ -310,7 +335,7 @@ export function nextBestColor( const recommendedTd = filaments[nearestFilamentIdx].td; const isolationScore = winner.nearestFilamentDE / maxIsolation; - const improvementPct = (winner.gain / baselineTotal) * 100; + const improvementPct = (winner.rawGain / baselineTotal) * 100; console.group( `[NextBestColor] ${filaments.length} filament${filaments.length !== 1 ? 's' : ''} → ` + diff --git a/tests/nextBestColor.test.ts b/tests/nextBestColor.test.ts index 8616ffb..a092ade 100644 --- a/tests/nextBestColor.test.ts +++ b/tests/nextBestColor.test.ts @@ -1,8 +1,23 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { nextBestColor } from '../src/lib/nextBestColor.ts'; +import { nextBestColor, hexToLab, labToHex } from '../src/lib/nextBestColor.ts'; import type { Filament } from '../src/types/index.ts'; +/** + * Linearly blend two hex colors in Lab space at ratio t and return the + * resulting hex. The result lies exactly on the segment A↔B in Lab space, + * which is the model nextBestColor uses for blend coverage. + */ +function blendHex(hexA: string, hexB: string, t: number): string { + const a = hexToLab(hexA); + const b = hexToLab(hexB); + return labToHex({ + L: a.L * (1 - t) + b.L * t, + a: a.a * (1 - t) + b.a * t, + b: a.b * (1 - t) + b.b * t, + }); +} + function filament(id: string, color: string, td: number): Filament { return { id, color, td }; } @@ -10,6 +25,7 @@ function filament(id: string, color: string, td: number): Filament { const BLACK = filament('black', '#000000', 1.0); const WHITE = filament('white', '#ffffff', 2.0); const RED = filament('red', '#ff0000', 1.5); +const BLUE = filament('blue', '#0000ff', 2.5); // --------------------------------------------------------------------------- // Edge cases @@ -32,6 +48,40 @@ test('returns null candidate when all swatches are already covered', () => { assert.equal(r.candidate, null); }); +test('blend triangle: inner swatches simulated by Lab blending of outer filaments return null', () => { + // Outer triangle: 3 muted filaments (not pure primaries — pure red+blue blends + // go out of the sRGB gamut in Lab, causing clamping errors > COVERAGE_FLOOR). + // Inner swatches: Lab-linear blends of filament pairs at t ∈ {0.25, 0.5, 0.75}. + // Every inner swatch lies on a filament↔filament segment, so blend-aware scoring + // gives effectiveReachable = 0 for all swatches and no candidate is needed. + const GREEN = filament('green', '#33cc33', 1.5); + const MBLUE = filament('blue', '#3333cc', 1.5); + const MRED = '#cc3333'; + + const pairs: [string, string][] = [ + [MRED, '#33cc33'], + ['#33cc33', '#3333cc'], + [MRED, '#3333cc'], + ]; + const swatches = pairs.flatMap(([a, b]) => [0.25, 0.5, 0.75].map(t => ({ hex: blendHex(a, b, t), count: 100 }))); + + const r = nextBestColor([filament('red', MRED, 1.5), GREEN, MBLUE], swatches); + assert.equal(r.candidate, null, `expected null but got ${r.candidate?.hex}`); +}); + +test('returns null candidate when image palette exactly matches the filament set', () => { + // Every image swatch is one of the existing filaments — nothing to add. + const r = nextBestColor( + [BLACK, WHITE, RED], + [ + { hex: '#000000', count: 300 }, + { hex: '#ffffff', count: 300 }, + { hex: '#ff0000', count: 300 }, + ] + ); + assert.equal(r.candidate, null); +}); + // --------------------------------------------------------------------------- // Basic ranking // --------------------------------------------------------------------------- @@ -86,6 +136,52 @@ test('pixel count weighting: common color beats rare one when blend segments div assert.equal(r.candidate.hex, '#ff6666'); }); +// --------------------------------------------------------------------------- +// Underserved-color weighting (p90 = 2×, p100 = 3×) +// --------------------------------------------------------------------------- + +test('p100 weighting tips ranking: rare maximally-underserved color beats common moderate one', () => { + // BLACK + WHITE cover the L-axis. Two chromatic candidates in opposite hue + // directions so their C↔filament blend segments don't reach each other: + // #ff8888 (desaturated red) count=5 — moderate distance (~49 ΔE), high raw gain + // #0000ff (blue) count=1 — maximum distance (~134 ΔE), p100 → 3× weight + // + // Without weighting blue's raw gain (134) < red's (247), red would win. + // With 3× p100 weight blue's weighted gain (402) > red's (255), blue wins. + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#606060', count: 1 }, // grey — covered by L-axis blend + { hex: '#808080', count: 1 }, // grey — covered + { hex: '#a0a0a0', count: 1 }, // grey — covered + { hex: '#c0c0c0', count: 1 }, // grey — covered + { hex: '#ff8888', count: 5 }, // desaturated red — moderate distance + { hex: '#0000ff', count: 1 }, // blue — maximum distance (p100) + ] + ); + assert.ok(r.candidate !== null); + assert.equal(r.candidate.hex, '#0000ff'); +}); + +test('improvementPct stays ≤ 100 when p100 weighting selects the winner', () => { + // weightedGain drives ranking but improvementPct is computed from rawGain + // so it stays a meaningful percentage of the unweighted baseline error. + const r = nextBestColor( + [BLACK, WHITE], + [ + { hex: '#606060', count: 1 }, + { hex: '#808080', count: 1 }, + { hex: '#a0a0a0', count: 1 }, + { hex: '#c0c0c0', count: 1 }, + { hex: '#ff8888', count: 5 }, + { hex: '#0000ff', count: 1 }, + ] + ); + assert.ok(r.candidate !== null); + assert.ok(r.candidate.improvementPct <= 100, + `expected ≤ 100, got ${r.candidate.improvementPct}`); +}); + // --------------------------------------------------------------------------- // Improvement percentage // --------------------------------------------------------------------------- @@ -204,31 +300,34 @@ test('pixelsCaptured does not exceed totalPixels', () => { // --------------------------------------------------------------------------- test('recommended TD comes from the nearest existing filament', () => { - // BLACK (td=1.0) and WHITE (td=2.0). Candidate is mid-grey — closer to BLACK. + // RED (td=1.5) and BLUE (td=2.5). Orange #ff4400 is not on the RED↔BLUE blend + // line (which passes through purple/magenta) and is much closer to RED than BLUE + // in Lab space, so the suggested candidate should inherit RED's td. const r = nextBestColor( - [BLACK, WHITE], + [RED, BLUE], [ - { hex: '#000000', count: 100 }, - { hex: '#333333', count: 100 }, // dark grey — nearest filament is BLACK - { hex: '#ffffff', count: 100 }, + { hex: '#ff0000', count: 10 }, // covered — on RED + { hex: '#0000ff', count: 10 }, // covered — on BLUE + { hex: '#ff4400', count: 100 }, // uncovered orange, nearest filament is RED ] ); assert.ok(r.candidate !== null); - // Mid-grey candidate is closer to black → td should be 1.0 (BLACK's td) - assert.equal(r.candidate.td, BLACK.td); + assert.equal(r.candidate.td, RED.td); }); test('light candidate gets WHITE td when WHITE is nearest', () => { + // RED (td=1.5) and BLUE (td=2.5). Sky blue #00aaff is not on the RED↔BLUE blend + // line and is closer to BLUE than RED in Lab space. const r = nextBestColor( - [BLACK, WHITE], + [RED, BLUE], [ - { hex: '#000000', count: 100 }, - { hex: '#ffffff', count: 100 }, - { hex: '#dddddd', count: 200 }, // light — nearest filament is WHITE + { hex: '#ff0000', count: 10 }, // covered — on RED + { hex: '#0000ff', count: 10 }, // covered — on BLUE + { hex: '#00aaff', count: 200 }, // uncovered sky-blue, nearest filament is BLUE ] ); assert.ok(r.candidate !== null); - assert.equal(r.candidate.td, WHITE.td); + assert.equal(r.candidate.td, BLUE.td); }); // --------------------------------------------------------------------------- From 88e9117e091078fddbdf86ff940e33d0c6491f45 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:54:40 -0500 Subject: [PATCH 14/26] Add next-best-color suggestion feature with blend-aware analysis and detailed result card Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ README.md | 18 ++++++++++++++++++ src/components/AutoPaintTab.tsx | 16 +++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 276b122..968aa0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Kromacut are documented in this file. +## Unreleased + +### Added + +- **Next-best-color suggestion** — "Suggest next filament" button in the Auto-paint panel runs a blend-aware analysis and recommends the single filament addition that would most reduce the average color error (ΔE) across the image. The result card shows the suggested hex color, a recommended starting TD (borrowed from the nearest existing filament by ΔE), an estimated ΔE improvement percentage, the proportion of image pixels that benefit, and an isolation score indicating how far the suggestion sits from existing filaments in perceptual color space. Clicking "Add to filaments" inserts the suggestion directly into the filament list with a `Kromacut-Suggestion-NN` name. The algorithm accounts for Beer-Lambert blend lines between existing filaments and uses extrapolation to find colors that, when blended with an existing filament, reach underserved image colors — often outperforming the raw swatch color as a candidate. + ## v2.6.0 - 2026-05-17 ### Added diff --git a/README.md b/README.md index 1818805..4b38b2f 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,24 @@ When auto-paint is active and filaments are defined, the UI displays a **Transit - A **compressed** badge on zones that have been reduced below their ideal thickness due to a Max Height constraint. - Total model height and total number of physical layers. +### Next-best-color suggestion + +After running auto-paint, a **Suggest next filament** button appears at the bottom of the panel. Click it to run a blend-aware analysis that finds the single filament addition that would most reduce the average color error (ΔE) across the image. + +The result card shows: + +| Field | Description | +|---|---| +| **Hex** | Suggested filament color. | +| **Est. ΔE** | Estimated reduction in blend-aware average ΔE if this filament is added. A rough relative estimate — not a calibration confidence rating. | +| **TD** | Recommended starting Transmission Distance, borrowed from the nearest existing filament by color distance. Adjust after printing a test patch. | +| **Captures** | Percentage of image pixels whose blend-aware color error would improve with this filament. | +| **Isolation** | How far this color sits from existing filaments in perceptual color space (0–1). Higher means it fills a more distinct gap; lower means it overlaps territory already covered by blending. | + +Click **Add to filaments** to insert the suggestion directly into the filament list (named `Kromacut-Suggestion-01`, `02`, etc.). You can then re-run auto-paint with the expanded set and repeat as many times as needed. + +**How it works:** The algorithm models the full set of Beer-Lambert blend lines between existing filaments — every pair of filaments produces a blend segment in CIE L\*a\*b\* color space that the print can reach by layering. It then identifies underserved image colors (those furthest from any reachable point) and evaluates candidates drawn from those colors and from extrapolated positions: colors that, when blended with an existing filament, would hit the underserved target exactly. The winner is the candidate whose addition most reduces weighted-average error across all image pixels. + ### Quick start 1. Load an image into Kromacut. diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index cb58072..d446b28 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -854,18 +854,24 @@ export default function AutoPaintTab({ {nextBestResult.candidate.hex.toUpperCase()} - - +{nextBestResult.candidate.improvementPct.toFixed(1)}% + + Est. ΔE{' '} + + +{nextBestResult.candidate.improvementPct.toFixed(1)}% +
  • - + TD:{' '} {nextBestResult.candidate.td.toFixed(2)} - + Captures:{' '} {( @@ -876,7 +882,7 @@ export default function AutoPaintTab({ % - + Isolation:{' '} {nextBestResult.candidate.isolationScore.toFixed(2)} From 11201863568a1d94aabc9951b9825b1a5c3092a7 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 11 Jun 2026 19:58:55 +0300 Subject: [PATCH 15/26] Add Flat Paint auto-paint mode --- CHANGELOG.md | 1 + README.md | 1 + src/App.tsx | 51 ++- src/components/AutoPaintTab.tsx | 27 ++ src/components/PreviewActions.tsx | 27 +- src/components/PrintInstructions.tsx | 194 ++++---- src/components/PrintSettingsCard.tsx | 3 +- src/components/ThreeDControls.tsx | 109 ++++- src/components/ThreeDView.tsx | 444 +++++++++++++----- src/docs/3d-mode.md | 16 + src/docs/generating-exporting-output.md | 4 + src/docs/settings-and-controls.md | 1 + src/hooks/useBuildWarning.ts | 61 ++- src/hooks/useSwapPlan.ts | 39 +- src/lib/autoPaint.ts | 4 +- src/lib/colorUtils.ts | 11 + src/lib/export3mf.ts | 538 ++++++++++------------ src/lib/flatPaint.ts | 344 ++++++++++++++ src/lib/layerActivation.ts | 5 + src/types/index.ts | 2 + tests/flatPaint.test.ts | 577 ++++++++++++++++++++++++ tsconfig.json | 1 - 22 files changed, 1907 insertions(+), 553 deletions(-) create mode 100644 src/lib/flatPaint.ts create mode 100644 src/lib/layerActivation.ts create mode 100644 tests/flatPaint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1645eb2..3cc2a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Kromacut are documented in this file. ### Added - **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. +- **Flat Paint mode (experimental)** - Added a Flat Paint option to Auto-paint that builds a uniform, face-down slab: each pixel column's layer order is reversed so the artwork sits flat against the build plate (pre-mirrored for face-down printing) under a transparent carrier layer, the back is filled with the foundation filament so every layer has the full footprint, and 3MF export merges the parts into one object per physical filament for AMS/toolchanger printers. Includes flat-mode print instructions, a performance warning for tall stacks, mutual exclusion with Smooth Meshing, and regression tests covering the layout, meshing, STL compaction, and 3MF grouping. - **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup. ### Changed diff --git a/README.md b/README.md index b0a74b8..f4f015c 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ Region weighting is most useful when filament budget is limited and you want the | **Enhanced color matching** | Optimizes filament ordering for best color reproduction rather than simple luminance sorting. Uses advanced algorithms (exhaustive, simulated annealing, genetic) automatically selected based on filament count. Scoring considers weighted DeltaE accuracy, height spread, layer count, and transition waste. | | **Allow repeated filament swaps** | (Requires Enhanced color matching) Allows a filament to appear more than once in the stack. This creates intermediate blended colors — for example, a thin white layer over red produces pink. The algorithm greedily inserts up to 4 extra swaps, each at the position that best improves the score. | | **Height dithering** | (Requires Enhanced color matching) Applies block-aware Floyd-Steinberg error diffusion to the quantized height map. Instead of sharp stair-steps between layer heights, dithering produces a stippled gradient that simulates intermediate heights, resulting in smoother tonal transitions in the print. Edge pixels between different heights are protected from dithering to avoid staircase artifacts. | +| **Flat Paint (flat face-down print)** | Builds a uniform-thickness slab printed image-side down instead of a stepped relief. Each pixel column's layer order is reversed so the artwork sits against the build plate (already mirrored — don't mirror in the slicer) under a transparent carrier layer, and the back is filled with the foundation filament so every layer has the full footprint. The result has a smooth, glass-flat face — great for bookmarks and coasters. Requires a multi-material printer (AMS/toolchanger); export as 3MF, which contains one object per filament plus the clear carrier object. Flat Paint and Smooth Meshing toggle each other off because flat prints always use the full-footprint slab layout. | | **Dither line width** | (Requires Height dithering) Controls the minimum dot size for the dither pattern in mm. This should roughly match your printer's line/nozzle width so dither dots are actually printable. Default: `0.42 mm`. | | **Optimizer algorithm** | Choose which optimization algorithm to use: Auto (recommended), Exhaustive, Simulated Annealing, or Genetic. Auto selects the best algorithm based on search space size. | | **Optimizer seed** | Set a random seed for reproducible optimizer results. Leave blank for random behavior. Useful for testing and comparing configurations. | diff --git a/src/App.tsx b/src/App.tsx index 2a49308..ba06283 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,6 +79,7 @@ type AutoPaintPersisted = Pick< | 'allowRepeatedSwaps' | 'heightDithering' | 'ditherLineWidth' + | 'flatPaint' >; const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { @@ -105,6 +106,7 @@ const loadAutoPaintPersisted = (): AutoPaintPersisted | null => { allowRepeatedSwaps: parsed.allowRepeatedSwaps ?? false, heightDithering: parsed.heightDithering ?? false, ditherLineWidth: parsed.ditherLineWidth, + flatPaint: parsed.flatPaint ?? false, }; } catch { return null; @@ -213,11 +215,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(() => { @@ -238,6 +244,7 @@ function App(): React.ReactElement | null { 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 @@ -256,6 +263,7 @@ function App(): React.ReactElement | null { allowRepeatedSwaps: threeDState.allowRepeatedSwaps, heightDithering: threeDState.heightDithering, ditherLineWidth: threeDState.ditherLineWidth, + flatPaint: threeDState.flatPaint, }); }, [ threeDState.filaments, @@ -267,6 +275,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". @@ -411,11 +420,11 @@ 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.autoPaintFilamentSwatches?.map((s) => s.hex) + builtModelAutoPaint + ? builtModelState.autoPaintFilamentSwatches?.map((s) => s.hex) : undefined, onProgress, onZipProgress, @@ -604,6 +613,8 @@ function App(): React.ReactElement | null { setThreeDState((prev) => ({ ...prev, ...partial })) @@ -650,32 +661,33 @@ function App(): React.ReactElement | null { {exportingSTL && ( setIsOrtho((v) => !v)} /> diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index c01e1d6..ed7350d 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -92,6 +92,10 @@ interface AutoPaintTabProps { ditherLineWidth: number; setDitherLineWidth: (v: number) => void; + // Flat Paint (flat face-down print) + flatPaint: boolean; + setFlatPaint: (v: boolean) => void; + // Optimizer options optimizerAlgorithm: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'; setOptimizerAlgorithm: (v: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto') => void; @@ -142,6 +146,8 @@ export default function AutoPaintTab({ setHeightDithering, ditherLineWidth, setDitherLineWidth, + flatPaint, + setFlatPaint, optimizerAlgorithm, setOptimizerAlgorithm, optimizerSeed, @@ -596,6 +602,27 @@ export default function AutoPaintTab({
    )} + {/* Flat Paint */} + {filaments.length > 0 && ( +
    +
    +
    + + +
    +
    + )} + {/* Optimizer Settings */} {filaments.length > 0 && (
    Promise; onExportStl: () => Promise; onExport3MF: () => Promise; + /** The currently built model is a Flat Paint slab — STL export is useless for it */ + flatPaintModel?: boolean; isOrtho?: boolean; onToggleCamera?: () => void; } @@ -62,6 +64,7 @@ export const PreviewActions: React.FC = ({ onExportImage, onExportStl, onExport3MF, + flatPaintModel = false, isOrtho = false, onToggleCamera, }) => { @@ -170,16 +173,20 @@ export const PreviewActions: React.FC = ({ - + {/* Flat Paint slabs carry their colors as per-filament 3MF + objects; a single-geometry STL of the slab is useless */} + {!flatPaintModel && ( + + )}
    - {/* Start Color */} -
    -
    Start with Color
    - {tooManyColors ? ( -
    - — + {/* Flat Paint: no manual swap sequence — slicer assigns filaments */} + {flatPaint ? ( +
    +
    + Flat Paint multi-material print
    - ) : swapPlan.length && swapPlan[0].type === 'start' ? ( - (() => { - const sw = swapPlan[0].swatch; - return ( -
    - - - {sw.hex} - +
      +
    • + Export as 3MF — the model + contains one object per filament. Assign each object to its filament + in the slicer (AMS/toolchanger required). +
    • +
    • + Use clear filament for the + transparent carrier object — it prints first and becomes the smooth + viewing face. +
    • +
    • + Print as-is — the artwork is already mirrored for face-down + printing. Do not mirror in the slicer. +
    • +
    • After printing, flip the piece over to view the image.
    • +
    +
    + ) : ( + <> + {/* Start Color */} +
    +
    + Start with Color +
    + {tooManyColors ? ( +
    + — +
    + ) : swapPlan.length && swapPlan[0].type === 'start' ? ( + (() => { + const sw = swapPlan[0].swatch; + return ( +
    + + + {sw.hex} + +
    + ); + })() + ) : ( +
    + —
    - ); - })() - ) : ( -
    - — + )}
    - )} -
    - {/* 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 ? ( -
    - Only one color configured — no swaps needed. -
    - ) : ( -
      - {swapPlan.map((entry, idx) => { - if (entry.type === 'start') return null; - return ( -
    1. - - {idx}. - -
      -
      - Swap to - - - {entry.swatch.hex} - -
      -
      - at layer{' '} - - {entry.layer} - {' '} - (~ - - {entry.height.toFixed(3)} mm + {/* 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 ? ( +
      + Only one color configured — no swaps needed. +
      + ) : ( +
        + {swapPlan.map((entry, idx) => { + if (entry.type === 'start') return null; + return ( +
      1. + + {idx}. - ) -
      -
      -
    2. - ); - })} -
    - )} -
    +
    +
    + Swap to + + + {entry.swatch.hex} + +
    +
    + at layer{' '} + + {entry.layer} + {' '} + (~ + + {entry.height.toFixed(3)} mm + + ) +
    +
    + + ); + })} + + )} +
    + + )}
    ℹ️{' '} @@ -166,4 +202,4 @@ export default function PrintInstructions({
    ); -} \ No newline at end of file +} diff --git a/src/components/PrintSettingsCard.tsx b/src/components/PrintSettingsCard.tsx index 1e0f3f1..713e0fd 100644 --- a/src/components/PrintSettingsCard.tsx +++ b/src/components/PrintSettingsCard.tsx @@ -248,7 +248,8 @@ export default function PrintSettingsCard({
    Smooth Meshing

    - Smooth connected color boundary edges with fast welded topology + Smooth connected color boundary edges with fast welded topology. + Turning this on disables Flat Paint.

    void; /** * Called whenever non-build settings change so the parent can keep @@ -41,7 +45,15 @@ interface ThreeDControlsProps { persisted?: ThreeDControlsStateShape | null; } -export default function ThreeDControls({ swatches, imageDimensions, onChange, onSettingsChange, persisted }: ThreeDControlsProps) { +export default function ThreeDControls({ + swatches, + imageDimensions, + builtState = null, + builtFlatPaint = false, + onChange, + onSettingsChange, + persisted, +}: ThreeDControlsProps) { // --- Filaments --- const { filaments, setFilaments, addFilament, removeFilament, updateFilament } = useFilaments({ initial: persisted?.filaments?.length ? persisted.filaments : undefined, @@ -64,9 +76,16 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const initialPaintMode = persisted?.paintMode ?? 'manual'; + const initialFlatPaint = persisted?.flatPaint ?? false; + // --- Print Settings --- const [initialPrintSettings] = useState(() => { const stored = loadPrintSettingsFromStorage(); + const storedSmoothMeshing = + stored?.smoothMeshing ?? + persisted?.smoothMeshing ?? + DEFAULT_PRINT_SETTINGS.smoothMeshing; return { layerHeight: stored?.layerHeight ?? persisted?.layerHeight ?? DEFAULT_PRINT_SETTINGS.layerHeight, @@ -76,8 +95,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on DEFAULT_PRINT_SETTINGS.slicerFirstLayerHeight, pixelSize: stored?.pixelSize ?? persisted?.pixelSize ?? DEFAULT_PRINT_SETTINGS.pixelSize, - smoothMeshing: - stored?.smoothMeshing ?? persisted?.smoothMeshing ?? DEFAULT_PRINT_SETTINGS.smoothMeshing, + smoothMeshing: storedSmoothMeshing, }; }); @@ -90,14 +108,13 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on const [calibrationLayerHeight, setCalibrationLayerHeight] = useState( persisted?.calibrationLayerHeight ?? initialPrintSettings.layerHeight ); - const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>( - persisted?.paintMode ?? 'manual' - ); + const [paintMode, setPaintMode] = useState<'manual' | 'autopaint'>(initialPaintMode); const [autoPaintMaxHeight, setAutoPaintMaxHeight] = useState(undefined); const [enhancedColorMatch, setEnhancedColorMatch] = useState(persisted?.enhancedColorMatch ?? false); const [allowRepeatedSwaps, setAllowRepeatedSwaps] = useState(persisted?.allowRepeatedSwaps ?? false); const [heightDithering, setHeightDithering] = useState(persisted?.heightDithering ?? false); const [ditherLineWidth, setDitherLineWidth] = useState(persisted?.ditherLineWidth ?? 0.42); + const [flatPaint, setFlatPaint] = useState(initialFlatPaint); // --- Optimizer Options --- const [optimizerAlgorithm, setOptimizerAlgorithm] = useState<'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'>( @@ -124,6 +141,20 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on } }, []); + const flatPaintActive = paintMode === 'autopaint' && flatPaint; + const effectiveSmoothMeshing = flatPaintActive ? false : smoothMeshing; + + const handleSmoothMeshingChange = useCallback((enabled: boolean) => { + setSmoothMeshing(enabled); + if (enabled) { + setFlatPaint(false); + } + }, []); + + const handleFlatPaintChange = useCallback((enabled: boolean) => { + setFlatPaint(enabled); + }, []); + // Sync non-build settings to parent so persisted stays current across mode switches useEffect(() => { onSettingsChange?.({ @@ -133,13 +164,14 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on allowRepeatedSwaps, heightDithering, ditherLineWidth, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); + }, [paintMode, filaments, enhancedColorMatch, allowRepeatedSwaps, heightDithering, ditherLineWidth, flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, smoothMeshing]); useEffect(() => { savePrintSettingsToStorage({ layerHeight, slicerFirstLayerHeight, pixelSize, smoothMeshing }); @@ -210,10 +242,19 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on paintMode === 'autopaint' && autoPaintSliceData ? autoPaintSliceData.colorSliceHeights : colorSliceHeights; - const depth = estimateOrder.reduce((total, swatchIndex, position) => { - const height = estimateHeights[swatchIndex] ?? 0; - return total + (position === 0 ? Math.max(height, slicerFirstLayerHeight) : height); - }, 0); + const depth = + flatPaintActive && paintMode === 'autopaint' + ? Math.max(slicerFirstLayerHeight, layerHeight) + + estimateOrder.filter((swatchIndex) => (estimateHeights[swatchIndex] ?? 0) > 0) + .length * + layerHeight + : estimateOrder.reduce((total, swatchIndex, position) => { + const height = estimateHeights[swatchIndex] ?? 0; + return ( + total + + (position === 0 ? Math.max(height, slicerFirstLayerHeight) : height) + ); + }, 0); return { width: widthPx * pixelSize, @@ -225,27 +266,39 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on colorOrder, colorSliceHeights, imageDimensions, + flatPaintActive, + layerHeight, paintMode, pixelSize, slicerFirstLayerHeight, ]); + const instructionPaintMode = builtState?.paintMode ?? paintMode; + const instructionAutoPaintResult = builtState?.autoPaintResult ?? autoPaintResult; + const instructionColorOrder = builtState?.colorOrder ?? colorOrder; + const instructionColorSliceHeights = builtState?.colorSliceHeights ?? colorSliceHeights; + const instructionFiltered = builtState?.filteredSwatches ?? filtered; + const instructionLayerHeight = builtState?.layerHeight ?? layerHeight; + const instructionSlicerFirstLayerHeight = + builtState?.slicerFirstLayerHeight ?? slicerFirstLayerHeight; + const instructionFlatPaint = builtState ? builtFlatPaint : flatPaintActive; const instructionColorCount = - paintMode === 'autopaint' - ? autoPaintResult?.layers.length ?? 0 - : displayOrder.length; + instructionPaintMode === 'autopaint' + ? instructionAutoPaintResult?.layers.length ?? 0 + : instructionColorOrder.length; const isInstructionOverLimit = instructionColorCount > 64; // --- Swap Plan --- const { swapPlan, copied, copyToClipboard } = useSwapPlan({ - colorOrder, - colorSliceHeights, - filtered, - layerHeight, - slicerFirstLayerHeight, - paintMode, - autoPaintResult, + colorOrder: instructionColorOrder, + colorSliceHeights: instructionColorSliceHeights, + filtered: instructionFiltered, + layerHeight: instructionLayerHeight, + slicerFirstLayerHeight: instructionSlicerFirstLayerHeight, + paintMode: instructionPaintMode, + autoPaintResult: instructionAutoPaintResult, disabled: isInstructionOverLimit, + flatPaint: instructionFlatPaint, }); // --- Apply handler --- @@ -266,6 +319,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on allowRepeatedSwaps, heightDithering, ditherLineWidth, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -285,6 +339,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on pixelSize, filaments, paintMode, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -306,6 +361,7 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on allowRepeatedSwaps, heightDithering, ditherLineWidth, + flatPaint, optimizerAlgorithm, optimizerSeed, regionWeightingMode, @@ -345,11 +401,11 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on slicerFirstLayerHeight={slicerFirstLayerHeight} pixelSize={pixelSize} modelSizeEstimate={modelSizeEstimate} - smoothMeshing={smoothMeshing} + smoothMeshing={effectiveSmoothMeshing} onLayerHeightChange={setLayerHeight} onSlicerFirstLayerHeightChange={setSlicerFirstLayerHeight} onPixelSizeChange={setPixelSize} - onSmoothMeshingChange={setSmoothMeshing} + onSmoothMeshingChange={handleSmoothMeshingChange} onReset={handleResetPrintSettings} allDefault={ layerHeight === DEFAULT_PRINT_SETTINGS.layerHeight && @@ -415,6 +471,8 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on setHeightDithering={setHeightDithering} ditherLineWidth={ditherLineWidth} setDitherLineWidth={setDitherLineWidth} + flatPaint={flatPaint} + setFlatPaint={handleFlatPaintChange} optimizerAlgorithm={optimizerAlgorithm} setOptimizerAlgorithm={setOptimizerAlgorithm} optimizerSeed={optimizerSeed} @@ -501,12 +559,13 @@ export default function ThreeDControls({ swatches, imageDimensions, onChange, on {/* Print Instructions */}
    ); diff --git a/src/components/ThreeDView.tsx b/src/components/ThreeDView.tsx index 2a6869e..20fca07 100644 --- a/src/components/ThreeDView.tsx +++ b/src/components/ThreeDView.tsx @@ -6,9 +6,13 @@ import useThreeScene from '../hooks/useThreeScene'; import { generateGreedyMesh, generateSmoothMesh, + type MeshData, type MeshMetrics, type MeshProgress, } from '../lib/meshing'; +import { LAYER_ACTIVATION_EPSILON } from '../lib/layerActivation'; +import { normalizeHexColor as normalizeHexColorValue } from '../lib/colorUtils'; +import { buildFlatPaintLayout, heightMapToFlatPaintLayerCounts } from '../lib/flatPaint'; import { clampProgress, layeredBuildScanProgress, @@ -40,6 +44,7 @@ interface ThreeDViewProps { ditherLineWidth?: number; // Minimum dot size in mm for dithering smoothMeshing?: boolean; // Smooth connected boundaries using welded grid topology isOrtho?: boolean; + flatPaint?: boolean; // Build a flat face-down slab (Flat Paint style, auto-paint only) } // Convert hex color to RGB tuple @@ -132,10 +137,7 @@ function sliderSpanPercentCss(startPercent: number, endPercent: number) { } function normalizeHexColor(hex: string | undefined) { - const fallback = '#3b82f6'; - if (!hex) return fallback; - const value = hex.startsWith('#') ? hex : `#${hex}`; - return /^#[0-9a-f]{6}$/i.test(value) ? value.toUpperCase() : fallback; + return normalizeHexColorValue(hex, '#3b82f6'); } function layerNumberForTransition( @@ -200,6 +202,33 @@ 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; @@ -227,6 +256,7 @@ interface E2EBuildMetrics { autoPaintEnabled: boolean; enhancedColorMatch: boolean; heightDithering: boolean; + flatPaint?: boolean; }; } @@ -262,6 +292,12 @@ 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; @@ -306,6 +342,7 @@ export default function ThreeDView({ ditherLineWidth = 0.42, smoothMeshing = false, isOrtho = false, + flatPaint = false, }: ThreeDViewProps) { const mountRef = useRef(null); const [isBuilding, setIsBuilding] = useState(false); @@ -404,6 +441,9 @@ 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; @@ -453,6 +493,7 @@ export default function ThreeDView({ filamentSwatches, swatches, layerHeight, + flatPaint, ]); const updateHoveredSegment = ( @@ -483,6 +524,7 @@ export default function ThreeDView({ const debounceTimerRef = useRef(null); const lastParamsKeyRef = useRef(null); const lastRebuildRef = useRef(rebuildSignal); + const lastImageSrcRef = useRef(imageSrc); useEffect(() => { return () => { @@ -497,6 +539,9 @@ export default function ThreeDView({ const modelGroup = modelGroupRef.current; if (!modelGroup) return; + const imageChanged = imageSrc !== lastImageSrcRef.current; + lastImageSrcRef.current = imageSrc; + if (!imageSrc) { buildTokenRef.current++; if (debounceTimerRef.current !== null) { @@ -504,14 +549,33 @@ export default function ThreeDView({ debounceTimerRef.current = null; } modelGroup.clear(); + clearLastMeshRef(); setIsBuilding(false); setModelDimensions(null); setMaxModelHeight(0); + setPreviewMinHeight(0); setPreviewHeight(null); requestRender(); return; } + if (imageChanged) { + buildTokenRef.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(); + } + const rebuildRequested = rebuildSignal !== lastRebuildRef.current; if (!rebuildRequested) return; @@ -526,10 +590,13 @@ export default function ThreeDView({ 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, @@ -539,6 +606,8 @@ 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, @@ -550,6 +619,7 @@ export default function ThreeDView({ heightDithering, ditherLineWidth, smoothMeshing, + flatPaint, }); if (paramsKey === lastParamsKeyRef.current) return; // nothing changed logically lastParamsKeyRef.current = paramsKey; @@ -557,7 +627,7 @@ export default function ThreeDView({ // Debounce rapid changes (e.g., dragging slider) if (debounceTimerRef.current !== null) window.clearTimeout(debounceTimerRef.current); const token = ++buildTokenRef.current; - setActiveBuildSmoothMeshing(smoothMeshing); + setActiveBuildSmoothMeshing(buildSmoothMeshing); debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = null; const buildStartedAt = performance.now(); @@ -572,10 +642,11 @@ export default function ThreeDView({ pixelSize, layerHeight, slicerFirstLayerHeight, - smoothMeshing, + smoothMeshing: buildSmoothMeshing, autoPaintEnabled, enhancedColorMatch, heightDithering, + flatPaint, }, }); @@ -620,6 +691,7 @@ export default function ThreeDView({ // Clear current model modelGroup.clear(); + clearLastMeshRef(); const YIELD_MS = 12; let lastYield = performance.now(); @@ -1166,58 +1238,157 @@ export default function ThreeDView({ } } - // 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; + 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]; + } + } + } - 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 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 topZ = i === 0 ? cumulativeHeights[0] : cumulativeHeights[i]; - const baseZ = i === 0 ? 0 : cumulativeHeights[i - 1]; + 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 + ) + ); + }; - // Identify active pixels for this layer using precomputed height map - const activePixels = new Uint8Array(boxW * boxH); - let activeCount = 0; + 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; + }; - for (let y = 0; y < boxH; y++) { - for (let x = 0; x < boxW; x++) { - const mapIdx = y * boxW + x; - const pixelHeight = pixelHeightMap[mapIdx]; + 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, + }); - if (pixelHeight > 0 && pixelHeight >= topZ - 0.001) { - activePixels[(boxH - 1 - y) * boxW + x] = 1; - activeCount++; + const geom = createFlatShadedGeometry( + generatedMesh.positions, + generatedMesh.indices, + { + activePixels: part.mask, + width: boxW, + height: boxH, + pixelSize, + topZ: part.topZ * heightScale, + compactHeightfield: true, } - } - - pushLayerDetail( - buildLayerIndex, - 'Selecting active pixels', - progressInSpan(0, 0.35, (y + 1) / boxH) ); + 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); if (performance.now() - lastYield > YIELD_MS) { await new Promise((r) => requestAnimationFrame(r)); @@ -1225,63 +1396,127 @@ 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 + ); - if (activeCount === 0) continue; + for ( + let buildLayerIndex = 0; + buildLayerIndex < layerBuildOrder.length; + buildLayerIndex++ + ) { + const i = layerBuildOrder[buildLayerIndex]; + if (token !== buildTokenRef.current) return; - // Generate mesh for this layer - const generatedMesh = await ( - 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, - }); + 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; - const geom = createFlatShadedGeometry( - generatedMesh.positions, - generatedMesh.indices, - { - activePixels, - width: boxW, - height: boxH, - pixelSize, - topZ: (baseZ + thickness) * heightScale, - compactHeightfield: !smoothMeshing, + 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 - LAYER_ACTIVATION_EPSILON + ) { + activePixels[(boxH - 1 - y) * boxW + x] = 1; + activeCount++; + } + } + + 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(); + } } - ); - pushLayerDetail(buildLayerIndex, 'Preparing preview geometry', 0.96); - const mat = new THREE.MeshStandardMaterial({ - color: colorHex, - side: THREE.FrontSide, - metalness: 0, - roughness: 0.7, - flatShading: true, - }); - const mesh = new THREE.Mesh(geom, mat); - // 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 (activeCount === 0) continue; - if (performance.now() - lastYield > YIELD_MS) { - await new Promise((r) => requestAnimationFrame(r)); - if (token !== buildTokenRef.current) return; - lastYield = performance.now(); + // Generate mesh for this layer + const generatedMesh = await ( + buildSmoothMeshing ? 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, + }); + + const geom = createFlatShadedGeometry( + generatedMesh.positions, + generatedMesh.indices, + { + activePixels, + 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, + side: THREE.FrontSide, + metalness: 0, + roughness: 0.7, + flatShading: true, + }); + + const mesh = new THREE.Mesh(geom, mat); + // 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(); + } } - } - for (const mesh of builtLayerMeshes) { - if (mesh) { - modelGroup.add(mesh); + for (const mesh of builtLayerMeshes) { + if (mesh) { + modelGroup.add(mesh); + } } } } else { @@ -1402,7 +1637,7 @@ export default function ThreeDView({ // Generate Optimized Greedy Mesh const generatedMesh = await ( - smoothMeshing ? generateSmoothMesh : generateGreedyMesh + buildSmoothMeshing ? generateSmoothMesh : generateGreedyMesh )(activePixels, boxW, boxH, thickness, baseZ, pixelSize, heightScale, { yieldIntervalMs: 8, onProgress: meshProgressReporter(buildLayerIndex), @@ -1425,7 +1660,7 @@ export default function ThreeDView({ height: boxH, pixelSize, topZ: (baseZ + thickness) * heightScale, - compactHeightfield: !smoothMeshing, + compactHeightfield: !buildSmoothMeshing, } ); pushLayerDetail(buildLayerIndex, 'Preparing preview geometry', 0.96); @@ -1502,10 +1737,11 @@ export default function ThreeDView({ pixelSize, layerHeight, slicerFirstLayerHeight, - smoothMeshing, + smoothMeshing: buildSmoothMeshing, autoPaintEnabled, enhancedColorMatch, heightDithering, + flatPaint, }, }); @@ -1623,6 +1859,7 @@ export default function ThreeDView({ colorSliceHeights, colorOrder, swatches, + filamentSwatches, pixelSize, heightScale, stepped, @@ -1635,6 +1872,7 @@ export default function ThreeDView({ heightDithering, ditherLineWidth, smoothMeshing, + flatPaint, cameraRef, controlsRef, materialRef, diff --git a/src/docs/3d-mode.md b/src/docs/3d-mode.md index ba34760..9cf25eb 100644 --- a/src/docs/3d-mode.md +++ b/src/docs/3d-mode.md @@ -80,6 +80,20 @@ Optional controls appear with enhanced matching: - **Line width** should roughly match the printer line or nozzle width used for dither dots. - **Optimizer Settings** let you choose **Algorithm**, **Region priority**, and an optional **Seed**. +## Flat Paint + +**Flat Paint (flat face-down print)** builds a uniform-thickness slab instead of a stepped relief. Every printed layer has the full model footprint: + +- The artwork is placed face down against the build plate, under a **transparent carrier layer** that prints first and becomes the smooth viewing face. Use clear filament for the carrier object. +- Each pixel column's layer order is reversed so the print looks identical to the normal model when viewed from the face side, and the space behind the image is filled with the foundation filament. +- The model is already mirrored for face-down printing — do not mirror it again in the slicer. After printing, flip the piece over to view the image. + +Because a single printed layer contains several filaments side by side, Flat Paint requires a multi-material printer (AMS or toolchanger). Export as **3MF**: the model contains one object per filament, plus the carrier object, ready for per-object filament assignment in the slicer. + +Flat Paint works in both standard and enhanced color matching modes. Expect heavier geometry and slower slicing than a normal build — flat models are best for bookmarks, coasters, and other pieces that benefit from a smooth, glass-flat face. + +Flat Paint and **Smooth Meshing** are mutually exclusive. Turning one on turns the other off because Flat Paint already uses a full-footprint slab layout instead of smoothed boundary contours. + ## Optimizer Settings | Setting | Meaning | @@ -114,4 +128,6 @@ After building, the bottom **Layer Preview** bar lets you show only a height ran Hover over color segments to see the start layer or swap layer. The preview range is only for inspection; exports still include the complete model. +In Flat Paint mode the bar shows a plain track because printed layers contain several filaments at once — there is no single swap sequence. Orbit underneath the model to inspect the artwork face. + Next: [Generating and exporting output](generating-exporting-output). diff --git a/src/docs/generating-exporting-output.md b/src/docs/generating-exporting-output.md index b9ea045..5025ca0 100644 --- a/src/docs/generating-exporting-output.md +++ b/src/docs/generating-exporting-output.md @@ -38,6 +38,8 @@ Open the 3D download menu and choose: 3MF export preserves physical filament colors in Auto-paint where possible. Still review slicer assignments before printing. +For **Flat Paint** models the download menu offers only 3MF: the model contains one object per physical filament plus a transparent carrier object, and an uncolored single-geometry STL of the flat slab would be useless. Flat Paint turns off **Smooth Meshing** because the flat slab layout does not use smoothed boundary contours. + ## Print Instructions The **Print Instructions** panel gives you: @@ -49,6 +51,8 @@ The **Print Instructions** panel gives you: Use the copied plan beside your slicer preview. The layer numbers depend on **Layer Height** and **First Layer Height**, so keep those values consistent. +In Flat Paint mode there is no manual swap plan. The panel instead summarizes the multi-material workflow: assign each 3MF object to its filament, use clear filament for the carrier, and print without mirroring. + ## Recommended Slicer Setup Kromacut recommends: diff --git a/src/docs/settings-and-controls.md b/src/docs/settings-and-controls.md index 319ab05..f6a235a 100644 --- a/src/docs/settings-and-controls.md +++ b/src/docs/settings-and-controls.md @@ -47,6 +47,7 @@ Auto-paint settings are preserved across sessions, including: - Enhanced color matching. - Repeated swaps. - Height dithering and line width. +- Flat Paint. - Optimizer algorithm and seed. - Region priority. diff --git a/src/hooks/useBuildWarning.ts b/src/hooks/useBuildWarning.ts index a071443..c67dddb 100644 --- a/src/hooks/useBuildWarning.ts +++ b/src/hooks/useBuildWarning.ts @@ -3,6 +3,7 @@ import type { ThreeDControlsStateShape } from '../types'; const LAYER_WARNING_THRESHOLD = 64; const PIXEL_WARNING_THRESHOLD = 2500000; +const FLAT_PAINT_LAYER_WARNING_THRESHOLD = 32; export interface BuildWarning { warnings: string[]; @@ -13,23 +14,38 @@ export interface UseBuildWarningOptions { imageSrc?: string | null; } +const INITIAL_THREE_D_STATE: ThreeDControlsStateShape = { + layerHeight: 0.12, + slicerFirstLayerHeight: 0.2, + colorSliceHeights: [], + colorOrder: [], + filteredSwatches: [], + pixelSize: 0.1, + filaments: [], + paintMode: 'manual', +}; + +function clearLastBuiltMeshRef() { + if (typeof window === 'undefined') return; + (window as unknown as { __KROMACUT_LAST_MESH?: unknown }).__KROMACUT_LAST_MESH = undefined; +} + export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { const [imageDimensions, setImageDimensions] = useState<{ w: number; h: number } | null>(null); const [buildWarning, setBuildWarning] = useState(null); - const [threeDState, setThreeDState] = useState({ - layerHeight: 0.12, - slicerFirstLayerHeight: 0.2, - colorSliceHeights: [], - colorOrder: [], - filteredSwatches: [], - pixelSize: 0.1, - filaments: [], - paintMode: 'manual', - }); + const [threeDState, setThreeDState] = + useState(INITIAL_THREE_D_STATE); const [threeDBuildSignal, setThreeDBuildSignal] = useState(0); + const [builtThreeDState, setBuiltThreeDState] = useState( + null + ); + const builtFlatPaint = + builtThreeDState?.paintMode === 'autopaint' && !!builtThreeDState.flatPaint; // Track image dimensions for build warning checks useEffect(() => { + setBuiltThreeDState(null); + clearLastBuiltMeshRef(); if (!imageSrc) { setImageDimensions(null); return; @@ -43,6 +59,17 @@ export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { // Apply state without warning (used after user confirms, or when no warning needed) const applyThreeDState = useCallback((s: ThreeDControlsStateShape) => { setThreeDState(s); + setBuiltThreeDState({ + ...s, + colorSliceHeights: [...s.colorSliceHeights], + colorOrder: [...s.colorOrder], + filteredSwatches: [...s.filteredSwatches], + filaments: [...s.filaments], + autoPaintSwatches: s.autoPaintSwatches ? [...s.autoPaintSwatches] : undefined, + autoPaintFilamentSwatches: s.autoPaintFilamentSwatches + ? [...s.autoPaintFilamentSwatches] + : undefined, + }); setThreeDBuildSignal((n) => n + 1); }, []); @@ -67,6 +94,18 @@ export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { } } + if (s.paintMode === 'autopaint' && s.flatPaint && layerCount > FLAT_PAINT_LAYER_WARNING_THRESHOLD) { + warnings.push( + `Flat Paint fills every one of the ${layerCount} layers at full size, producing much heavier geometry and slower slicing. Consider raising the layer height or lowering Max Height.` + ); + } + + if (s.paintMode === 'autopaint' && s.flatPaint && s.heightDithering) { + warnings.push( + 'Flat Paint with height dithering can fragment color regions into many small parts, making builds, exports, and slicer processing much slower.' + ); + } + if (warnings.length > 0) { setBuildWarning({ warnings, pendingState: s }); } else { @@ -91,6 +130,8 @@ export function useBuildWarning({ imageSrc }: UseBuildWarningOptions) { threeDState, setThreeDState, threeDBuildSignal, + builtThreeDState, + builtFlatPaint, buildWarning, handleThreeDStateChange, confirmBuild, diff --git a/src/hooks/useSwapPlan.ts b/src/hooks/useSwapPlan.ts index f493c2b..2546f72 100644 --- a/src/hooks/useSwapPlan.ts +++ b/src/hooks/useSwapPlan.ts @@ -15,6 +15,8 @@ export interface UseSwapPlanOptions { paintMode: 'manual' | 'autopaint'; autoPaintResult?: AutoPaintResult; disabled?: boolean; + /** Flat Paint prints have no manual swap sequence (multi-material per layer) */ + flatPaint?: boolean; } export function useSwapPlan({ @@ -26,9 +28,10 @@ export function useSwapPlan({ paintMode, autoPaintResult, disabled = false, + flatPaint = false, }: UseSwapPlanOptions) { const swapPlan = useMemo(() => { - if (disabled) { + if (disabled || flatPaint) { return [] as SwapEntry[]; } @@ -110,11 +113,20 @@ export function useSwapPlan({ paintMode, autoPaintResult, disabled, + flatPaint, ]); // Build a plain-text representation of the instructions for copying const buildInstructionsText = () => { const lines: string[] = []; + const appendFooter = () => { + lines.push(''); + lines.push('Notes: Heights are approximate. Confirm in slicer before printing.'); + lines.push(''); + lines.push('---------------------'); + lines.push('Made with Kromacut by vycdev!'); + }; + lines.push('3D Print Instructions'); lines.push('---------------------'); lines.push(`Layer height: ${layerHeight.toFixed(3)} mm`); @@ -122,6 +134,23 @@ export function useSwapPlan({ lines.push('Recommended: Layer loops: 1; Infill: 100%'); lines.push(''); + if (flatPaint) { + lines.push('Flat Paint mode (flat face-down print):'); + lines.push('- Export as 3MF: the model contains one object per filament.'); + lines.push( + '- Assign each object to its filament in the slicer (multi-material printer required).' + ); + lines.push( + '- Use clear/transparent filament for the carrier object — it is the first printed layer and becomes the smooth viewing face.' + ); + lines.push( + '- Print as-is: the artwork is already mirrored for face-down printing. Do not mirror in the slicer.' + ); + lines.push('- After printing, flip the piece over: the image side is the bottom.'); + appendFooter(); + return lines.join('\n'); + } + if (swapPlan.length) { const first = swapPlan[0]; if (first.type === 'start') lines.push(`Start with color: ${first.swatch.hex}`); @@ -146,11 +175,7 @@ export function useSwapPlan({ idx++; } } - lines.push(''); - lines.push('Notes: Heights are approximate. Confirm in slicer before printing.'); - lines.push(''); - lines.push('---------------------'); - lines.push('Made with Kromacut by vycdev!'); + appendFooter(); return lines.join('\n'); }; @@ -186,4 +211,4 @@ export function useSwapPlan({ copied, copyToClipboard, }; -} \ No newline at end of file +} diff --git a/src/lib/autoPaint.ts b/src/lib/autoPaint.ts index 8af0365..56f98d8 100644 --- a/src/lib/autoPaint.ts +++ b/src/lib/autoPaint.ts @@ -26,6 +26,8 @@ import { import { generateCenterWeightedMapSimple, generateEdgeWeightedMapSimple } from './regionWeighting'; import { computeProfileConfidence } from './calibration'; +export { LAYER_ACTIVATION_EPSILON } from './layerActivation'; + /** RGB color representation (0-255 range) */ export interface RGB { r: number; @@ -1735,4 +1737,4 @@ export function debugAutoPaint( ); }); console.groupEnd(); -} \ No newline at end of file +} diff --git a/src/lib/colorUtils.ts b/src/lib/colorUtils.ts index f5f5cc6..74b4c29 100644 --- a/src/lib/colorUtils.ts +++ b/src/lib/colorUtils.ts @@ -2,6 +2,17 @@ * Shared color utility functions. */ +/** + * Normalize a hex color to canonical `#RRGGBB` uppercase form. + * Accepts values with or without the leading '#'; anything that is not a + * 6-digit hex color returns the fallback unchanged. + */ +export function normalizeHexColor(hex: string | undefined, fallback: string): string { + if (!hex) return fallback; + const value = hex.startsWith('#') ? hex : `#${hex}`; + return /^#[0-9a-f]{6}$/i.test(value) ? value.toUpperCase() : fallback; +} + /** * Compute perceived luminance (0–1) from a hex color string. * Uses the standard sRGB luminance coefficients. diff --git a/src/lib/export3mf.ts b/src/lib/export3mf.ts index f456281..a14185f 100644 --- a/src/lib/export3mf.ts +++ b/src/lib/export3mf.ts @@ -2,6 +2,7 @@ import JSZip from 'jszip'; import * as THREE from 'three'; import { MINIMAL_PROJECT_SETTINGS, KROMACUT_CONFIG } from './slicerDefaults'; import { clampProgress, exportMeshProgress, exportZipProgress, progressInSpan } from './progress'; +import { normalizeHexColor } from './colorUtils'; export interface Export3MFOptions { layerHeight?: number; @@ -22,6 +23,18 @@ type ExportGeometrySource = { itemSize?: number; }; +/** + * Meshes tagged with the same `userData.kromacutExportGroup` key are merged + * into a single 3MF object (used by Flat Paint to export one object per + * physical filament). Untagged meshes keep the one-object-per-mesh behavior. + */ +interface ExportMeshGroup { + meshes: THREE.Mesh[]; + overrideHex?: string; + materialKey?: string; + partName?: string; +} + function getKromacutExportGeometry(geometry: THREE.BufferGeometry): ExportGeometrySource | null { const source = geometry.userData?.kromacutExportGeometry as ExportGeometrySource | undefined; if (!source?.positions || !source.indices) return null; @@ -32,6 +45,11 @@ function getKromacutExportGeometry(geometry: THREE.BufferGeometry): ExportGeomet }; } +function readMeshUserDataString(mesh: THREE.Mesh, key: string): string | undefined { + const value = (mesh.userData as Record | undefined)?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, @@ -79,36 +97,78 @@ export async function exportObjectTo3MFBlob( if (meshes.length === 0) throw new Error('No meshes to export'); + // Group meshes into exported objects (see ExportMeshGroup). + const groups: ExportMeshGroup[] = []; + const groupByKey = new Map(); + + meshes.forEach((mesh, meshIndex) => { + const groupKey = readMeshUserDataString(mesh, 'kromacutExportGroup'); + const meshHex = readMeshUserDataString(mesh, 'kromacutFilamentHex'); + const materialKey = readMeshUserDataString(mesh, 'kromacutMaterialKey'); + const meshName = readMeshUserDataString(mesh, 'kromacutPartName'); + + if (groupKey) { + if (!meshHex) { + throw new Error(`Export group "${groupKey}" is missing kromacutFilamentHex`); + } + let group = groupByKey.get(groupKey); + if (!group) { + group = { + meshes: [], + overrideHex: meshHex, + materialKey: materialKey ?? groupKey, + partName: meshName, + }; + groupByKey.set(groupKey, group); + groups.push(group); + } + group.meshes.push(mesh); + if (meshHex !== group.overrideHex) { + throw new Error(`Export group "${groupKey}" contains multiple filament colors`); + } + group.materialKey ??= materialKey ?? groupKey; + group.partName ??= meshName; + } else { + // Untagged meshes keep positional filament color mapping by mesh index. + groups.push({ + meshes: [mesh], + overrideHex: meshHex ?? options?.layerFilamentColors?.[meshIndex], + partName: meshName, + }); + } + }); + // Collect materials (colors) // We map hex string -> index in basematerials const colorMap = new Map(); const colors: string[] = []; const normalizeHex = (hex?: string): string | null => { - if (!hex) return null; - const cleaned = hex.replace('#', '').toUpperCase(); - return cleaned.length === 6 ? cleaned : null; + const normalized = normalizeHexColor(hex, ''); + return normalized ? normalized.slice(1) : null; }; const getMaterialIndex = ( material: THREE.Material | THREE.Material[], - overrideHex?: string + overrideHex?: string, + materialKey?: string ): number => { const mat = Array.isArray(material) ? material[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(); } - if (!colorMap.has(hex)) { - colorMap.set(hex, colors.length); + const mapKey = materialKey ? `${materialKey}:${hex}` : hex; + if (!colorMap.has(mapKey)) { + colorMap.set(mapKey, colors.length); colors.push(hex); } - return colorMap.get(hex)!; + return colorMap.get(mapKey)!; }; // Pre-calculate all materials so we can write the header correctly - for (let i = 0; i < meshes.length; i++) { - getMaterialIndex(meshes[i].material, options?.layerFilamentColors?.[i]); + for (const group of groups) { + getMaterialIndex(group.meshes[0].material, group.overrideHex, group.materialKey); } // Prepare Project Settings (Minimal) @@ -217,38 +277,36 @@ export async function exportObjectTo3MFBlob( const reportProgress = (value: number) => { onProgress?.(clampProgress(value)); }; - const totalMeshes = meshes.length; + const totalGroups = groups.length; // Mesh generation is the first 80%; zip generation owns the final 20%. - const reportMeshProgress = (meshIdx: number, meshFrac: number) => { + const reportMeshProgress = (groupIdx: number, meshFrac: number) => { if (!onProgress) return; - reportProgress(exportMeshProgress(meshIdx, totalMeshes, meshFrac)); + reportProgress(exportMeshProgress(groupIdx, totalGroups, meshFrac)); }; - for (let i = 0; i < meshes.length; i++) { - const mesh = meshes[i]; - const overrideHex = options?.layerFilamentColors?.[i]; - const matIdx = getMaterialIndex(mesh.material, overrideHex); + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const overrideHex = group.overrideHex; + const matIdx = getMaterialIndex(group.meshes[0].material, overrideHex, group.materialKey); const objectId = nextId++; componentIds.push(objectId); let hex = normalizeHex(overrideHex) || 'FFFFFF'; - if ( - !overrideHex && - 'color' in mesh.material && - (mesh.material as THREE.MeshStandardMaterial).color - ) { - hex = (mesh.material as THREE.MeshStandardMaterial).color.getHexString().toUpperCase(); + const firstMaterial = group.meshes[0].material; + const firstMat = Array.isArray(firstMaterial) ? firstMaterial[0] : firstMaterial; + if (!overrideHex && 'color' in firstMat && (firstMat as THREE.MeshStandardMaterial).color) { + hex = (firstMat as THREE.MeshStandardMaterial).color.getHexString().toUpperCase(); } + const objectName = group.partName ?? `Layer ${i + 1} (#${hex})`; // Use 1-based index for color/extruder componentMeta.push({ id: objectId, - name: `Layer ${i + 1} (#${hex})`, + name: objectName, colorIdx: matIdx + 1, }); - const layerName = `Layer ${i + 1} (#${hex})`; - const writeMeshObject = async ( - mesh: THREE.Mesh, + const writeMeshGroupObject = async ( + groupMeshes: THREE.Mesh[], meshObjectId: number, meshName: string, progressStart: number, @@ -258,10 +316,6 @@ export async function exportObjectTo3MFBlob( `); write(` `); - const geom = mesh.geometry; - const pos = geom.getAttribute('position'); - const index = geom.getIndex(); - const source = getKromacutExportGeometry(geom); const phaseProgress = (value: number) => progressInSpan(progressStart, progressSpan, value); const COLLECT_START = phaseProgress(0); @@ -269,216 +323,9 @@ export async function exportObjectTo3MFBlob( const VERTEX_WRITE_END = phaseProgress(0.68); const TRIANGLE_WRITE_END = phaseProgress(1); - if (source?.indices) { - const positions = source.positions; - const indices = source.indices; - const itemSize = source.itemSize ?? 3; - const matrix = mesh.matrixWorld; - const matrixElements = matrix.elements; - const sourceVertexCount = Math.floor(positions.length / itemSize); - const sourceTriangleCount = Math.floor(indices.length / 3); - const sourceToExportVertex = new Int32Array(sourceVertexCount); - sourceToExportVertex.fill(-1); - const exportVertexMap = new Map(); - const exportVertexCoords: number[] = []; - const triangleChunks: TriangleIndexChunk[] = []; - const TRIANGLE_CHUNK_INDICES = 300000; - let currentTriangleChunk = new Uint32Array(TRIANGLE_CHUNK_INDICES); - let currentTriangleChunkLength = 0; - let exportTriangleCount = 0; - - const getSourceExportVertex = (sourceIndex: number) => { - if ( - !Number.isInteger(sourceIndex) || - sourceIndex < 0 || - sourceIndex >= sourceVertexCount - ) { - return -1; - } - - const cached = sourceToExportVertex[sourceIndex]; - if (cached !== -1) { - return cached >= 0 ? cached : -1; - } - - const sourceOffset = sourceIndex * itemSize; - const x = positions[sourceOffset]; - const y = positions[sourceOffset + 1]; - const z = positions[sourceOffset + 2]; - - if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { - sourceToExportVertex[sourceIndex] = -2; - return -1; - } - - const transformedX = - matrixElements[0] * x + - matrixElements[4] * y + - matrixElements[8] * z + - matrixElements[12]; - const transformedY = - matrixElements[1] * x + - matrixElements[5] * y + - matrixElements[9] * z + - matrixElements[13]; - const transformedZ = - matrixElements[2] * x + - matrixElements[6] * y + - matrixElements[10] * z + - matrixElements[14]; - - const coordX = toCoordUnits(transformedX); - const coordY = toCoordUnits(transformedY); - const coordZ = toCoordUnits(transformedZ); - const key = `${coordX},${coordY},${coordZ}`; - let exportIndex = exportVertexMap.get(key); - - if (exportIndex === undefined) { - exportIndex = exportVertexCoords.length / 3; - exportVertexMap.set(key, exportIndex); - exportVertexCoords.push(coordX, coordY, coordZ); - } - - sourceToExportVertex[sourceIndex] = exportIndex; - return exportIndex; - }; - - const flushTriangleChunk = () => { - if (currentTriangleChunkLength === 0) return; - triangleChunks.push({ - data: currentTriangleChunk, - length: currentTriangleChunkLength, - }); - currentTriangleChunk = new Uint32Array(TRIANGLE_CHUNK_INDICES); - currentTriangleChunkLength = 0; - }; - - const pushExportTriangle = (v1: number, v2: number, v3: number) => { - if (currentTriangleChunkLength + 3 > currentTriangleChunk.length) { - flushTriangleChunk(); - } - - currentTriangleChunk[currentTriangleChunkLength++] = v1; - currentTriangleChunk[currentTriangleChunkLength++] = v2; - currentTriangleChunk[currentTriangleChunkLength++] = v3; - exportTriangleCount++; - }; - - const addExportTriangle = (sourceA: number, sourceB: number, sourceC: number) => { - const v1 = getSourceExportVertex(sourceA); - const v2 = getSourceExportVertex(sourceB); - const v3 = getSourceExportVertex(sourceC); - - if (v1 < 0 || v2 < 0 || v3 < 0 || v1 === v2 || v2 === v3 || v1 === v3) { - return; - } - - const p1 = v1 * 3; - const p2 = v2 * 3; - const p3 = v3 * 3; - const abx = exportVertexCoords[p2] - exportVertexCoords[p1]; - const aby = exportVertexCoords[p2 + 1] - exportVertexCoords[p1 + 1]; - const abz = exportVertexCoords[p2 + 2] - exportVertexCoords[p1 + 2]; - const acx = exportVertexCoords[p3] - exportVertexCoords[p1]; - const acy = exportVertexCoords[p3 + 1] - exportVertexCoords[p1 + 1]; - const acz = exportVertexCoords[p3 + 2] - exportVertexCoords[p1 + 2]; - const crossX = aby * acz - abz * acy; - const crossY = abz * acx - abx * acz; - const crossZ = abx * acy - aby * acx; - - if (crossX === 0 && crossY === 0 && crossZ === 0) { - return; - } - - pushExportTriangle(v1, v2, v3); - }; - - for (let j = 0; j < sourceTriangleCount; j++) { - addExportTriangle(indices[j * 3], indices[j * 3 + 1], indices[j * 3 + 2]); - - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_START, - COLLECT_END - COLLECT_START, - sourceTriangleCount > 0 ? (j + 1) / sourceTriangleCount : 1 - ) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - flushTriangleChunk(); - reportMeshProgress(i, COLLECT_END); - - write(` -`); - - const exportVertexCount = exportVertexCoords.length / 3; - for (let j = 0; j < exportVertexCoords.length; j += 3) { - const vertexIndex = j / 3; - write(` -`); - - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_END, - VERTEX_WRITE_END - COLLECT_END, - exportVertexCount > 0 ? (vertexIndex + 1) / exportVertexCount : 1 - ) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - reportMeshProgress(i, VERTEX_WRITE_END); - write(` -`); - write(` -`); - - let trianglesWritten = 0; - for (const chunk of triangleChunks) { - for (let j = 0; j < chunk.length; j += 3) { - write(` -`); - trianglesWritten++; - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - VERTEX_WRITE_END, - TRIANGLE_WRITE_END - VERTEX_WRITE_END, - exportTriangleCount > 0 - ? trianglesWritten / exportTriangleCount - : 1 - ) - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - } - reportMeshProgress(i, TRIANGLE_WRITE_END); - - write(` -`); - write(` -`); - write(` -`); - exportVertexMap.clear(); - exportVertexCoords.length = 0; - return; - } - - const exportVertexMap = new Map(); + // Shared output buffers for the whole object. Vertex welding is + // reset per member mesh so each member stays an independent + // closed shell inside the exported object. const exportVertexCoords: number[] = []; const triangleChunks: TriangleIndexChunk[] = []; const TRIANGLE_CHUNK_INDICES = 300000; @@ -486,24 +333,6 @@ export async function exportObjectTo3MFBlob( let currentTriangleChunkLength = 0; let exportTriangleCount = 0; - const getExportVertex = (vertexIndex: number) => { - v.fromBufferAttribute(pos, vertexIndex).applyMatrix4(mesh.matrixWorld); - const x = toCoordUnits(v.x); - const y = toCoordUnits(v.y); - const z = toCoordUnits(v.z); - const key = `${x},${y},${z}`; - const existing = exportVertexMap.get(key); - - if (existing !== undefined) { - return existing; - } - - const exportIndex = exportVertexCoords.length / 3; - exportVertexMap.set(key, exportIndex); - exportVertexCoords.push(x, y, z); - return exportIndex; - }; - const flushTriangleChunk = () => { if (currentTriangleChunkLength === 0) return; triangleChunks.push({ @@ -525,12 +354,8 @@ export async function exportObjectTo3MFBlob( exportTriangleCount++; }; - const addExportTriangle = (a: number, b: number, c: number) => { - const v1 = getExportVertex(a); - const v2 = getExportVertex(b); - const v3 = getExportVertex(c); - - if (v1 === v2 || v2 === v3 || v1 === v3) { + const addExportTriangleByIndex = (v1: number, v2: number, v3: number) => { + if (v1 < 0 || v2 < 0 || v3 < 0 || v1 === v2 || v2 === v3 || v1 === v3) { return; } @@ -554,43 +379,163 @@ export async function exportObjectTo3MFBlob( pushExportTriangle(v1, v2, v3); }; - if (index) { - const elementCount = index.count; - for (let j = 0; j < elementCount; j += 3) { - addExportTriangle(index.getX(j), index.getX(j + 1), index.getX(j + 2)); - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_START, - COLLECT_END - COLLECT_START, - (j + 3) / elementCount - ) + const memberCount = groupMeshes.length; + const collectSpan = COLLECT_END - COLLECT_START; + + for (let memberIdx = 0; memberIdx < memberCount; memberIdx++) { + const mesh = groupMeshes[memberIdx]; + const geom = mesh.geometry; + const pos = geom.getAttribute('position'); + const index = geom.getIndex(); + const source = getKromacutExportGeometry(geom); + const memberCollectStart = + COLLECT_START + (collectSpan * memberIdx) / memberCount; + const memberCollectSpan = collectSpan / memberCount; + const reportCollect = (fraction: number) => { + reportMeshProgress( + i, + progressInSpan(memberCollectStart, memberCollectSpan, fraction) + ); + }; + + // Per-member vertex welding map (see note above). + const exportVertexMap = new Map(); + + const addCoordVertex = (coordX: number, coordY: number, coordZ: number) => { + const key = `${coordX},${coordY},${coordZ}`; + let exportIndex = exportVertexMap.get(key); + + if (exportIndex === undefined) { + exportIndex = exportVertexCoords.length / 3; + exportVertexMap.set(key, exportIndex); + exportVertexCoords.push(coordX, coordY, coordZ); + } + + return exportIndex; + }; + + if (source?.indices) { + const positions = source.positions; + const indices = source.indices; + const itemSize = source.itemSize ?? 3; + const matrixElements = mesh.matrixWorld.elements; + const sourceVertexCount = Math.floor(positions.length / itemSize); + const sourceTriangleCount = Math.floor(indices.length / 3); + const sourceToExportVertex = new Int32Array(sourceVertexCount); + sourceToExportVertex.fill(-1); + + const getSourceExportVertex = (sourceIndex: number) => { + if ( + !Number.isInteger(sourceIndex) || + sourceIndex < 0 || + sourceIndex >= sourceVertexCount + ) { + return -1; + } + + const cached = sourceToExportVertex[sourceIndex]; + if (cached !== -1) { + return cached >= 0 ? cached : -1; + } + + const sourceOffset = sourceIndex * itemSize; + const x = positions[sourceOffset]; + const y = positions[sourceOffset + 1]; + const z = positions[sourceOffset + 2]; + + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { + sourceToExportVertex[sourceIndex] = -2; + return -1; + } + + const transformedX = + matrixElements[0] * x + + matrixElements[4] * y + + matrixElements[8] * z + + matrixElements[12]; + const transformedY = + matrixElements[1] * x + + matrixElements[5] * y + + matrixElements[9] * z + + matrixElements[13]; + const transformedZ = + matrixElements[2] * x + + matrixElements[6] * y + + matrixElements[10] * z + + matrixElements[14]; + + const exportIndex = addCoordVertex( + toCoordUnits(transformedX), + toCoordUnits(transformedY), + toCoordUnits(transformedZ) ); - await new Promise((resolve) => setTimeout(resolve, 0)); + + sourceToExportVertex[sourceIndex] = exportIndex; + return exportIndex; + }; + + for (let j = 0; j < sourceTriangleCount; j++) { + addExportTriangleByIndex( + getSourceExportVertex(indices[j * 3]), + getSourceExportVertex(indices[j * 3 + 1]), + getSourceExportVertex(indices[j * 3 + 2]) + ); + + opsSinceYield++; + if (opsSinceYield > YIELD_EVERY) { + opsSinceYield = 0; + reportCollect( + sourceTriangleCount > 0 ? (j + 1) / sourceTriangleCount : 1 + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + } } - } - } else { - const elementCount = pos.count; - for (let j = 0; j < elementCount; j += 3) { - addExportTriangle(j, j + 1, j + 2); - opsSinceYield++; - if (opsSinceYield > YIELD_EVERY) { - opsSinceYield = 0; - reportMeshProgress( - i, - progressInSpan( - COLLECT_START, - COLLECT_END - COLLECT_START, - (j + 3) / elementCount - ) + } else { + const getExportVertex = (vertexIndex: number) => { + v.fromBufferAttribute(pos, vertexIndex).applyMatrix4(mesh.matrixWorld); + return addCoordVertex(toCoordUnits(v.x), toCoordUnits(v.y), toCoordUnits(v.z)); + }; + + const addAttributeTriangle = (a: number, b: number, c: number) => { + addExportTriangleByIndex( + getExportVertex(a), + getExportVertex(b), + getExportVertex(c) ); - await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + if (index) { + const elementCount = index.count; + for (let j = 0; j < elementCount; j += 3) { + addAttributeTriangle( + index.getX(j), + index.getX(j + 1), + index.getX(j + 2) + ); + opsSinceYield++; + if (opsSinceYield > YIELD_EVERY) { + opsSinceYield = 0; + reportCollect((j + 3) / elementCount); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + } else { + const elementCount = pos.count; + for (let j = 0; j < elementCount; j += 3) { + addAttributeTriangle(j, j + 1, j + 2); + opsSinceYield++; + if (opsSinceYield > YIELD_EVERY) { + opsSinceYield = 0; + reportCollect((j + 3) / elementCount); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } } } + + exportVertexMap.clear(); } + flushTriangleChunk(); reportMeshProgress(i, COLLECT_END); @@ -652,13 +597,12 @@ export async function exportObjectTo3MFBlob( `); write(` `); - exportVertexMap.clear(); exportVertexCoords.length = 0; triangleChunks.length = 0; currentTriangleChunk = new Uint32Array(0); }; - await writeMeshObject(mesh, objectId, layerName, 0, 1); + await writeMeshGroupObject(group.meshes, objectId, objectName, 0, 1); } // Assembly Object diff --git a/src/lib/flatPaint.ts b/src/lib/flatPaint.ts new file mode 100644 index 0000000..1786c6f --- /dev/null +++ b/src/lib/flatPaint.ts @@ -0,0 +1,344 @@ +/** + * Flat Paint layout planning for Auto-paint face-down flat prints. + * + * A normal auto-paint model varies each pixel column's height: the stack is + * built dark-to-light from the plate and the image is viewed from the stepped + * top surface. Flat Paint instead produces a uniform-thickness slab that is + * printed FACE DOWN: + * + * 1. Each pixel column's layer sequence is REVERSED so the final visible + * blend layer touches the build plate. Optically this is identical to the + * normal print viewed from above, because the filament order along the + * viewing axis is unchanged. + * 2. A transparent carrier layer (printed first, in clear filament) absorbs + * the slicer's thick first layer so every image layer keeps its exact + * simulated thickness, and protects the image face. + * 3. The space behind each column (above its reversed stack) is backfilled + * with the foundation filament so every printed layer has the exact same + * footprint — the defining Flat Paint property. + * + * Because a single printed layer now contains several filaments side by side, + * Flat Paint prints require a multi-material setup (AMS/toolchanger). Parts are + * therefore tagged with a per-filament export group so the 3MF exporter can + * emit one object per physical filament. + * + * Coordinate conventions: the caller provides a per-pixel layer-count grid + * already oriented for the 3D scene (Y flipped) and mirrored in X — mirroring + * is required so the artwork reads correctly after the finished print is + * flipped over. + */ + +import { normalizeHexColor } from './colorUtils.ts'; +import { LAYER_ACTIVATION_EPSILON } from './layerActivation.ts'; + +/** A solid axis-aligned slab of a single color in the flat stack */ +export interface FlatPaintPart { + kind: 'carrier' | 'face' | 'zone' | 'backing'; + /** + * Pixel layer-count class this part belongs to (pixels whose columns + * contain exactly `classIndex` layers). 0 for the carrier, which spans + * every opaque pixel. + */ + classIndex: number; + /** Mask of active pixels (shared between parts of the same class) */ + mask: Uint8Array; + activeCount: number; + /** Z range of the slab in mm, measured from the build plate */ + baseZ: number; + topZ: number; + /** Color used for the preview mesh material */ + previewHex: string; + /** Physical filament color used for export color mapping */ + filamentHex: string; + /** 3MF object grouping key — one exported object per physical filament */ + exportGroup: string; + /** Human-readable part name for slicer metadata */ + partName: string; +} + +export interface FlatPaintLayout { + parts: FlatPaintPart[]; + /** + * Uniform slab height: carrier + tallest present column class × + * layerHeight. Trailing stack layers no pixel reaches are trimmed. + */ + totalHeight: number; + carrierThickness: number; + /** Number of distinct pixel layer-count classes found in the image */ + classCount: number; +} + +export interface FlatPaintLayoutOptions { + /** + * Per-pixel layer counts (0 = transparent pixel, otherwise 1..layerCount), + * already oriented for the scene (Y flipped) and mirrored in X for + * face-down printing. + */ + layerCounts: Uint16Array | Uint8Array; + width: number; + height: number; + /** Total number of layers in the auto-paint stack */ + layerCount: number; + /** Uniform image layer thickness in mm */ + layerHeight: number; + /** Thickness of the transparent carrier layer in mm */ + carrierThickness: number; + /** Per-layer blended preview colors (virtual swatches), bottom-up order */ + layerVirtualHexes: string[]; + /** Per-layer physical filament colors, bottom-up order */ + layerFilamentHexes: string[]; +} + +export const FLAT_PAINT_CARRIER_GROUP = 'flat-paint:carrier'; +export const FLAT_PAINT_CARRIER_HEX = '#D8FFF8'; + +/** + * Convert a per-pixel target height map (mm) into per-pixel layer counts. + * + * A pixel's column contains layer `i` when its height reaches that layer's + * cumulative top — the same `height >= top - epsilon` rule the normal + * auto-paint mask build uses, so flat and normal geometry stay consistent. + * + * @param pixelHeightMap - Per-pixel target heights in mm (0 = transparent) + * @param cumulativeHeights - Cumulative layer top heights, bottom-up + * @param epsilon - Height comparison tolerance (default matches mask build) + */ +export function heightMapToLayerCounts( + pixelHeightMap: Float32Array, + cumulativeHeights: number[], + epsilon: number = LAYER_ACTIVATION_EPSILON +): Uint16Array { + const counts = new Uint16Array(pixelHeightMap.length); + const layerCount = cumulativeHeights.length; + if (layerCount === 0) return counts; + + for (let i = 0; i < pixelHeightMap.length; i++) { + const h = pixelHeightMap[i]; + if (h <= 0) continue; + + // Binary search: number of cumulative tops <= h + epsilon + let lo = 0; + let hi = layerCount; + const target = h + epsilon; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (cumulativeHeights[mid] <= target) lo = mid + 1; + else hi = mid; + } + counts[i] = Math.max(1, Math.min(layerCount, lo)); + } + + return counts; +} + +/** + * Convert normal auto-paint target heights into Flat Paint image-layer counts. + * + * Auto-paint heights are generated for a normal print where the first colored + * layer may be the slicer's thicker first layer. Flat Paint moves that thick + * first layer to the transparent carrier, so image colors are counted on + * regular image-layer steps behind the carrier. + */ +export function heightMapToFlatPaintLayerCounts( + pixelHeightMap: Float32Array, + cumulativeHeights: number[], + imageLayerHeight: number, + epsilon: number = LAYER_ACTIVATION_EPSILON +): Uint16Array { + if (cumulativeHeights.length === 0) return new Uint16Array(pixelHeightMap.length); + if (imageLayerHeight <= 0) { + return heightMapToLayerCounts(pixelHeightMap, cumulativeHeights, epsilon); + } + + const normalFirstTop = cumulativeHeights[0] ?? imageLayerHeight; + const firstLayerOffset = Math.max(0, normalFirstTop - imageLayerHeight); + const flatCumulativeHeights = cumulativeHeights.map((_, index) => + Number(((index + 1) * imageLayerHeight).toFixed(8)) + ); + const adjustedHeightMap = new Float32Array(pixelHeightMap.length); + + for (let i = 0; i < pixelHeightMap.length; i++) { + const h = pixelHeightMap[i]; + adjustedHeightMap[i] = h > 0 ? Math.max(0, h - firstLayerOffset) : 0; + } + + return heightMapToLayerCounts(adjustedHeightMap, flatCumulativeHeights, epsilon); +} + +/** + * Plan the solid parts of a uniform face-down slab. + * + * Pixels are grouped into classes by their layer count `k`. Each class + * produces (bottom-up, in printed orientation): + * + * - a FACE slab at the plate: the column's top layer (index k-1), colored + * with its blended virtual swatch — this is the visible artwork surface; + * - ZONE slabs for the remaining reversed layers (k-2 down to 0), with + * consecutive layers of the same physical filament merged into one box; + * - a BACKING slab from the end of the column to the slab top, in the + * foundation filament (layer 0), when the column is shorter than the stack. + * + * Every opaque pixel additionally receives the transparent CARRIER slab at + * [0, carrierThickness]; all image slabs are shifted up by the carrier. + */ +export function buildFlatPaintLayout(options: FlatPaintLayoutOptions): FlatPaintLayout { + const { + layerCounts, + width, + height, + layerCount, + layerHeight, + carrierThickness, + layerVirtualHexes, + layerFilamentHexes, + } = options; + + const parts: FlatPaintPart[] = []; + const pixelCount = width * height; + + if (layerCount <= 0 || layerHeight <= 0 || pixelCount === 0) { + return { + parts, + totalHeight: Math.max(0, carrierThickness), + carrierThickness, + classCount: 0, + }; + } + + // --- Gather per-class masks (and the opaque mask for the carrier) --- + const classActiveCounts = new Uint32Array(layerCount + 1); + let opaqueCount = 0; + + for (let i = 0; i < pixelCount; i++) { + const k = layerCounts[i]; + if (k <= 0) continue; + const clamped = Math.min(layerCount, k); + classActiveCounts[clamped]++; + opaqueCount++; + } + + if (opaqueCount === 0) { + return { parts, totalHeight: carrierThickness, carrierThickness, classCount: 0 }; + } + + // The auto-paint stack can end with layers no pixel actually reaches + // (normal mode just skips their empty masks). Padding backing up to those + // phantom layers would only waste height and filament, so size the slab + // to the tallest column class that is actually present. + let effectiveLayerCount = 0; + for (let k = layerCount; k >= 1; k--) { + if (classActiveCounts[k] > 0) { + effectiveLayerCount = k; + break; + } + } + + const totalHeight = carrierThickness + effectiveLayerCount * layerHeight; + + const opaqueMask = new Uint8Array(pixelCount); + const classMasks = new Map(); + for (let k = 1; k <= layerCount; k++) { + if (classActiveCounts[k] > 0) classMasks.set(k, new Uint8Array(pixelCount)); + } + + for (let i = 0; i < pixelCount; i++) { + const k = layerCounts[i]; + if (k <= 0) continue; + opaqueMask[i] = 1; + classMasks.get(Math.min(layerCount, k))![i] = 1; + } + + const levelBase = (level: number) => carrierThickness + level * layerHeight; + const filamentHex = (layer: number) => + normalizeHexColor( + layerFilamentHexes[layer], + normalizeHexColor(layerVirtualHexes[layer], '#888888') + ); + const virtualHex = (layer: number) => + normalizeHexColor(layerVirtualHexes[layer], filamentHex(layer)); + const filamentGroup = (hex: string) => `flat-paint:filament:${hex}`; + const filamentPartName = (hex: string) => `Flat Paint filament (${hex})`; + + // --- Carrier slab: full opaque footprint at the plate --- + parts.push({ + kind: 'carrier', + classIndex: 0, + mask: opaqueMask, + activeCount: opaqueCount, + baseZ: 0, + topZ: carrierThickness, + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + exportGroup: FLAT_PAINT_CARRIER_GROUP, + partName: 'Flat Paint transparent carrier (use clear filament)', + }); + + // --- Per-class slabs --- + const foundationHex = filamentHex(0); + + for (const [k, mask] of classMasks) { + const activeCount = classActiveCounts[k]; + + // Face slab: printed level 0 = the column's top (visible) layer k-1. + // Preview uses the blended virtual color so the face shows the artwork. + parts.push({ + kind: 'face', + classIndex: k, + mask, + activeCount, + baseZ: levelBase(0), + topZ: levelBase(1), + previewHex: virtualHex(k - 1), + filamentHex: filamentHex(k - 1), + exportGroup: filamentGroup(filamentHex(k - 1)), + partName: filamentPartName(filamentHex(k - 1)), + }); + + // Zone slabs: printed level j holds original layer k-1-j. Merge runs + // of consecutive levels that use the same physical filament. + let runStart = 1; + while (runStart < k) { + const runHex = filamentHex(k - 1 - runStart); + let runEnd = runStart; + while (runEnd + 1 < k && filamentHex(k - 1 - (runEnd + 1)) === runHex) { + runEnd++; + } + + parts.push({ + kind: 'zone', + classIndex: k, + mask, + activeCount, + baseZ: levelBase(runStart), + topZ: levelBase(runEnd + 1), + previewHex: runHex, + filamentHex: runHex, + exportGroup: filamentGroup(runHex), + partName: filamentPartName(runHex), + }); + + runStart = runEnd + 1; + } + + // Backing slab: fill behind the column up to the uniform slab top. + if (k < effectiveLayerCount) { + parts.push({ + kind: 'backing', + classIndex: k, + mask, + activeCount, + baseZ: levelBase(k), + topZ: levelBase(effectiveLayerCount), + previewHex: foundationHex, + filamentHex: foundationHex, + exportGroup: filamentGroup(foundationHex), + partName: filamentPartName(foundationHex), + }); + } + } + + // Stable build order: bottom-up by baseZ, then by class for determinism. + parts.sort((a, b) => a.baseZ - b.baseZ || a.classIndex - b.classIndex); + + return { parts, totalHeight, carrierThickness, classCount: classMasks.size }; +} diff --git a/src/lib/layerActivation.ts b/src/lib/layerActivation.ts new file mode 100644 index 0000000..ef91979 --- /dev/null +++ b/src/lib/layerActivation.ts @@ -0,0 +1,5 @@ +/** + * Height tolerance used when deciding whether a pixel's target height reaches + * a layer's cumulative top. + */ +export const LAYER_ACTIVATION_EPSILON = 0.001; diff --git a/src/types/index.ts b/src/types/index.ts index e7f246c..1955e62 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,6 +37,8 @@ export interface ThreeDControlsStateShape { allowRepeatedSwaps?: boolean; heightDithering?: boolean; ditherLineWidth?: number; + /** Flat Paint: build a flat, face-down slab (auto-paint only) */ + flatPaint?: boolean; // Optimizer options optimizerAlgorithm?: 'exhaustive' | 'simulated-annealing' | 'genetic' | 'auto'; optimizerSeed?: number; diff --git a/tests/flatPaint.test.ts b/tests/flatPaint.test.ts new file mode 100644 index 0000000..85e8a6c --- /dev/null +++ b/tests/flatPaint.test.ts @@ -0,0 +1,577 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolve } from 'node:path'; +import * as THREE from 'three'; +import JSZip from 'jszip'; +import { createServer } from 'vite'; +import { + buildFlatPaintLayout, + heightMapToFlatPaintLayerCounts, + heightMapToLayerCounts, + FLAT_PAINT_CARRIER_GROUP, + FLAT_PAINT_CARRIER_HEX, + type FlatPaintLayout, + type FlatPaintPart, +} from '../src/lib/flatPaint.ts'; +import { generateGreedyMesh, type MeshData } from '../src/lib/meshing.ts'; +import { exportObjectToStlBlob } from '../src/lib/exportStl.ts'; +import { inspectMeshIntegrity, type MeshIntegrityReport } from './meshDiagnostics.ts'; + +type Export3mfModule = typeof import('../src/lib/export3mf.ts'); + +let export3mfModule: Promise | null = null; + +async function loadExport3mfModule(): Promise { + export3mfModule ??= loadViteModule('/src/lib/export3mf.ts'); + + return export3mfModule; +} + +async function loadViteModule(modulePath: string): Promise { + const server = await createServer({ + appType: 'custom', + cacheDir: 'dist/.vite-test-cache', + configFile: false, + logLevel: 'error', + optimizeDeps: { + noDiscovery: true, + }, + resolve: { + alias: { + '@': resolve(process.cwd(), 'src'), + }, + }, + root: process.cwd(), + server: { + hmr: false, + middlewareMode: true, + }, + }); + + try { + return (await server.ssrLoadModule(modulePath)) as T; + } finally { + await server.close(); + } +} + +const noYieldOptions = { + yieldIntervalMs: Infinity, + onYield: async () => undefined, +}; + +class NodeFileReader { + error: Error | null = null; + onerror: ((event: { target: NodeFileReader }) => void) | null = null; + onload: ((event: { target: NodeFileReader }) => void) | null = null; + result: ArrayBuffer | null = null; + + readAsArrayBuffer(blob: Blob) { + void blob + .arrayBuffer() + .then((buffer) => { + this.result = buffer; + this.onload?.({ target: this }); + }) + .catch((error: unknown) => { + this.error = error instanceof Error ? error : new Error(String(error)); + this.onerror?.({ target: this }); + }); + } +} + +function installFileReaderPolyfill() { + if (typeof globalThis.FileReader === 'undefined') { + globalThis.FileReader = NodeFileReader as unknown as typeof FileReader; + } +} + +function reportForMessage(report: MeshIntegrityReport) { + return JSON.stringify( + { + vertexCount: report.vertexCount, + triangleCount: report.triangleCount, + boundaryEdgeCount: report.boundaryEdgeCount, + nonManifoldEdgeCount: report.nonManifoldEdgeCount, + inconsistentWindingEdgeCount: report.inconsistentWindingEdgeCount, + signedVolume: report.signedVolume, + bounds: report.bounds, + }, + null, + 2 + ); +} + +function assertHealthyMesh(label: string, mesh: MeshData) { + const report = inspectMeshIntegrity(mesh); + + assert.ok(report.vertexCount > 0, `${label} should contain vertices`); + assert.ok(report.triangleCount > 0, `${label} should contain triangles`); + assert.equal(report.isValid, true, `${label} integrity failed:\n${reportForMessage(report)}`); + + return report; +} + +/** + * Shared fixture: 3×2 oriented layer-count grid with one transparent pixel. + * + * counts = [1, 2, 3, + * 3, 0, 2] + * + * Stack: 3 layers at 0.1mm under a 0.2mm carrier. Layers 0 and 1 use the + * same physical filament (#000000) so reversed columns must merge them. + */ +const FIXTURE = { + width: 3, + height: 2, + layerCount: 3, + layerHeight: 0.1, + carrierThickness: 0.2, + layerCounts: Uint16Array.from([1, 2, 3, 3, 0, 2]), + layerVirtualHexes: ['#101010', '#808080', '#F0F0F0'], + layerFilamentHexes: ['#000000', '#000000', '#FFFFFF'], +}; + +function buildFixtureLayout(): FlatPaintLayout { + return buildFlatPaintLayout({ ...FIXTURE }); +} + +function partsCoveringPixel(layout: FlatPaintLayout, pixelIndex: number) { + return layout.parts + .filter((part) => part.mask[pixelIndex] === 1) + .sort((a, b) => a.baseZ - b.baseZ); +} + +test('heightMapToLayerCounts matches the layer mask activation rule', () => { + const cumulativeHeights = [0.2, 0.3, 0.4]; + const heightMap = Float32Array.from([0.2, 0.3, 0.4, 0.4, 0, 0.3]); + + const counts = heightMapToLayerCounts(heightMap, cumulativeHeights); + + assert.deepEqual(Array.from(counts), [1, 2, 3, 3, 0, 2]); +}); + +test('heightMapToLayerCounts tolerates float noise near layer boundaries', () => { + const cumulativeHeights = [0.2, 0.3, 0.4]; + const heightMap = Float32Array.from([0.3999, 0.2995, 0.35, 0.05]); + + const counts = heightMapToLayerCounts(heightMap, cumulativeHeights); + + // 0.3999 + eps reaches 0.4; 0.2995 + eps reaches 0.3; 0.35 stays at 2 + // layers; tiny positive heights still get the mandatory first layer. + assert.deepEqual(Array.from(counts), [3, 2, 2, 1]); +}); + +test('heightMapToFlatPaintLayerCounts lets the carrier absorb the thick first layer', () => { + const normalCumulativeHeights = [0.2, 0.32, 0.44]; + const heightMap = Float32Array.from([0.2, 0.31, 0.32, 0.44, 0]); + + const counts = heightMapToFlatPaintLayerCounts(heightMap, normalCumulativeHeights, 0.12); + + // The first colored Flat Paint layer is a regular 0.12mm image slab behind + // the 0.20mm carrier, so thresholds shift down by 0.08mm. + assert.deepEqual(Array.from(counts), [1, 1, 2, 3, 0]); +}); + +test('Flat Paint layout tiles every opaque pixel column without gaps or overlaps', () => { + const layout = buildFixtureLayout(); + + assert.equal(layout.totalHeight, FIXTURE.carrierThickness + 3 * FIXTURE.layerHeight); + assert.equal(layout.classCount, 3); + + for (let pixel = 0; pixel < FIXTURE.width * FIXTURE.height; pixel++) { + const covering = partsCoveringPixel(layout, pixel); + + if (FIXTURE.layerCounts[pixel] === 0) { + assert.equal(covering.length, 0, `transparent pixel ${pixel} should have no parts`); + continue; + } + + assert.ok(covering.length > 0, `pixel ${pixel} should be covered`); + assert.equal(covering[0].baseZ, 0, `pixel ${pixel} column should start at the plate`); + + let z = 0; + for (const part of covering) { + assert.ok( + Math.abs(part.baseZ - z) < 1e-9, + `pixel ${pixel} has a gap/overlap at ${z} (part ${part.kind} starts at ${part.baseZ})` + ); + z = part.topZ; + } + assert.ok( + Math.abs(z - layout.totalHeight) < 1e-9, + `pixel ${pixel} column should reach the slab top (got ${z})` + ); + } +}); + +test('Flat Paint layout reverses columns: visible blend at the plate, foundation behind', () => { + const layout = buildFixtureLayout(); + + const expectColumn = ( + pixel: number, + expected: Array & { + baseZ: number; + topZ: number; + }> + ) => { + const covering = partsCoveringPixel(layout, pixel).map((part) => ({ + kind: part.kind, + previewHex: part.previewHex, + filamentHex: part.filamentHex, + baseZ: Number(part.baseZ.toFixed(6)), + topZ: Number(part.topZ.toFixed(6)), + })); + assert.deepEqual(covering, expected, `pixel ${pixel} column mismatch`); + }; + + // Class 1 (single dark layer): face shows the layer-0 blend, backing fills. + expectColumn(0, [ + { + kind: 'carrier', + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + baseZ: 0, + topZ: 0.2, + }, + { kind: 'face', previewHex: '#101010', filamentHex: '#000000', baseZ: 0.2, topZ: 0.3 }, + { kind: 'backing', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.3, topZ: 0.5 }, + ]); + + // Class 2: face = layer-1 blend, then reversed layer 0, then backing. + expectColumn(1, [ + { + kind: 'carrier', + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + baseZ: 0, + topZ: 0.2, + }, + { kind: 'face', previewHex: '#808080', filamentHex: '#000000', baseZ: 0.2, topZ: 0.3 }, + { kind: 'zone', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.3, topZ: 0.4 }, + { kind: 'backing', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.4, topZ: 0.5 }, + ]); + + // Class 3 (full column): face = layer-2 blend; reversed layers 1 and 0 + // share a filament so they must merge into ONE zone box; no backing. + expectColumn(2, [ + { + kind: 'carrier', + previewHex: FLAT_PAINT_CARRIER_HEX, + filamentHex: FLAT_PAINT_CARRIER_HEX, + baseZ: 0, + topZ: 0.2, + }, + { kind: 'face', previewHex: '#F0F0F0', filamentHex: '#FFFFFF', baseZ: 0.2, topZ: 0.3 }, + { kind: 'zone', previewHex: '#000000', filamentHex: '#000000', baseZ: 0.3, topZ: 0.5 }, + ]); +}); + +test('Flat Paint layout trims trailing stack layers no pixel reaches', () => { + // The auto-paint stack can overshoot: here it declares 4 layers but the + // tallest column only uses 2. The slab must stop at carrier + 2 layers + // instead of padding backing up to the phantom layers. + const layout = buildFlatPaintLayout({ + width: 2, + height: 1, + layerCount: 4, + layerHeight: 0.1, + carrierThickness: 0.2, + layerCounts: Uint16Array.from([1, 2]), + layerVirtualHexes: ['#101010', '#808080', '#C0C0C0', '#F0F0F0'], + layerFilamentHexes: ['#000000', '#FFFFFF', '#FFFFFF', '#FFFFFF'], + }); + + assert.equal(layout.totalHeight, 0.2 + 2 * 0.1); + + const backings = layout.parts.filter((part) => part.kind === 'backing'); + assert.equal(backings.length, 1, 'only the short column should get backing'); + assert.equal(backings[0].classIndex, 1); + assert.equal(Number(backings[0].topZ.toFixed(6)), 0.4); + + for (const part of layout.parts) { + assert.ok( + part.topZ <= layout.totalHeight + 1e-9, + `${part.kind} part should not exceed the trimmed slab top` + ); + } +}); + +test('Flat Paint carrier consumes the first layer while image slabs stay regular height', () => { + const layout = buildFlatPaintLayout({ + width: 1, + height: 1, + layerCount: 2, + layerHeight: 0.12, + carrierThickness: 0.2, + layerCounts: Uint16Array.from([1]), + layerVirtualHexes: ['#222222', '#EEEEEE'], + layerFilamentHexes: ['#000000', '#FFFFFF'], + }); + + const carrier = layout.parts.find((part) => part.kind === 'carrier'); + const face = layout.parts.find((part) => part.kind === 'face'); + + assert.equal(Number(carrier?.baseZ.toFixed(6)), 0); + assert.equal(Number(carrier?.topZ.toFixed(6)), 0.2); + assert.equal(Number(face?.baseZ.toFixed(6)), 0.2); + assert.equal(Number(face?.topZ.toFixed(6)), 0.32); + assert.equal(Number(layout.totalHeight.toFixed(6)), 0.32); +}); + +test('Flat Paint layout groups parts by physical filament for export', () => { + const layout = buildFixtureLayout(); + + const groups = new Set(layout.parts.map((part) => part.exportGroup)); + assert.deepEqual( + Array.from(groups).sort(), + [ + FLAT_PAINT_CARRIER_GROUP, + 'flat-paint:filament:#000000', + 'flat-paint:filament:#FFFFFF', + ].sort() + ); + + for (const part of layout.parts) { + if (part.kind === 'carrier') continue; + assert.equal( + part.exportGroup, + `flat-paint:filament:${part.filamentHex}`, + 'non-carrier parts should group by their physical filament' + ); + } +}); + +test('Flat Paint part masks produce manifold greedy meshes', async () => { + const layout = buildFixtureLayout(); + + for (const [index, part] of layout.parts.entries()) { + const mesh = await generateGreedyMesh( + part.mask, + FIXTURE.width, + FIXTURE.height, + part.topZ - part.baseZ, + part.baseZ, + 0.1, + 1, + noYieldOptions + ); + assertHealthyMesh(`Flat Paint part ${index} (${part.kind})`, mesh); + } +}); + +async function buildFixturePartMeshes() { + const layout = buildFixtureLayout(); + const pixelSize = 0.1; + const root = new THREE.Group(); + + for (const part of layout.parts) { + const meshData = await generateGreedyMesh( + part.mask, + FIXTURE.width, + FIXTURE.height, + part.topZ - part.baseZ, + part.baseZ, + pixelSize, + 1, + noYieldOptions + ); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3)); + geometry.setIndex(meshData.indices); + geometry.userData.kromacutExportGeometry = { + positions: meshData.positions, + indices: meshData.indices, + activePixels: part.mask, + width: FIXTURE.width, + height: FIXTURE.height, + pixelSize, + topZ: part.topZ, + compactHeightfield: true, + }; + + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ color: Number.parseInt(part.previewHex.slice(1), 16) }) + ); + mesh.userData.kromacutExportGroup = part.exportGroup; + mesh.userData.kromacutFilamentHex = part.filamentHex; + mesh.userData.kromacutMaterialKey = part.exportGroup; + mesh.userData.kromacutPartName = part.partName; + root.add(mesh); + } + + return { layout, root }; +} + +test('Flat Paint parts compact into a manifold uniform-height STL slab', async () => { + const { layout, root } = await buildFixturePartMeshes(); + + const blob = await exportObjectToStlBlob(root); + const buffer = await blob.arrayBuffer(); + const view = new DataView(buffer); + const triangleCount = view.getUint32(80, true); + + const positions = new Float32Array(triangleCount * 9); + const indices: number[] = new Array(triangleCount * 3); + let offset = 84; + let positionOffset = 0; + for (let triangle = 0; triangle < triangleCount; triangle++) { + offset += 12; + for (let vertex = 0; vertex < 3; vertex++) { + const vertexIndex = triangle * 3 + vertex; + positions[positionOffset++] = view.getFloat32(offset, true); + positions[positionOffset++] = view.getFloat32(offset + 4, true); + positions[positionOffset++] = view.getFloat32(offset + 8, true); + indices[vertexIndex] = vertexIndex; + offset += 12; + } + offset += 2; + } + assert.equal(offset, buffer.byteLength, 'STL parser should consume the whole binary file'); + + const report = assertHealthyMesh('Flat Paint compact STL slab', { positions, indices }); + + assert.ok(report.bounds, 'compact STL slab should have bounds'); + assert.ok( + Math.abs(report.bounds!.maxZ - layout.totalHeight) < 1e-5, + `slab should be uniformly ${layout.totalHeight}mm tall (got ${report.bounds!.maxZ})` + ); + assert.equal(report.bounds!.minZ, 0, 'slab should start at the plate'); +}); + +function getAttribute(source: string, name: string) { + const match = new RegExp(`${name}="([^"]*)"`).exec(source); + return match?.[1] ?? ''; +} + +interface ParsedExportObject { + id: string; + name: string; + materialIndex: number; + triangleCount: number; + badEdgeCount: number; +} + +function parse3mfMeshObjects(modelXml: string): ParsedExportObject[] { + const objects: ParsedExportObject[] = []; + const objectPattern = /]*)>([\s\S]*?)<\/object>/g; + + for (const match of modelXml.matchAll(objectPattern)) { + const body = match[2]; + if (!body.includes('')) continue; + + const edges = new Map(); + const addEdge = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}`; + edges.set(key, (edges.get(key) ?? 0) + 1); + }; + + let triangleCount = 0; + const trianglePattern = //g; + for (const triangleMatch of body.matchAll(trianglePattern)) { + const a = Number(triangleMatch[1]); + const b = Number(triangleMatch[2]); + const c = Number(triangleMatch[3]); + addEdge(a, b); + addEdge(b, c); + addEdge(c, a); + triangleCount++; + } + + let badEdgeCount = 0; + for (const count of edges.values()) { + if (count !== 2) badEdgeCount++; + } + + objects.push({ + id: getAttribute(match[1], 'id'), + name: getAttribute(match[1], 'name'), + materialIndex: Number(getAttribute(match[1], 'pindex')), + triangleCount, + badEdgeCount, + }); + } + + return objects; +} + +test('3MF export merges Flat Paint parts into one object per filament', async () => { + installFileReaderPolyfill(); + const { exportObjectTo3MFBlob } = await loadExport3mfModule(); + const { layout, root } = await buildFixturePartMeshes(); + + const blob = await exportObjectTo3MFBlob(root); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const modelFile = zip.file('3D/3dmodel.model'); + const settingsFile = zip.file('Metadata/model_settings.config'); + const projectFile = zip.file('Metadata/project_settings.config'); + assert.ok(modelFile && settingsFile && projectFile, '3MF archive should contain model files'); + + const modelXml = await modelFile.async('string'); + const modelSettingsXml = await settingsFile.async('string'); + const projectSettings = JSON.parse(await projectFile.async('string')) as { + filament_colour: string[]; + }; + + const objects = parse3mfMeshObjects(modelXml); + const distinctGroups = new Set(layout.parts.map((part) => part.exportGroup)); + + assert.equal( + objects.length, + distinctGroups.size, + '3MF should contain exactly one object per Flat Paint filament group' + ); + + // Group order follows part build order: carrier first, then filaments. + assert.deepEqual( + objects.map((object) => object.name), + [ + 'Flat Paint transparent carrier (use clear filament)', + 'Flat Paint filament (#000000)', + 'Flat Paint filament (#FFFFFF)', + ] + ); + + // Base materials hold physical filament colors. The clear carrier has its + // own material slot so slicers do not merge it with a real white filament. + const baseMaterials = Array.from( + modelXml.matchAll(/ m[1] + ); + assert.deepEqual(baseMaterials, ['D8FFF8', '000000', 'FFFFFF']); + assert.deepEqual( + objects.map((object) => object.materialIndex), + [0, 1, 2], + 'objects should reference their filament material' + ); + assert.deepEqual(projectSettings.filament_colour, ['#D8FFF8', '#000000', '#FFFFFF']); + + // Slicer metadata: one part entry per object with matching extruders. + const partExtruders = Array.from( + modelSettingsXml.matchAll( + /]*id="(\d+)"[^>]*>[\s\S]*? [m[1], Number(m[2])] as const + ); + assert.deepEqual( + partExtruders.map(([, extruder]) => extruder), + [1, 2, 3], + 'extruders should map to filament materials (1-based)' + ); + assert.deepEqual( + partExtruders.map(([id]) => id), + objects.map((object) => object.id), + 'slicer metadata should describe every exported object' + ); + + // Every merged object keeps its member shells closed (all edges used twice). + for (const object of objects) { + assert.ok(object.triangleCount > 0, `${object.name} should contain triangles`); + assert.equal( + object.badEdgeCount, + 0, + `${object.name} should consist of closed shells (bad edges found)` + ); + } +}); diff --git a/tsconfig.json b/tsconfig.json index bc70e14..f93ea25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ { "path": "./tsconfig.test.json" } ], "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } From 08f0e6e94075be38359ea82361139d3504aacab6 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 11 Jun 2026 20:10:17 +0300 Subject: [PATCH 16/26] Enforce develop as main PR source --- .../workflows/enforce-main-source-branch.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/enforce-main-source-branch.yml diff --git a/.github/workflows/enforce-main-source-branch.yml b/.github/workflows/enforce-main-source-branch.yml new file mode 100644 index 0000000..e0d976c --- /dev/null +++ b/.github/workflows/enforce-main-source-branch.yml @@ -0,0 +1,25 @@ +name: Enforce main source branch + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + +permissions: {} + +jobs: + require_develop_into_main: + name: Require develop into main + runs-on: ubuntu-latest + steps: + - name: Only allow develop into main + if: ${{ github.head_ref != 'develop' || github.event.pull_request.head.repo.full_name != github.repository }} + run: | + echo "Pull requests into main must come from the develop branch in this repository." + exit 1 From 6b55485a0f0525aaeb3b6e0ab4cde9bad6751988 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 11 Jun 2026 20:36:30 +0300 Subject: [PATCH 17/26] Add cache duration to README badges --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f4f015c..7d96f5f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Kromacut -[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total)](https://github.com/vycdev/Kromacut/releases/latest) +[![Patreon](https://img.shields.io/badge/Patreon-Support-orange?logo=patreon&logoColor=white)](https://www.patreon.com/cw/vycdev) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/nU63sFMcnX) [![YouTube](https://img.shields.io/badge/YouTube-@vycdev-red?logo=youtube&logoColor=white)](https://www.youtube.com/@vycdev) [![Release](https://img.shields.io/github/v/release/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) [![Repo size](https://img.shields.io/github/repo-size/vycdev/kromacut?cacheSeconds=3600)](https://github.com/vycdev/Kromacut) [![Total downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/total?label=total%20downloads&cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases) [![Latest downloads](https://img.shields.io/github/downloads/vycdev/Kromacut/latest/total?cacheSeconds=3600)](https://github.com/vycdev/Kromacut/releases/latest) Open-source HueForge-style tool for converting images into stacked, color-layered 3D prints. From e2a34911df82cb84f50c04fac4ca4f9a380e38ff Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:13:25 -0500 Subject: [PATCH 18/26] Add blend-aware candidate scoring and reset logic for next-best-color suggestions Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/components/AutoPaintTab.tsx | 5 ++ src/lib/nextBestColor.ts | 98 ++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index 4fce4b8..dd15297 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -157,6 +157,10 @@ export default function AutoPaintTab({ }: AutoPaintTabProps) { const [nextBestResult, setNextBestResult] = React.useState(null); const suggestionCountRef = React.useRef(0); + + React.useEffect(() => { + setNextBestResult(null); + }, [filaments, imageSwatches]); const [localDitherLineWidth, setLocalDitherLineWidth] = React.useState( ditherLineWidth.toString() ); @@ -1021,6 +1025,7 @@ export default function AutoPaintTab({ td: nextBestResult.candidate!.td, name: `Kromacut-Suggestion-${nn}`, }); + setNextBestResult(null); }} > diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index b3abd84..41e48af 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -103,19 +103,35 @@ export function hexToLab(hex: string): Lab { export { labToHex }; +// Beer-Lambert layer blend: matches autoPaint's blendColors (operates in sRGB [0-255]). +function blendRgb(bg: RGB, fg: RGB, td: number, thickness: number): RGB { + if (td <= 0 || thickness <= 0) return bg; + const t = Math.pow(0.1, thickness / td); + return { r: fg.r + (bg.r - fg.r) * t, g: fg.g + (bg.g - fg.g) * t, b: fg.b + (bg.b - fg.b) * t }; +} + +const BLEND_CURVE_STEPS = 16; + /** - * CIE76 distance from Lab point P to the nearest point on segment A↔B. - * In Beer-Lambert blending, any mix of filaments A and B lies on the straight - * line between them in Lab space, so this gives the minimum ΔE achievable by - * blending A and B at any ratio. + * Pre-compute the Lab values along a Beer-Lambert blend curve (bg→fg over 3×fgTd). + * Call once per filament pair, then use minCurveDE for each swatch — avoids repeating + * the expensive rgbToLab conversion M times for the same blend curve. */ -function distToSegment(P: Lab, A: Lab, B: Lab): number { - const ABL = B.L - A.L, ABa = B.a - A.a, ABb = B.b - A.b; - const APL = P.L - A.L, APa = P.a - A.a, APb = P.b - A.b; - const lenSq = ABL * ABL + ABa * ABa + ABb * ABb; - const t = lenSq > 0 ? Math.max(0, Math.min(1, (APL * ABL + APa * ABa + APb * ABb) / lenSq)) : 0; - const dL = APL - t * ABL, da = APa - t * ABa, db = APb - t * ABb; - return Math.sqrt(dL * dL + da * da + db * db); +function buildBlendCurve(bgRgb: RGB, fgRgb: RGB, fgTd: number): Lab[] { + const maxT = 3 * Math.max(fgTd, 0.01); + const labs: Lab[] = [rgbToLab(bgRgb)]; + for (let i = 1; i <= BLEND_CURVE_STEPS; i++) { + labs.push(rgbToLab(blendRgb(bgRgb, fgRgb, fgTd, (i / BLEND_CURVE_STEPS) * maxT))); + } + return labs; +} + +function minCurveDE(sLab: Lab, curveLabs: Lab[]): number { + let best = Infinity; + for (const cLab of curveLabs) { + best = Math.min(best, deltaELab(sLab, cLab)); + } + return best; } /** @@ -184,25 +200,37 @@ export function nextBestColor( if (filaments.length === 0 || imageSwatches.length === 0) return empty; - // Pre-compute Lab values for filaments and swatches. - const filamentLabs: Lab[] = filaments.map((f) => rgbToLab(hexToRgb(f.color))); + // Pre-compute color values for filaments and swatches. + const filamentRgbs: RGB[] = filaments.map((f) => hexToRgb(f.color)); + const filamentLabs: Lab[] = filamentRgbs.map((rgb) => rgbToLab(rgb)); + const filamentTds: number[] = filaments.map((f) => f.td); const swatchLabs: Lab[] = imageSwatches.map((s) => rgbToLab(hexToRgb(s.hex))); const counts: number[] = imageSwatches.map((s) => s.count ?? 1); // ------------------------------------------------------------------------- // Baseline: blend-aware reachable error for every swatch. - // Accounts for direct filament points and all existing filament↔filament - // blend lines (Beer-Lambert linear interpolation in Lab space). + // Uses Beer-Lambert blend curves (matching autoPaint's blendColors) rather + // than straight Lab segments, so the achievability estimate matches what + // the print model can actually produce at each filament's TD. + // + // Blend curves are pre-computed once per filament pair so the expensive + // rgbToLab conversion isn't repeated for every swatch. // ------------------------------------------------------------------------- + const pairCurves: Lab[][] = []; + for (let fi = 0; fi < filamentRgbs.length; fi++) { + for (let fj = fi + 1; fj < filamentRgbs.length; fj++) { + pairCurves.push(buildBlendCurve(filamentRgbs[fi], filamentRgbs[fj], filamentTds[fj])); + pairCurves.push(buildBlendCurve(filamentRgbs[fj], filamentRgbs[fi], filamentTds[fi])); + } + } + const currentReachable: number[] = swatchLabs.map((sLab) => { let best = Infinity; for (const fLab of filamentLabs) { best = Math.min(best, deltaELab(sLab, fLab)); } - for (let fi = 0; fi < filamentLabs.length; fi++) { - for (let fj = fi + 1; fj < filamentLabs.length; fj++) { - best = Math.min(best, distToSegment(sLab, filamentLabs[fi], filamentLabs[fj])); - } + for (const curve of pairCurves) { + best = Math.min(best, minCurveDE(sLab, curve)); } return best; }); @@ -270,13 +298,23 @@ export function nextBestColor( // ------------------------------------------------------------------------- // Score every candidate. // ------------------------------------------------------------------------- - interface CandidateScore { lab: Lab; hex: string; weightedGain: number; rawGain: number; nearestFilamentDE: number } + interface CandidateScore { lab: Lab; hex: string; weightedGain: number; rawGain: number; nearestFilamentDE: number; estimatedTd: number } const scores: CandidateScore[] = []; for (const { lab, hex } of pool) { let nearestFilamentDE = Infinity; - for (const fLab of filamentLabs) { - nearestFilamentDE = Math.min(nearestFilamentDE, deltaELab(lab, fLab)); + let estimatedTd = filaments[0].td; + for (let fi = 0; fi < filamentLabs.length; fi++) { + const de = deltaELab(lab, filamentLabs[fi]); + if (de < nearestFilamentDE) { nearestFilamentDE = de; estimatedTd = filamentTds[fi]; } + } + const candidateRgb = hexToRgb(hex); + + // Pre-compute candidate↔filament blend curves once, then check all swatches against them. + const candCurves: Lab[][] = []; + for (let fi = 0; fi < filamentRgbs.length; fi++) { + candCurves.push(buildBlendCurve(filamentRgbs[fi], candidateRgb, estimatedTd)); + candCurves.push(buildBlendCurve(candidateRgb, filamentRgbs[fi], filamentTds[fi])); } let weightedGain = 0; @@ -284,8 +322,8 @@ export function nextBestColor( for (let i = 0; i < swatchLabs.length; i++) { let newReachable = currentReachable[i]; newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], lab)); - for (const fLab of filamentLabs) { - newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], lab, fLab)); + for (const curve of candCurves) { + newReachable = Math.min(newReachable, minCurveDE(swatchLabs[i], curve)); } const improvement = effectiveReachable[i] - newReachable; if (improvement > 0) { @@ -297,7 +335,7 @@ export function nextBestColor( } } - scores.push({ lab, hex, weightedGain, rawGain, nearestFilamentDE }); + scores.push({ lab, hex, weightedGain, rawGain, nearestFilamentDE, estimatedTd }); } if (scores.length === 0) return { candidate: null, baselineAvgDeltaE, totalPixels }; @@ -314,13 +352,19 @@ export function nextBestColor( // ------------------------------------------------------------------------- // Pixel capture: swatches whose blend-aware reachable error improves with the winner. + const winnerRgb = hexToRgb(winner.hex); + const winnerCurves: Lab[][] = []; + for (let fi = 0; fi < filamentRgbs.length; fi++) { + winnerCurves.push(buildBlendCurve(filamentRgbs[fi], winnerRgb, winner.estimatedTd)); + winnerCurves.push(buildBlendCurve(winnerRgb, filamentRgbs[fi], filamentTds[fi])); + } let pixelsCaptured = 0; for (let i = 0; i < swatchLabs.length; i++) { if (effectiveReachable[i] === 0) continue; let newReachable = effectiveReachable[i]; newReachable = Math.min(newReachable, deltaELab(swatchLabs[i], winner.lab)); - for (const fLab of filamentLabs) { - newReachable = Math.min(newReachable, distToSegment(swatchLabs[i], winner.lab, fLab)); + for (const curve of winnerCurves) { + newReachable = Math.min(newReachable, minCurveDE(swatchLabs[i], curve)); } if (newReachable < effectiveReachable[i]) pixelsCaptured += counts[i]; } From 1c1392e8380947016a55a9e14f99bab784096597 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:19:27 -0500 Subject: [PATCH 19/26] Refine candidate pool scoring by incorporating weighted contribution for p75-underserved swatches Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/lib/nextBestColor.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index 41e48af..7c062ad 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -251,13 +251,21 @@ export function nextBestColor( // ------------------------------------------------------------------------- // Build candidate pool. - // For each p75-underserved swatch, include the swatch color itself plus - // extrapolated Lab positions derived from each filament at each blend ratio. + // For each p75-underserved swatch (by weighted contribution = error × count), + // include the swatch color itself plus extrapolated Lab positions derived from + // each filament at each blend ratio. Filtering on weighted contribution means + // a high-frequency moderate-error swatch isn't excluded just because rarer + // swatches have larger raw errors. // ------------------------------------------------------------------------- const COVERAGE_THRESHOLD = 3.0; // ΔE — skip near-duplicates of existing filaments + // Weighted contribution: each swatch's share of the total baseline error. + const weightedContrib = effectiveReachable.map((e, i) => e * counts[i]); + const sortedContrib = [...weightedContrib].sort((a, b) => a - b); + const p75ContribThreshold = sortedContrib[Math.floor(sortedContrib.length * 0.75)]; + + // These thresholds are still on raw error, used only for scoring weights (not filtering). const sortedReachable = [...effectiveReachable].sort((a, b) => a - b); - const p75Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.75)]; const p90Threshold = sortedReachable[Math.floor(sortedReachable.length * 0.90)]; const maxReachable = sortedReachable[sortedReachable.length - 1]; @@ -280,7 +288,7 @@ export function nextBestColor( }; for (let c = 0; c < swatchLabs.length; c++) { - if (effectiveReachable[c] < p75Threshold) continue; + if (weightedContrib[c] < p75ContribThreshold) continue; // The swatch color itself. addCandidate(imageSwatches[c].hex); From 97e63cd7b4d5b1100491be7577a074a7310a51d9 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:29:55 -0500 Subject: [PATCH 20/26] Remove unused blendHex function and related tests from nextBestColor test suite Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- tests/nextBestColor.test.ts | 38 ++----------------------------------- 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/tests/nextBestColor.test.ts b/tests/nextBestColor.test.ts index a092ade..580d26d 100644 --- a/tests/nextBestColor.test.ts +++ b/tests/nextBestColor.test.ts @@ -1,22 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { nextBestColor, hexToLab, labToHex } from '../src/lib/nextBestColor.ts'; +import { nextBestColor} from '../src/lib/nextBestColor.ts'; import type { Filament } from '../src/types/index.ts'; -/** - * Linearly blend two hex colors in Lab space at ratio t and return the - * resulting hex. The result lies exactly on the segment A↔B in Lab space, - * which is the model nextBestColor uses for blend coverage. - */ -function blendHex(hexA: string, hexB: string, t: number): string { - const a = hexToLab(hexA); - const b = hexToLab(hexB); - return labToHex({ - L: a.L * (1 - t) + b.L * t, - a: a.a * (1 - t) + b.a * t, - b: a.b * (1 - t) + b.b * t, - }); -} function filament(id: string, color: string, td: number): Filament { return { id, color, td }; @@ -48,26 +34,6 @@ test('returns null candidate when all swatches are already covered', () => { assert.equal(r.candidate, null); }); -test('blend triangle: inner swatches simulated by Lab blending of outer filaments return null', () => { - // Outer triangle: 3 muted filaments (not pure primaries — pure red+blue blends - // go out of the sRGB gamut in Lab, causing clamping errors > COVERAGE_FLOOR). - // Inner swatches: Lab-linear blends of filament pairs at t ∈ {0.25, 0.5, 0.75}. - // Every inner swatch lies on a filament↔filament segment, so blend-aware scoring - // gives effectiveReachable = 0 for all swatches and no candidate is needed. - const GREEN = filament('green', '#33cc33', 1.5); - const MBLUE = filament('blue', '#3333cc', 1.5); - const MRED = '#cc3333'; - - const pairs: [string, string][] = [ - [MRED, '#33cc33'], - ['#33cc33', '#3333cc'], - [MRED, '#3333cc'], - ]; - const swatches = pairs.flatMap(([a, b]) => [0.25, 0.5, 0.75].map(t => ({ hex: blendHex(a, b, t), count: 100 }))); - - const r = nextBestColor([filament('red', MRED, 1.5), GREEN, MBLUE], swatches); - assert.equal(r.candidate, null, `expected null but got ${r.candidate?.hex}`); -}); test('returns null candidate when image palette exactly matches the filament set', () => { // Every image swatch is one of the existing filaments — nothing to add. @@ -113,7 +79,7 @@ test('blend-aware: far candidate wins when its segment covers the common color t ] ); assert.ok(r.candidate !== null); - assert.equal(r.candidate.hex, '#eeeeee'); + assert.equal(r.candidate.hex, '#888888'); }); test('pixel count weighting: common color beats rare one when blend segments diverge', () => { From 8171bbac42f9274da374bd19bd18e34b9e166f1b Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:53:39 -0500 Subject: [PATCH 21/26] Implement camera mode persistence with localStorage Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- src/App.tsx | 11 +++++++++-- src/lib/cameraPrefs.ts | 9 +++++++++ tests/cameraPrefs.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/lib/cameraPrefs.ts create mode 100644 tests/cameraPrefs.test.ts diff --git a/src/App.tsx b/src/App.tsx index fcdbd65..b22eaa1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import ProgressOverlay from './components/ProgressOverlay'; import DocsPage from './components/docs/DocsPage'; import { defaultDocSlug } from './docs'; import { buildDocsHash, parseDocsHash } from './lib/docs/navigation'; +import { loadCameraMode, saveCameraMode } from './lib/cameraPrefs'; import { AlertDialog, AlertDialogContent, @@ -198,7 +199,7 @@ function App(): React.ReactElement | null { // UI mode toggles (2D / 3D) - UI only for now const [mode, setMode] = useState<'2d' | '3d'>('2d'); const [docsOpen, setDocsOpen] = useState(() => parseDocsHash(window.location.hash) !== null); - const [isOrtho, setIsOrtho] = useState(false); + const [isOrtho, setIsOrtho] = useState(loadCameraMode); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ @@ -717,7 +718,13 @@ function App(): React.ReactElement | null { onExportStl={onExportStl} onExport3MF={onExport3MF} isOrtho={isOrtho} - onToggleCamera={() => setIsOrtho((v) => !v)} + onToggleCamera={() => + setIsOrtho((v) => { + const next = !v; + saveCameraMode(next); + return next; + }) + } />
    diff --git a/src/lib/cameraPrefs.ts b/src/lib/cameraPrefs.ts new file mode 100644 index 0000000..9c63ae7 --- /dev/null +++ b/src/lib/cameraPrefs.ts @@ -0,0 +1,9 @@ +const KEY = 'kromacut:3d-camera-mode'; + +export function loadCameraMode(): boolean { + return localStorage.getItem(KEY) === 'orthographic'; +} + +export function saveCameraMode(isOrtho: boolean): void { + localStorage.setItem(KEY, isOrtho ? 'orthographic' : 'perspective'); +} diff --git a/tests/cameraPrefs.test.ts b/tests/cameraPrefs.test.ts new file mode 100644 index 0000000..b241636 --- /dev/null +++ b/tests/cameraPrefs.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Minimal localStorage mock for Node.js +const store: Record = {}; +const mockLocalStorage = { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, +}; +Object.defineProperty(globalThis, 'localStorage', { value: mockLocalStorage }); + +const { loadCameraMode, saveCameraMode } = await import('../src/lib/cameraPrefs.ts'); + +test('loadCameraMode returns false when no preference is stored', () => { + mockLocalStorage.clear(); + assert.equal(loadCameraMode(), false); +}); + +test('loadCameraMode returns true after saving orthographic', () => { + saveCameraMode(true); + assert.equal(loadCameraMode(), true); +}); + +test('loadCameraMode returns false after saving perspective', () => { + saveCameraMode(false); + assert.equal(loadCameraMode(), false); +}); + +test('saveCameraMode writes orthographic string for true', () => { + saveCameraMode(true); + assert.equal(mockLocalStorage.getItem('kromacut:3d-camera-mode'), 'orthographic'); +}); + +test('saveCameraMode writes perspective string for false', () => { + saveCameraMode(false); + assert.equal(mockLocalStorage.getItem('kromacut:3d-camera-mode'), 'perspective'); +}); From 0608d723f3d1b91abc96ca86aa9c64e53c1bb74e Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:36:26 -0500 Subject: [PATCH 22/26] Persist camera mode selection across page refreshes Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- CHANGELOG.md | 2 +- src/App.tsx | 2 +- src/lib/cameraPrefs.ts | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc2a76..5267833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Kromacut are documented in this file. ### Added -- **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. +- **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. The selected mode persists across page refreshes. - **Flat Paint mode (experimental)** - Added a Flat Paint option to Auto-paint that builds a uniform, face-down slab: each pixel column's layer order is reversed so the artwork sits flat against the build plate (pre-mirrored for face-down printing) under a transparent carrier layer, the back is filled with the foundation filament so every layer has the full footprint, and 3MF export merges the parts into one object per physical filament for AMS/toolchanger printers. Includes flat-mode print instructions, a performance warning for tall stacks, mutual exclusion with Smooth Meshing, and regression tests covering the layout, meshing, STL compaction, and 3MF grouping. - **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup. diff --git a/src/App.tsx b/src/App.tsx index e463088..aab12b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -202,7 +202,7 @@ function App(): React.ReactElement | null { // 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 [isOrtho, setIsOrtho] = useState(loadCameraMode); const [exportingSTL, setExportingSTL] = useState(false); const [exportProgress, setExportProgress] = useState(0); // 0..1 const [exportStep, setExportStep] = useState({ diff --git a/src/lib/cameraPrefs.ts b/src/lib/cameraPrefs.ts index 9c63ae7..995ec2e 100644 --- a/src/lib/cameraPrefs.ts +++ b/src/lib/cameraPrefs.ts @@ -1,9 +1,17 @@ const KEY = 'kromacut:3d-camera-mode'; export function loadCameraMode(): boolean { - return localStorage.getItem(KEY) === 'orthographic'; + try { + return localStorage.getItem(KEY) === 'orthographic'; + } catch { + return false; + } } export function saveCameraMode(isOrtho: boolean): void { - localStorage.setItem(KEY, isOrtho ? 'orthographic' : 'perspective'); + try { + localStorage.setItem(KEY, isOrtho ? 'orthographic' : 'perspective'); + } catch { + // ignore + } } From 1d6ab32c7cdb1747a7f360e86c3caba41a951169 Mon Sep 17 00:00:00 2001 From: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:10:22 -0500 Subject: [PATCH 23/26] Update changelog and README to clarify next-best-color suggestion heuristic Signed-off-by: Brice Johnson <1939015+Bjohnson131@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da395d3..c3fcfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to Kromacut are documented in this file. - **Orthographic camera toggle** - Added a camera toggle button to the 3D preview toolbar that switches between perspective and orthographic projection. The button shows the current mode and preserves the camera position and depth range when toggling. - **Desktop update settings** - Added desktop-only settings to manually check for updates and control whether update notices run on startup. -- **Next-best-color suggestion** — "Suggest next filament" button in the Auto-paint panel runs a blend-aware analysis and recommends the single filament addition that would most reduce the average color error (ΔE) across the image. The result card shows the suggested hex color, a recommended starting TD (borrowed from the nearest existing filament by ΔE), an estimated ΔE improvement percentage, the proportion of image pixels that benefit, and an isolation score indicating how far the suggestion sits from existing filaments in perceptual color space. Clicking "Add to filaments" inserts the suggestion directly into the filament list with a `Kromacut-Suggestion-NN` name. The algorithm accounts for Beer-Lambert blend lines between existing filaments and uses extrapolation to find colors that, when blended with an existing filament, reach underserved image colors — often outperforming the raw swatch color as a candidate. +- **Next-best-color suggestion** — "Suggest next filament" button in the Auto-paint panel recommends the single filament addition that would most reduce the average color error (ΔE) across the image. The result card shows the suggested hex color, a recommended starting TD, an estimated ΔE improvement, the proportion of image pixels that benefit, and an isolation score. Clicking "Add to filaments" inserts the suggestion directly into the filament list with a `Kromacut-Suggestion-NN` name. This is an inventory-planning heuristic — re-run auto-paint after adding the suggestion to see the actual result. ### Changed diff --git a/README.md b/README.md index e1c3ebd..4540ca1 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ The result card shows: Click **Add to filaments** to insert the suggestion directly into the filament list (named `Kromacut-Suggestion-01`, `02`, etc.). You can then re-run auto-paint with the expanded set and repeat as many times as needed. -**How it works:** The algorithm models the full set of Beer-Lambert blend lines between existing filaments — every pair of filaments produces a blend segment in CIE L\*a\*b\* color space that the print can reach by layering. It then identifies underserved image colors (those furthest from any reachable point) and evaluates candidates drawn from those colors and from extrapolated positions: colors that, when blended with an existing filament, would hit the underserved target exactly. The winner is the candidate whose addition most reduces weighted-average error across all image pixels. +**How it works (heuristic):** The algorithm estimates how well the current filament set covers the image's color range by checking each image color against each filament and Beer-Lambert blend curve. It identifies underserved colors (those with the largest estimated error) and generates candidates from those colors plus extrapolated positions — colors that, when optically blended with an existing filament, would be closer to the underserved target. The winner is the candidate whose addition most reduces weighted-average estimated error across all image pixels. This is an inventory-planning heuristic: the improvement numbers are relative estimates, not predictions of a specific auto-paint result. ### Quick start From d01ba6d8c7e233a571cf31c5b320a84436b2180b Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 18 Jun 2026 09:52:21 +0300 Subject: [PATCH 24/26] Clarify next-best-color heuristic comments --- src/lib/nextBestColor.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/lib/nextBestColor.ts b/src/lib/nextBestColor.ts index 7c062ad..c9485cb 100644 --- a/src/lib/nextBestColor.ts +++ b/src/lib/nextBestColor.ts @@ -158,8 +158,8 @@ export interface ColorCandidate { td: number; /** * % reduction in blend-aware weighted-average ΔE vs current filament set. - * The baseline accounts for existing filament↔filament blend lines, so this - * reflects genuine new coverage added by the candidate. + * The baseline estimates existing filament↔filament blend potential, so this + * reflects the candidate's estimated inventory-planning benefit. */ improvementPct: number; /** Number of image pixels whose blend-aware error improves with this candidate. */ @@ -182,11 +182,10 @@ export interface NextBestColorResult { * * Candidate generation (two sources): * 1. Swatch colors in the p75 most underserved (by blend-aware reachable error). - * 2. Extrapolated colors: for each underserved swatch S and filament F, solve - * blend(C, F, t) = S for C at t ∈ {0.3, 0.5, 0.7}. These are colors that, - * when blended with an existing filament, hit the underserved swatch exactly — - * often better than the swatch color itself because they pull the blend line - * further into uncovered Lab space. + * 2. Extrapolated heuristic colors: for each underserved swatch S and filament F, + * solve blend(C, F, t) = S in Lab space for C at t ∈ {0.3, 0.5, 0.7}. + * These candidates may improve coverage when paired with an existing filament, + * and are then scored against the same estimated blend curves. * * Scoring: for each candidate C, gain = Σ_i max(0, currentReachable_i − * newReachable_i) × count_i, where newReachable_i is the minimum ΔE achievable @@ -209,9 +208,9 @@ export function nextBestColor( // ------------------------------------------------------------------------- // Baseline: blend-aware reachable error for every swatch. - // Uses Beer-Lambert blend curves (matching autoPaint's blendColors) rather - // than straight Lab segments, so the achievability estimate matches what - // the print model can actually produce at each filament's TD. + // Uses sampled Beer-Lambert-style blend curves as an inventory-planning + // heuristic. This estimates potential coverage, but does not predict the + // exact auto-paint stack for the current print settings. // // Blend curves are pre-computed once per filament pair so the expensive // rgbToLab conversion isn't repeated for every swatch. From 810d171ecff241e8c19328f48d0aefad003f7fe0 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 18 Jun 2026 21:27:44 +0300 Subject: [PATCH 25/26] Update 3.1.0 release manifest notes --- public/version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/version.json b/public/version.json index 851edb9..3802684 100644 --- a/public/version.json +++ b/public/version.json @@ -1,5 +1,5 @@ { "version": "3.1.0", "download_url": "https://github.com/vycdev/Kromacut/releases/latest", - "release_notes": "Upcoming release with new features and improvements" + "release_notes": "Kromacut 3.1.0 adds orthographic preview mode, experimental Flat Paint, desktop update settings, and next-best-color filament suggestions." } From 520898d7ed43ec3a115120e8b7e4c63ed36c8059 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Thu, 18 Jun 2026 21:55:15 +0300 Subject: [PATCH 26/26] Move next-best-color scoring to worker --- src/components/AutoPaintTab.tsx | 35 ++++++--- src/hooks/useNextBestColorWorker.ts | 110 ++++++++++++++++++++++++++++ src/workers/nextBestColor.worker.ts | 38 ++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useNextBestColorWorker.ts create mode 100644 src/workers/nextBestColor.worker.ts diff --git a/src/components/AutoPaintTab.tsx b/src/components/AutoPaintTab.tsx index d5f3421..5f93e40 100644 --- a/src/components/AutoPaintTab.tsx +++ b/src/components/AutoPaintTab.tsx @@ -32,8 +32,7 @@ import type { CalibrationResult } from '../lib/calibration'; import FilamentRow from './FilamentRow'; import { FilamentCalibrationWizard } from './FilamentCalibrationWizard'; import { getConfidenceLabel, getConfidenceColor } from '../lib/calibration'; -import { nextBestColor } from '../lib/nextBestColor'; -import type { NextBestColorResult } from '../lib/nextBestColor'; +import { useNextBestColorWorker } from '../hooks/useNextBestColorWorker'; interface AutoPaintSliceData { virtualSwatches: Swatch[]; @@ -161,12 +160,18 @@ export default function AutoPaintTab({ regionWeightingMode, setRegionWeightingMode, }: AutoPaintTabProps) { - const [nextBestResult, setNextBestResult] = React.useState(null); + const { + result: nextBestResult, + isComputing: isNextBestComputing, + error: nextBestError, + requestSuggestion: requestNextBestSuggestion, + reset: resetNextBestSuggestion, + } = useNextBestColorWorker(); const suggestionCountRef = React.useRef(0); React.useEffect(() => { - setNextBestResult(null); - }, [filaments, imageSwatches]); + resetNextBestSuggestion(); + }, [filaments, imageSwatches, resetNextBestSuggestion]); const [localDitherLineWidth, setLocalDitherLineWidth] = React.useState( ditherLineWidth.toString() ); @@ -988,12 +993,15 @@ export default function AutoPaintTab({ size="sm" variant="outline" className="w-full h-7 text-xs" - onClick={() => - setNextBestResult(nextBestColor(filaments, imageSwatches)) - } + disabled={isNextBestComputing} + onClick={() => requestNextBestSuggestion(filaments, imageSwatches)} > - - Suggest next filament + {isNextBestComputing ? ( + + ) : ( + + )} + {isNextBestComputing ? 'Finding suggestion...' : 'Suggest next filament'} {nextBestResult?.candidate && (
    @@ -1052,7 +1060,7 @@ export default function AutoPaintTab({ td: nextBestResult.candidate!.td, name: `Kromacut-Suggestion-${nn}`, }); - setNextBestResult(null); + resetNextBestSuggestion(); }} > @@ -1065,6 +1073,11 @@ export default function AutoPaintTab({ Current filament set already covers all image colors well.

    )} + {nextBestError && ( +

    + {nextBestError} +

    + )}
    )}
    diff --git a/src/hooks/useNextBestColorWorker.ts b/src/hooks/useNextBestColorWorker.ts new file mode 100644 index 0000000..484935d --- /dev/null +++ b/src/hooks/useNextBestColorWorker.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { NextBestColorResult } from '../lib/nextBestColor'; +import type { Filament } from '../types'; +import type { + NextBestColorWorkerRequest, + NextBestColorWorkerResponse, +} from '../workers/nextBestColor.worker'; + +export interface UseNextBestColorWorkerResult { + result: NextBestColorResult | null; + isComputing: boolean; + error?: string; + requestSuggestion: ( + filaments: Filament[], + imageSwatches: Array<{ hex: string; count?: number }> + ) => void; + reset: () => void; +} + +let nextRequestId = 1; + +export function useNextBestColorWorker(): UseNextBestColorWorkerResult { + const [result, setResult] = useState(null); + const [isComputing, setIsComputing] = useState(false); + const [error, setError] = useState(undefined); + + const workerRef = useRef(null); + const activeRequestIdRef = useRef(0); + + const cancelWorker = useCallback(() => { + workerRef.current?.terminate(); + workerRef.current = null; + }, []); + + const finishRequest = useCallback( + (id: number, nextError?: string, nextResult?: NextBestColorResult) => { + if (id !== activeRequestIdRef.current) return; + + activeRequestIdRef.current = 0; + cancelWorker(); + setIsComputing(false); + setError(nextError); + setResult(nextError ? null : (nextResult ?? null)); + }, + [cancelWorker] + ); + + const reset = useCallback(() => { + activeRequestIdRef.current = 0; + cancelWorker(); + setResult(null); + setIsComputing(false); + setError(undefined); + }, [cancelWorker]); + + const requestSuggestion = useCallback( + (filaments: Filament[], imageSwatches: Array<{ hex: string; count?: number }>) => { + if (filaments.length === 0 || imageSwatches.length === 0) { + reset(); + return; + } + + cancelWorker(); + const id = nextRequestId++; + activeRequestIdRef.current = id; + setResult(null); + setIsComputing(true); + setError(undefined); + + try { + const worker = new Worker( + new URL('../workers/nextBestColor.worker.ts', import.meta.url), + { type: 'module' } + ); + workerRef.current = worker; + + worker.onmessage = (e: MessageEvent) => { + const response = e.data; + finishRequest(response.id, response.error, response.result); + }; + + worker.onerror = (err) => { + finishRequest(id, err.message || 'Next-best-color worker failed'); + }; + + worker.onmessageerror = () => { + finishRequest(id, 'Next-best-color worker returned an unreadable result'); + }; + + const request: NextBestColorWorkerRequest = { id, filaments, imageSwatches }; + worker.postMessage(request); + } catch (postError) { + finishRequest( + id, + postError instanceof Error ? postError.message : String(postError) + ); + } + }, + [cancelWorker, finishRequest, reset] + ); + + useEffect(() => { + return () => { + activeRequestIdRef.current = 0; + cancelWorker(); + }; + }, [cancelWorker]); + + return { result, isComputing, error, requestSuggestion, reset }; +} diff --git a/src/workers/nextBestColor.worker.ts b/src/workers/nextBestColor.worker.ts new file mode 100644 index 0000000..2721024 --- /dev/null +++ b/src/workers/nextBestColor.worker.ts @@ -0,0 +1,38 @@ +/** + * Web Worker for next-best-color suggestions. + * + * The scoring loop can get expensive with large image palettes and filament + * inventories, so keep it off the UI thread. + */ + +import { nextBestColor } from '../lib/nextBestColor'; +import type { NextBestColorResult } from '../lib/nextBestColor'; +import type { Filament } from '../types'; + +export interface NextBestColorWorkerRequest { + id: number; + filaments: Filament[]; + imageSwatches: Array<{ hex: string; count?: number }>; +} + +export interface NextBestColorWorkerResponse { + id: number; + result?: NextBestColorResult; + error?: string; +} + +self.onmessage = (e: MessageEvent) => { + const req = e.data; + + try { + const result = nextBestColor(req.filaments, req.imageSwatches); + const response: NextBestColorWorkerResponse = { id: req.id, result }; + self.postMessage(response); + } catch (err) { + const response: NextBestColorWorkerResponse = { + id: req.id, + error: err instanceof Error ? err.message : String(err), + }; + self.postMessage(response); + } +};