diff --git a/src-frontend/package-lock.json b/src-frontend/package-lock.json index a8354ddb..9eeef18f 100644 --- a/src-frontend/package-lock.json +++ b/src-frontend/package-lock.json @@ -24,7 +24,8 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "tailwindcss": "^4.2.4", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" } }, "node_modules/@babel/code-frame": { @@ -838,6 +839,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -1140,6 +1148,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1207,6 +1233,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1247,6 +1386,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1338,6 +1487,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1423,6 +1582,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1618,6 +1784,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1628,6 +1804,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2284,6 +2470,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2354,6 +2551,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2524,6 +2728,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2534,6 +2745,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -2555,6 +2780,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2572,6 +2814,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2712,6 +2964,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2728,6 +3070,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/src-frontend/package.json b/src-frontend/package.json index 25af8d1a..d5ae49c8 100644 --- a/src-frontend/package.json +++ b/src-frontend/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint .", "preview": "vite preview" }, @@ -26,6 +28,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "tailwindcss": "^4.2.4", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" } } diff --git a/src-frontend/src/App.jsx b/src-frontend/src/App.jsx index 7dc63615..cdae5eaf 100644 --- a/src-frontend/src/App.jsx +++ b/src-frontend/src/App.jsx @@ -6,6 +6,7 @@ import PerfPanel from './components/PerfPanel.jsx' import Slider from './components/Slider.jsx' import { defaultPass, defaultGcodeConfig, PAPER_SIZES } from './store.js' import * as tauri from './hooks/useTauri.js' +import { serialize, deserialize } from './project.js' import { useFps } from './hooks/useFps.js' const VIEW_MODES = ['source', 'detection', 'contours', 'gcode'] @@ -13,7 +14,7 @@ const VIEW_MODES = ['source', 'detection', 'contours', 'gcode'] export default function App() { const [image, setImage] = useState(null) const [passes, setPasses] = useState([defaultPass(0)]) - const [activePass, setActivePass] = useState(0) + // Single pass — multi-pass is replaced by PenOutput nodes in the graph const [gcodeConfig, setGcodeConfig] = useState(defaultGcodeConfig()) const [viewMode, setViewMode] = useState('source') const [displayB64, setDisplayB64] = useState(null) // current image shown in viewport @@ -26,8 +27,25 @@ export default function App() { const fps = useFps() const [sidebarWidth, setSidebarWidth] = useState(320) + const [nodeWidth, setNodeWidth] = useState(450) + const [dpi, setDpi] = useState(150) + const [projectPath, setProjectPath] = useState(null) // null = unsaved const resizing = useRef(false) + // Ctrl+S / Ctrl+Shift+S — ref pattern keeps listener stable across renders + const saveProjectRef = useRef(null) + saveProjectRef.current = saveProject + useEffect(() => { + function onKey(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault() + saveProjectRef.current(e.shiftKey) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + // Long-task observer — fires whenever the JS main thread blocks > 50ms useEffect(() => { const obs = new PerformanceObserver(list => { @@ -58,13 +76,15 @@ export default function App() { window.addEventListener('mouseup', onUp) } - // Always-fresh ref so debounced callbacks never close over stale passes - const passesRef = useRef(passes) - const imageRef = useRef(image) - const activePasRef = useRef(activePass) - passesRef.current = passes - imageRef.current = image - activePasRef.current = activePass + // Always-fresh refs so debounced callbacks never close over stale state + const passesRef = useRef(passes) + const imageRef = useRef(image) + const dpiRef = useRef(dpi) + const gcodeConfigRef = useRef(gcodeConfig) + passesRef.current = passes + imageRef.current = image + dpiRef.current = dpi + gcodeConfigRef.current = gcodeConfig // Debounce timers: { 'idx-detection': timer, 'idx-fill': timer } const debounceTimers = useRef({}) @@ -84,13 +104,13 @@ export default function App() { setDisplayB64(image.preview_b64) break case 'detection': - setDisplayB64(passes[activePass]?.vizB64 ?? null) + setDisplayB64(passes[0]?.vizB64 ?? null) break case 'contours': - if (passes[activePass]?.hullCount > 0) { + if (passes[0]?.hullCount > 0) { try { const tv = performance.now() - const b64 = await tauri.getPassViz(activePass, viewMode) + const b64 = await tauri.getPassViz(0, viewMode) setPerfData(pd => ({ ...(pd ?? {}), js_viz: Math.round(performance.now() - tv) })) setDisplayB64(b64) } catch (e) { @@ -105,7 +125,7 @@ export default function App() { if (passes.some(p => p.strokeCount > 0)) { try { const tv = performance.now() - const b64 = await tauri.getGcodeViz(passes.map(p => p.penColor)) + const b64 = await tauri.getGcodeViz() setPerfData(pd => ({ ...(pd ?? {}), js_viz: Math.round(performance.now() - tv) })) setDisplayB64(b64) } catch (e) { @@ -119,7 +139,7 @@ export default function App() { } } refresh() - }, [viewMode, activePass, image, passes[activePass]?.vizB64, passes[activePass]?.hullCount, totalStrokeCount]) + }, [viewMode, image, passes[0]?.vizB64, passes[0]?.hullCount, totalStrokeCount]) // ── File open ────────────────────────────────────────────────────────────── async function openImage() { @@ -134,7 +154,7 @@ export default function App() { setViewMode('source') setStrokes(null) setGlobalStatus(`${info.width} × ${info.height}px`) - for (let i = 0; i < passesRef.current.length; i++) processPass(i, true) + processPass(0, true) } catch (e) { setGlobalStatus(`Error loading image: ${e}`) } @@ -147,14 +167,15 @@ export default function App() { if (!imageRef.current) return if (!silent) setBusy(true) const pass = passesRef.current[idx] - // Reset strokeCount NOW so the gcode viz useEffect sees 0 strokes and - // doesn't fire getGcodeViz while generateFill is about to start. + // Reset counts so viewport doesn't show stale data during reprocessing. updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 }) const t0 = performance.now() try { const result = await tauri.processPass({ pass_index: idx, graph: pass.graph, + dpi: dpiRef.current, + img_w_mm: gcodeConfigRef.current.img_w_mm, }) const js_process = Math.round(performance.now() - t0) setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process })) @@ -165,8 +186,7 @@ export default function App() { strokeCount: result.stroke_count, nodePreviews: result.node_previews ?? {}, }) - const colors = passesRef.current.map(p => p.penColor) - tauri.getAllStrokes(colors).then(s => setStrokes(s)).catch(() => {}) + tauri.getAllStrokes().then(s => setStrokes(s)).catch(() => {}) } catch (e) { updatePass(idx, { status: `Error: ${e}` }) setGlobalStatus(`Process error: ${e}`) @@ -175,31 +195,21 @@ export default function App() { }, []) // stable — uses refs // ── Debounced auto-reprocess triggered by slider changes ─────────────────── - const scheduleProcess = useCallback((idx) => { - const key = `${idx}-detect` - clearTimeout(debounceTimers.current[key]) - debounceTimers.current[key] = setTimeout(() => processPass(idx, true), 400) + const scheduleProcess = useCallback(() => { + clearTimeout(debounceTimers.current['detect']) + debounceTimers.current['detect'] = setTimeout(() => processPass(0, true), 400) }, [processPass]) - // ── Export ───────────────────────────────────────────────────────────────── - async function exportActivePass() { - const pass = passes[activePass] - const fname = `pass${activePass + 1}_${pass.label.replace(/\s+/g, '_')}.gcode` - const path = await tauri.pickSaveFile(fname) - if (!path) return - try { - await tauri.exportGcode(activePass, gcodeConfig) - setGlobalStatus(`Saved ${path}`) - } catch (e) { - setGlobalStatus(`Export error: ${e}`) - } - } + useEffect(() => { + if (imageRef.current) scheduleProcess() + }, [dpi, gcodeConfig.img_w_mm]) + // ── Export ───────────────────────────────────────────────────────────────── async function exportAll() { const dir = await tauri.pickFolder() if (!dir) return try { - const saved = await tauri.exportAllGcode(passes.map(p => p.penColor), gcodeConfig, dir) + const saved = await tauri.exportAllGcode(gcodeConfig, dir) setGlobalStatus(`Saved ${saved.length} file(s) to ${dir}`) } catch (e) { setGlobalStatus(`Export error: ${e}`) @@ -208,6 +218,78 @@ export default function App() { function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) } + // ── Project save ─────────────────────────────────────────────────────────── + async function saveProject(saveAs = false) { + let path = saveAs ? null : projectPath + if (!path) { + const suggested = image + ? image.path.replace(/\.[^.]+$/, '.trac3r').split('/').pop() + : 'project.trac3r' + path = await tauri.pickProjectSavePath(suggested) + if (!path) return + } + try { + const json = serialize({ + imagePath: image?.path ?? null, + dpi, + nodeWidth, + graph: passes[0].graph, + gcodeConfig, + }) + await tauri.writeProjectFile(path, json) + setProjectPath(path) + setGlobalStatus(`Saved: ${path.split('/').pop()}`) + } catch (e) { + setGlobalStatus(`Save error: ${e}`) + } + } + + // ── Project load ─────────────────────────────────────────────────────────── + async function loadProject() { + const path = await tauri.pickProjectOpenPath() + if (!path) return + setBusy(true) + try { + const json = await tauri.readProjectFile(path) + const restored = deserialize(json) + + // Apply non-image state immediately + if (restored.gcodeConfig) setGcodeConfig(restored.gcodeConfig) + if (restored.dpi) setDpi(restored.dpi) + if (restored.nodeWidth) setNodeWidth(restored.nodeWidth) + + // Replace the pass graph + if (restored.graph) { + setPasses([{ ...defaultPass(0), graph: restored.graph }]) + } + + setProjectPath(path) + setStrokes(null) + + // Load the image if the path is still valid + if (restored.imagePath) { + try { + const info = await tauri.loadImage(restored.imagePath) + setImage(info) + imageRef.current = info + setDisplayB64(info.preview_b64) + setViewMode('source') + setGlobalStatus(`Loaded: ${path.split('/').pop()}`) + processPass(0, true) + } catch { + setImage(null) + setDisplayB64(null) + setGlobalStatus(`Project loaded — image not found at: ${restored.imagePath}`) + } + } else { + setGlobalStatus(`Loaded: ${path.split('/').pop()}`) + } + } catch (e) { + setGlobalStatus(`Load error: ${e}`) + } + setBusy(false) + } + async function dumpDebugState() { try { const configs = passes.map(p => ({ @@ -230,16 +312,32 @@ export default function App() { {/* Toolbar */}
Graph
+Pipeline
+Paper
+Placement
+Plotter
+ +Export
+ +
diff --git a/src-frontend/src/components/Viewport.jsx b/src-frontend/src/components/Viewport.jsx
index b52badab..a95e00f3 100644
--- a/src-frontend/src/components/Viewport.jsx
+++ b/src-frontend/src/components/Viewport.jsx
@@ -19,8 +19,11 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
const { zoom, pan } = stateRef.current
const W = canvas.width
const H = canvas.height
- const iw = imgSize?.width ?? 512
- const ih = imgSize?.height ?? 512
+ // gcode/fill views use the scaled pipeline dimensions from strokes payload;
+ // all other views use the original loaded image dimensions.
+ const useStrokeDims = strokes && (viewMode === 'gcode' || viewMode === 'fill')
+ const iw = useStrokeDims ? (strokes.img_width ?? imgSize?.width ?? 512) : (imgSize?.width ?? 512)
+ const ih = useStrokeDims ? (strokes.img_height ?? imgSize?.height ?? 512) : (imgSize?.height ?? 512)
const fit = Math.min(W / iw, H / ih) * 0.92
const scale = fit * zoom
const ox = W / 2 - iw * scale / 2 + pan.x
@@ -64,7 +67,7 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
}
drawPaperOutline(ctx, iw, ih, scale, ox, oy)
} else {
- // All raster views (detection=JPEG, hulls/contours=SVG) go through ctx.drawImage
+ // All raster views (source=JPEG, detection=JPEG, contours=SVG) go through ctx.drawImage
// so that negative ox/oy when zoomed in are handled correctly by the canvas,
// not clipped by the parent's overflow:hidden.
if (svgImg) svgImg.style.display = 'none'
@@ -89,8 +92,8 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
ctx.fillStyle = '#1a1a1a'
ctx.fillRect(ox, oy, iw * scale, ih * scale)
}
- drawPaperOutline(ctx, iw, ih, scale, ox, oy)
}
+ drawPaperOutline(ctx, iw, ih, scale, ox, oy)
}
function drawPaperOutline(ctx, iw, ih, scale, ox, oy) {
@@ -133,11 +136,12 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
p.strokes.map(s => ({ color: p.color, points: s }))
)
- // Create offscreen canvas at 4× image resolution so zooming in stays sharp.
- // octx.scale(4,4) keeps all stroke coordinates in image-pixel space unchanged.
+ // Offscreen canvas sized to stroke coordinate space (pipeline dims, after DPI scaling).
+ const sw = strokes.img_width ?? imgSize.width
+ const sh = strokes.img_height ?? imgSize.height
const off = document.createElement('canvas')
- off.width = imgSize.width * 4
- off.height = imgSize.height * 4
+ off.width = sw * 4
+ off.height = sh * 4
const octx = off.getContext('2d')
octx.fillStyle = '#f5f0e8'
octx.fillRect(0, 0, off.width, off.height)
diff --git a/src-frontend/src/hooks/useTauri.js b/src-frontend/src/hooks/useTauri.js
index c852730f..6c239f4f 100644
--- a/src-frontend/src/hooks/useTauri.js
+++ b/src-frontend/src/hooks/useTauri.js
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
+import { FILE_EXT } from '../project.js'
// Wraps invoke with response-size logging so IPC payload bloat is visible in console
async function tracedInvoke(name, args) {
@@ -21,24 +22,20 @@ export async function processPass(payload) {
return tracedInvoke('process_pass', { payload })
}
-export async function getAllStrokes(passColors) {
- return tracedInvoke('get_all_strokes', { passColors })
+export async function getAllStrokes() {
+ return tracedInvoke('get_all_strokes', {})
}
-export async function getGcodeViz(passColors) {
- return tracedInvoke('get_gcode_viz', { passColors })
+export async function getGcodeViz() {
+ return tracedInvoke('get_gcode_viz', {})
}
export async function getPassViz(passIndex, mode) {
return tracedInvoke('get_pass_viz', { passIndex, mode })
}
-export async function exportGcode(passIndex, gcodeConfig) {
- return tracedInvoke('export_gcode', { passIndex, gcodeConfig })
-}
-
-export async function exportAllGcode(passColors, gcodeConfig, outDir) {
- return tracedInvoke('export_all_gcode', { passColors, gcodeConfig, outDir })
+export async function exportAllGcode(gcodeConfig, outDir) {
+ return tracedInvoke('export_all_gcode', { gcodeConfig, outDir })
}
export async function exportDebugState(passConfigs) {
@@ -64,3 +61,27 @@ export async function pickSaveFile(defaultName) {
export async function pickFolder() {
return openDialog({ directory: true })
}
+
+// ── Project file I/O ───────────────────────────────────────────────────────────
+
+export async function pickProjectOpenPath() {
+ return openDialog({
+ multiple: false,
+ filters: [{ name: 'Trac3r Project', extensions: [FILE_EXT] }],
+ })
+}
+
+export async function pickProjectSavePath(suggestedName) {
+ return saveDialog({
+ defaultPath: suggestedName ?? `project.${FILE_EXT}`,
+ filters: [{ name: 'Trac3r Project', extensions: [FILE_EXT] }],
+ })
+}
+
+export async function writeProjectFile(path, content) {
+ return invoke('write_project_file', { path, content })
+}
+
+export async function readProjectFile(path) {
+ return invoke('read_project_file', { path })
+}
diff --git a/src-frontend/src/project.js b/src-frontend/src/project.js
new file mode 100644
index 00000000..3956f853
--- /dev/null
+++ b/src-frontend/src/project.js
@@ -0,0 +1,78 @@
+// Project serialization — versioned JSON.
+//
+// Schema history:
+// v1: image_path, dpi, node_width, graph, gcode
+//
+// Adding a new version:
+// 1. Bump CURRENT_VERSION.
+// 2. Push a migrate(doc) function onto MIGRATIONS.
+// It receives the previous-version doc and returns the next-version doc.
+// Spread unknown fields (`...doc`) so future additions survive round-trips.
+
+import { bumpNodeSeq } from './store.js'
+
+export const CURRENT_VERSION = 1
+export const FILE_EXT = 'trac3r'
+
+// ── Migrations ─────────────────────────────────────────────────────────────────
+// migrations[n-1]: doc at version n → doc at version n+1
+const MIGRATIONS = [
+ // placeholder — v1 → v2 would go here
+]
+
+// ── Serialize ──────────────────────────────────────────────────────────────────
+export function serialize({ imagePath, dpi, nodeWidth, graph, gcodeConfig }) {
+ return JSON.stringify({
+ version: CURRENT_VERSION,
+ app: 'trac3r',
+ saved_at: new Date().toISOString(),
+ image_path: imagePath ?? null,
+ dpi,
+ node_width: nodeWidth,
+ graph,
+ gcode: gcodeConfig,
+ }, null, 2)
+}
+
+// ── Deserialize ────────────────────────────────────────────────────────────────
+// Returns { imagePath, dpi, nodeWidth, graph, gcodeConfig } or throws.
+// Unknown future-version files are loaded with a console warning (forward compat).
+//
+// The second argument is for testing only:
+// migrations — inject a custom migration chain
+// currentVersion — pretend the app is at this version (lets tests exercise
+// the migration engine without bumping the real constant)
+export function deserialize(json, { migrations: migs = MIGRATIONS, currentVersion: cv = CURRENT_VERSION } = {}) {
+ let doc
+ try { doc = JSON.parse(json) } catch {
+ throw new Error('Invalid project file (not valid JSON)')
+ }
+
+ if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) {
+ throw new Error('Not a Trac3r project file')
+ }
+ if (doc.app !== 'trac3r') throw new Error('Not a Trac3r project file')
+ if (typeof doc.version !== 'number') throw new Error('Missing version field')
+
+ let v = doc.version
+ while (v < cv) {
+ const migrate = migs[v - 1] // migs[n-1] upgrades version n → n+1
+ if (!migrate) throw new Error(`No migration from version ${v} (app too old?)`)
+ doc = migrate(doc)
+ v++
+ }
+ if (v > cv) {
+ console.warn(`[project] file is version ${v}, app knows version ${cv} — loading anyway`)
+ }
+
+ // Advance node ID counter so new nodes added after load don't collide
+ if (doc.graph?.nodes) bumpNodeSeq(doc.graph.nodes)
+
+ return {
+ imagePath: doc.image_path ?? null,
+ dpi: doc.dpi ?? 150,
+ nodeWidth: doc.node_width ?? 450,
+ graph: doc.graph ?? null,
+ gcodeConfig: doc.gcode ?? null,
+ }
+}
diff --git a/src-frontend/src/project.test.js b/src-frontend/src/project.test.js
new file mode 100644
index 00000000..d6c501b9
--- /dev/null
+++ b/src-frontend/src/project.test.js
@@ -0,0 +1,605 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { serialize, deserialize, CURRENT_VERSION, FILE_EXT } from './project.js'
+import { newNodeId, bumpNodeSeq, _resetNodeSeq } from './store.js'
+
+// ── Fixtures ───────────────────────────────────────────────────────────────────
+
+const MINIMAL_GRAPH = {
+ nodes: [
+ { id: 'source', kind: 'Source', x: 60, y: 160 },
+ { id: 'kernel_1', kind: 'Kernel', x: 310, y: 100, kernel: 'Luminance' },
+ { id: 'hull_2', kind: 'Hull', x: 560, y: 160, threshold: 128 },
+ { id: 'fill_3', kind: 'Fill', x: 840, y: 160, strategy: 'hatch' },
+ { id: 'pen_4', kind: 'PenOutput', x: 1110, y: 160 },
+ ],
+ edges: [
+ { from: 'source', to: 'kernel_1', port: 0 },
+ { from: 'kernel_1', to: 'hull_2', port: 0 },
+ { from: 'hull_2', to: 'fill_3', port: 0 },
+ { from: 'fill_3', to: 'pen_4', port: 0 },
+ ],
+}
+
+const MINIMAL_GCODE = {
+ paper_w_mm: 594, paper_h_mm: 841,
+ img_w_mm: 540, offset_x_mm: 27, offset_y_mm: 27,
+ feed_draw: 1000, feed_travel: 3000,
+ pen_down: 'M3 S1000', pen_up: 'M5',
+}
+
+const FULL_STATE = {
+ imagePath: '/home/user/photos/test.jpg',
+ dpi: 300,
+ nodeWidth: 500,
+ graph: MINIMAL_GRAPH,
+ gcodeConfig: MINIMAL_GCODE,
+}
+
+// Build a v1 JSON doc string directly (bypassing serialize) so tests can control
+// every field independently and don't depend on serialize's implementation.
+function makeV1Doc(overrides = {}) {
+ return JSON.stringify({
+ version: 1,
+ app: 'trac3r',
+ saved_at: '2026-01-01T00:00:00.000Z',
+ image_path: '/some/image.jpg',
+ dpi: 150,
+ node_width: 450,
+ graph: MINIMAL_GRAPH,
+ gcode: MINIMAL_GCODE,
+ ...overrides,
+ })
+}
+
+// ── Setup ──────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ _resetNodeSeq()
+})
+
+// ── serialize ──────────────────────────────────────────────────────────────────
+
+describe('serialize', () => {
+ it('produces valid JSON', () => {
+ expect(() => JSON.parse(serialize(FULL_STATE))).not.toThrow()
+ })
+
+ it('sets version to CURRENT_VERSION', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.version).toBe(CURRENT_VERSION)
+ })
+
+ it('sets app discriminator to "trac3r"', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.app).toBe('trac3r')
+ })
+
+ it('includes a valid ISO 8601 saved_at timestamp', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.saved_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
+ expect(new Date(doc.saved_at).getTime()).not.toBeNaN()
+ })
+
+ it('includes image_path', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.image_path).toBe(FULL_STATE.imagePath)
+ })
+
+ it('serializes null imagePath as null', () => {
+ const doc = JSON.parse(serialize({ ...FULL_STATE, imagePath: null }))
+ expect(doc.image_path).toBeNull()
+ })
+
+ it('serializes undefined imagePath as null', () => {
+ const { imagePath: _, ...rest } = FULL_STATE
+ const doc = JSON.parse(serialize(rest))
+ expect(doc.image_path).toBeNull()
+ })
+
+ it('includes dpi', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.dpi).toBe(300)
+ })
+
+ it('includes node_width', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.node_width).toBe(500)
+ })
+
+ it('includes the full graph', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.graph.nodes).toHaveLength(MINIMAL_GRAPH.nodes.length)
+ expect(doc.graph.edges).toHaveLength(MINIMAL_GRAPH.edges.length)
+ })
+
+ it('includes gcode config', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.gcode.paper_w_mm).toBe(594)
+ expect(doc.gcode.pen_down).toBe('M3 S1000')
+ })
+
+ it('does NOT include runtime fields (vizB64, hullCount, etc.)', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.vizB64).toBeUndefined()
+ expect(doc.hullCount).toBeUndefined()
+ expect(doc.strokeCount).toBeUndefined()
+ expect(doc.nodePreviews).toBeUndefined()
+ })
+
+ it('output is pretty-printed (human-readable)', () => {
+ const json = serialize(FULL_STATE)
+ expect(json).toContain('\n')
+ })
+})
+
+// ── deserialize — happy path ───────────────────────────────────────────────────
+
+describe('deserialize — happy path', () => {
+ it('loads a well-formed v1 document', () => {
+ const result = deserialize(makeV1Doc())
+ expect(result.imagePath).toBe('/some/image.jpg')
+ expect(result.dpi).toBe(150)
+ expect(result.nodeWidth).toBe(450)
+ expect(result.graph.nodes).toHaveLength(MINIMAL_GRAPH.nodes.length)
+ expect(result.graph.edges).toHaveLength(MINIMAL_GRAPH.edges.length)
+ expect(result.gcodeConfig.paper_w_mm).toBe(594)
+ })
+
+ it('maps image_path → imagePath', () => {
+ const result = deserialize(makeV1Doc({ image_path: '/custom/path.png' }))
+ expect(result.imagePath).toBe('/custom/path.png')
+ })
+
+ it('maps node_width → nodeWidth', () => {
+ const result = deserialize(makeV1Doc({ node_width: 600 }))
+ expect(result.nodeWidth).toBe(600)
+ })
+
+ it('maps gcode → gcodeConfig', () => {
+ const result = deserialize(makeV1Doc())
+ expect(result.gcodeConfig).toEqual(MINIMAL_GCODE)
+ })
+
+ it('preserves all graph node fields', () => {
+ const result = deserialize(makeV1Doc())
+ const hull = result.graph.nodes.find(n => n.kind === 'Hull')
+ expect(hull.threshold).toBe(128)
+ })
+
+ it('preserves all graph edge fields', () => {
+ const result = deserialize(makeV1Doc())
+ expect(result.graph.edges[0]).toEqual({ from: 'source', to: 'kernel_1', port: 0 })
+ })
+})
+
+// ── deserialize — missing optional fields use defaults ─────────────────────────
+
+describe('deserialize — missing optional fields', () => {
+ it('defaults dpi to 150 when missing', () => {
+ const { dpi: _, ...doc } = JSON.parse(makeV1Doc())
+ const result = deserialize(JSON.stringify(doc))
+ expect(result.dpi).toBe(150)
+ })
+
+ it('defaults node_width to 450 when missing', () => {
+ const doc = JSON.parse(makeV1Doc())
+ delete doc.node_width
+ const result = deserialize(JSON.stringify(doc))
+ expect(result.nodeWidth).toBe(450)
+ })
+
+ it('defaults imagePath to null when image_path is missing', () => {
+ const doc = JSON.parse(makeV1Doc())
+ delete doc.image_path
+ const result = deserialize(JSON.stringify(doc))
+ expect(result.imagePath).toBeNull()
+ })
+
+ it('defaults imagePath to null when image_path is null', () => {
+ const result = deserialize(makeV1Doc({ image_path: null }))
+ expect(result.imagePath).toBeNull()
+ })
+
+ it('defaults graph to null when missing', () => {
+ const doc = JSON.parse(makeV1Doc())
+ delete doc.graph
+ const result = deserialize(JSON.stringify(doc))
+ expect(result.graph).toBeNull()
+ })
+
+ it('defaults gcodeConfig to null when gcode is missing', () => {
+ const doc = JSON.parse(makeV1Doc())
+ delete doc.gcode
+ const result = deserialize(JSON.stringify(doc))
+ expect(result.gcodeConfig).toBeNull()
+ })
+
+ it('applies all defaults simultaneously when all optional fields are absent', () => {
+ const minimalDoc = JSON.stringify({ version: 1, app: 'trac3r' })
+ const result = deserialize(minimalDoc)
+ expect(result).toEqual({
+ imagePath: null, dpi: 150, nodeWidth: 450, graph: null, gcodeConfig: null,
+ })
+ })
+})
+
+// ── deserialize — validation errors ───────────────────────────────────────────
+
+describe('deserialize — validation errors', () => {
+ it('throws on non-JSON input', () => {
+ expect(() => deserialize('not json {{{')).toThrow('Invalid project file (not valid JSON)')
+ })
+
+ it('throws on empty string', () => {
+ expect(() => deserialize('')).toThrow('Invalid project file (not valid JSON)')
+ })
+
+ it('throws on JSON null', () => {
+ expect(() => deserialize('null')).toThrow('Not a Trac3r project file')
+ })
+
+ it('throws on JSON array instead of object', () => {
+ expect(() => deserialize('[]')).toThrow('Not a Trac3r project file')
+ })
+
+ it('throws on JSON number', () => {
+ expect(() => deserialize('42')).toThrow('Not a Trac3r project file')
+ })
+
+ it('throws when app field is missing', () => {
+ const doc = JSON.parse(makeV1Doc())
+ delete doc.app
+ expect(() => deserialize(JSON.stringify(doc))).toThrow('Not a Trac3r project file')
+ })
+
+ it('throws when app field is a different value', () => {
+ expect(() => deserialize(makeV1Doc({ app: 'inkscape' }))).toThrow('Not a Trac3r project file')
+ })
+
+ it('throws when app field is empty string', () => {
+ expect(() => deserialize(makeV1Doc({ app: '' }))).toThrow('Not a Trac3r project file')
+ })
+
+ it('throws when version field is missing', () => {
+ const doc = JSON.parse(makeV1Doc())
+ delete doc.version
+ expect(() => deserialize(JSON.stringify(doc))).toThrow('Missing version field')
+ })
+
+ it('throws when version is a string', () => {
+ expect(() => deserialize(makeV1Doc({ version: '1' }))).toThrow('Missing version field')
+ })
+
+ it('throws when version is null', () => {
+ expect(() => deserialize(makeV1Doc({ version: null }))).toThrow('Missing version field')
+ })
+
+ it('throws when version is a float that is not an integer', () => {
+ // floats ARE numbers — the check is typeof === 'number', so 1.5 passes the
+ // type check but then fails in the migration loop (no migration from v1.5)
+ // OR if 1.5 < CURRENT_VERSION it tries to migrate from v1 and succeeds.
+ // The important thing: it doesn't silently corrupt the data.
+ // Document the actual behavior rather than asserting a specific error:
+ const result = deserialize(makeV1Doc({ version: 1.5 }))
+ // 1.5 > CURRENT_VERSION=1, so it warns and loads as-is
+ expect(result).toBeDefined()
+ })
+
+ it('throws when a v1 file targets v2 but no migration is defined', () => {
+ expect(() => deserialize(makeV1Doc({ version: 1 }), { migrations: [], currentVersion: 2 }))
+ .toThrow('No migration from version 1')
+ })
+})
+
+// ── deserialize — version handling ────────────────────────────────────────────
+
+describe('deserialize — version handling', () => {
+ it('loads v1 without running any migrations when CURRENT_VERSION is 1', () => {
+ const spy = vi.fn(d => d)
+ // Inject a migration that should NOT run (file is already at current version)
+ const result = deserialize(makeV1Doc(), { migrations: [spy] })
+ expect(spy).not.toHaveBeenCalled()
+ expect(result.dpi).toBe(150)
+ })
+
+ it('warns (not throws) when file version is ahead of the app', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const futureDoc = makeV1Doc({ version: CURRENT_VERSION + 5 })
+ expect(() => deserialize(futureDoc)).not.toThrow()
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('loading anyway'))
+ warnSpy.mockRestore()
+ })
+
+ it('future version warn message includes both file version and app version', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ deserialize(makeV1Doc({ version: 99 }))
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('99'))
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(String(CURRENT_VERSION)))
+ warnSpy.mockRestore()
+ })
+
+ // Migration tests inject `currentVersion: 2` to simulate "the app has been
+ // upgraded to v2 but the file on disk is still v1." migs[v-1] means
+ // migs[0] is the v1→v2 migration.
+
+ it('runs a single migration when upgrading from v1 to v2', () => {
+ const v1_to_v2 = vi.fn(doc => ({ ...doc, version: 2 }))
+ deserialize(makeV1Doc({ version: 1 }), { migrations: [v1_to_v2], currentVersion: 2 })
+ expect(v1_to_v2).toHaveBeenCalledTimes(1)
+ })
+
+ it('runs migrations in order for a multi-version upgrade chain (v1 → v2 → v3)', () => {
+ const callOrder = []
+ const v1_to_v2 = vi.fn(doc => { callOrder.push('1→2'); return { ...doc, version: 2 } })
+ const v2_to_v3 = vi.fn(doc => { callOrder.push('2→3'); return { ...doc, version: 3 } })
+ deserialize(makeV1Doc({ version: 1 }), { migrations: [v1_to_v2, v2_to_v3], currentVersion: 3 })
+ expect(callOrder).toEqual(['1→2', '2→3'])
+ })
+
+ it('each migration receives the output of the previous one (chaining)', () => {
+ const v1_to_v2 = doc => ({ ...doc, version: 2, marker: 'added_at_v2' })
+ const v2_to_v3 = vi.fn(doc => ({ ...doc, version: 3 }))
+ deserialize(makeV1Doc({ version: 1 }), { migrations: [v1_to_v2, v2_to_v3], currentVersion: 3 })
+ // v2_to_v3 should have received the output of v1_to_v2 (which added marker)
+ expect(v2_to_v3).toHaveBeenCalledWith(expect.objectContaining({ marker: 'added_at_v2', version: 2 }))
+ })
+
+ it('migration can transform field names (example: node_width renamed from card_width)', () => {
+ // Simulate a v1 schema that used "card_width" → v2 renames it to "node_width"
+ const oldDoc = JSON.stringify({ version: 1, app: 'trac3r', card_width: 300 })
+ const v1_to_v2 = doc => ({ ...doc, version: 2, node_width: doc.card_width ?? 450 })
+ const result = deserialize(oldDoc, { migrations: [v1_to_v2], currentVersion: 2 })
+ expect(result.nodeWidth).toBe(300)
+ })
+
+ it('migration can add a new required field with a default', () => {
+ const oldDoc = JSON.stringify({ version: 1, app: 'trac3r', dpi: 150 })
+ const v1_to_v2 = doc => ({ ...doc, version: 2, node_width: doc.node_width ?? 450 })
+ const result = deserialize(oldDoc, { migrations: [v1_to_v2], currentVersion: 2 })
+ expect(result.nodeWidth).toBe(450)
+ })
+
+ it('throws with a clear message when no migration is defined for the version gap', () => {
+ // App at v2 but MIGRATIONS is empty: no upgrade path from v1 → v2
+ expect(() => deserialize(makeV1Doc({ version: 1 }), { migrations: [], currentVersion: 2 }))
+ .toThrow(/No migration from version 1/)
+ })
+})
+
+// ── round-trip ─────────────────────────────────────────────────────────────────
+
+describe('round-trip: serialize → deserialize', () => {
+ it('round-trips the full state', () => {
+ const json = serialize(FULL_STATE)
+ const result = deserialize(json)
+ expect(result.imagePath).toBe(FULL_STATE.imagePath)
+ expect(result.dpi).toBe(FULL_STATE.dpi)
+ expect(result.nodeWidth).toBe(FULL_STATE.nodeWidth)
+ expect(result.gcodeConfig).toEqual(FULL_STATE.gcodeConfig)
+ })
+
+ it('round-trips graph nodes with all their fields', () => {
+ const json = serialize(FULL_STATE)
+ const result = deserialize(json)
+ expect(result.graph.nodes).toHaveLength(FULL_STATE.graph.nodes.length)
+ for (const original of FULL_STATE.graph.nodes) {
+ const restored = result.graph.nodes.find(n => n.id === original.id)
+ expect(restored).toEqual(original)
+ }
+ })
+
+ it('round-trips graph edges exactly', () => {
+ const json = serialize(FULL_STATE)
+ const result = deserialize(json)
+ expect(result.graph.edges).toEqual(FULL_STATE.graph.edges)
+ })
+
+ it('round-trips null imagePath', () => {
+ const json = serialize({ ...FULL_STATE, imagePath: null })
+ const result = deserialize(json)
+ expect(result.imagePath).toBeNull()
+ })
+
+ it('round-trips a graph with a Combine node and multiple input ports', () => {
+ const combineGraph = {
+ nodes: [
+ { id: 'source', kind: 'Source', x: 0, y: 0 },
+ { id: 'kernel_10', kind: 'Kernel', x: 200, y: 0, kernel: 'Sobel' },
+ { id: 'kernel_11', kind: 'Kernel', x: 200, y: 150, kernel: 'Canny' },
+ { id: 'combine_12', kind: 'Combine', x: 400, y: 75, blend_mode: 'Max', inputCount: 2 },
+ { id: 'hull_13', kind: 'Hull', x: 600, y: 75, threshold: 100 },
+ ],
+ edges: [
+ { from: 'source', to: 'kernel_10', port: 0 },
+ { from: 'source', to: 'kernel_11', port: 0 },
+ { from: 'kernel_10', to: 'combine_12', port: 0 },
+ { from: 'kernel_11', to: 'combine_12', port: 1 },
+ { from: 'combine_12', to: 'hull_13', port: 0 },
+ ],
+ }
+ const json = serialize({ ...FULL_STATE, graph: combineGraph })
+ const result = deserialize(json)
+ expect(result.graph.edges).toHaveLength(5)
+ const combineEdges = result.graph.edges.filter(e => e.to === 'combine_12')
+ expect(combineEdges.map(e => e.port).sort()).toEqual([0, 1])
+ })
+
+ it('round-trips ColorIsolate node with all color filter fields', () => {
+ const ciNode = {
+ id: 'kernel_20', kind: 'Kernel', x: 300, y: 100, kernel: 'ColorIsolate',
+ ci_color: '#e63946', ci_hue_tolerance: 25, ci_sat_min: 0.3, ci_val_min: 0.2,
+ color_filter: { enabled: true, hue_min: 10, hue_max: 60, sat_min: 0.3, sat_max: 1.0, val_min: 0.2, val_max: 1.0 },
+ }
+ const graph = { nodes: [{ id: 'source', kind: 'Source', x: 0, y: 0 }, ciNode], edges: [] }
+ const result = deserialize(serialize({ ...FULL_STATE, graph }))
+ const restored = result.graph.nodes.find(n => n.id === 'kernel_20')
+ expect(restored.ci_color).toBe('#e63946')
+ expect(restored.color_filter.hue_min).toBe(10)
+ })
+
+ it('round-trips XDoG kernel parameters', () => {
+ const xdogNode = {
+ id: 'kernel_30', kind: 'Kernel', x: 300, y: 100, kernel: 'XDoG',
+ blur_radius: 1.2, xdog_sigma2: 3.4, xdog_tau: 0.97, xdog_phi: 50.0,
+ }
+ const graph = { nodes: [{ id: 'source', kind: 'Source', x: 0, y: 0 }, xdogNode], edges: [] }
+ const result = deserialize(serialize({ ...FULL_STATE, graph }))
+ const restored = result.graph.nodes.find(n => n.id === 'kernel_30')
+ expect(restored.xdog_sigma2).toBe(3.4)
+ expect(restored.xdog_tau).toBe(0.97)
+ })
+})
+
+// ── forward compatibility: unknown fields ──────────────────────────────────────
+
+describe('forward compatibility — unknown fields', () => {
+ it('does not throw when the document has extra unknown top-level fields', () => {
+ const doc = makeV1Doc({ future_feature: { enabled: true, value: 42 } })
+ expect(() => deserialize(doc)).not.toThrow()
+ })
+
+ it('does not throw when a node has extra unknown fields', () => {
+ const graph = {
+ ...MINIMAL_GRAPH,
+ nodes: MINIMAL_GRAPH.nodes.map(n =>
+ n.kind === 'Hull' ? { ...n, future_hull_param: 99 } : n
+ ),
+ }
+ const json = makeV1Doc({ graph })
+ expect(() => deserialize(json)).not.toThrow()
+ })
+
+ it('unknown node fields survive in the loaded graph', () => {
+ const graph = {
+ ...MINIMAL_GRAPH,
+ nodes: MINIMAL_GRAPH.nodes.map(n =>
+ n.kind === 'Fill' ? { ...n, mystery_param: 'hello' } : n
+ ),
+ }
+ const result = deserialize(makeV1Doc({ graph }))
+ const fill = result.graph.nodes.find(n => n.kind === 'Fill')
+ expect(fill.mystery_param).toBe('hello')
+ })
+
+ it('a migration preserves unknown fields it does not know about', () => {
+ const oldDoc = JSON.stringify({
+ version: 1, app: 'trac3r', dpi: 100, future_field: 'keep me',
+ })
+ // Spread the whole doc so the migration doesn't drop unknown fields
+ const v1_to_v2 = doc => ({ ...doc, version: 2 })
+ expect(() => deserialize(oldDoc, { migrations: [v1_to_v2], currentVersion: 2 })).not.toThrow()
+ })
+})
+
+// ── bumpNodeSeq ────────────────────────────────────────────────────────────────
+
+describe('bumpNodeSeq', () => {
+ it('has no effect on an empty array', () => {
+ _resetNodeSeq(0)
+ bumpNodeSeq([])
+ const id = newNodeId('kernel')
+ expect(id).toBe('kernel_1')
+ })
+
+ it('advances counter past the highest numeric suffix found', () => {
+ _resetNodeSeq(0)
+ bumpNodeSeq([
+ { id: 'kernel_5' },
+ { id: 'hull_3' },
+ { id: 'fill_12' },
+ ])
+ const id = newNodeId('kernel')
+ const suffix = parseInt(id.split('_').pop(), 10)
+ expect(suffix).toBeGreaterThan(12)
+ })
+
+ it('does not affect the counter for non-matching IDs', () => {
+ _resetNodeSeq(0)
+ bumpNodeSeq([
+ { id: 'source' }, // no underscore-number suffix
+ { id: 'hull' },
+ ])
+ const id = newNodeId('kernel')
+ expect(id).toBe('kernel_1') // counter was not advanced
+ })
+
+ it('handles fixed IDs like "source" alongside numbered IDs', () => {
+ _resetNodeSeq(0)
+ bumpNodeSeq([
+ { id: 'source' },
+ { id: 'kernel_7' },
+ ])
+ const id = newNodeId('fill')
+ const suffix = parseInt(id.split('_').pop(), 10)
+ expect(suffix).toBeGreaterThan(7)
+ })
+
+ it('handles nodes with no id field gracefully', () => {
+ _resetNodeSeq(0)
+ expect(() => bumpNodeSeq([{ kind: 'Source' }, { id: 'kernel_3' }])).not.toThrow()
+ const suffix = parseInt(newNodeId('fill').split('_').pop(), 10)
+ expect(suffix).toBeGreaterThan(3)
+ })
+
+ it('handles large ID numbers', () => {
+ _resetNodeSeq(0)
+ bumpNodeSeq([{ id: 'kernel_9999' }])
+ const suffix = parseInt(newNodeId('hull').split('_').pop(), 10)
+ expect(suffix).toBeGreaterThan(9999)
+ })
+
+ it('calling bumpNodeSeq twice does not reset progress from the first call', () => {
+ _resetNodeSeq(0)
+ bumpNodeSeq([{ id: 'kernel_10' }])
+ bumpNodeSeq([{ id: 'hull_5' }]) // lower — should not decrease counter
+ const suffix = parseInt(newNodeId('fill').split('_').pop(), 10)
+ expect(suffix).toBeGreaterThan(10)
+ })
+
+ it('two successive newNodeId calls after a bump produce distinct IDs', () => {
+ _resetNodeSeq(0)
+ bumpNodeSeq([{ id: 'kernel_20' }])
+ const id1 = newNodeId('fill')
+ const id2 = newNodeId('hull')
+ expect(id1).not.toBe(id2)
+ expect(parseInt(id1.split('_').pop(), 10)).toBeGreaterThan(20)
+ expect(parseInt(id2.split('_').pop(), 10)).toBeGreaterThan(20)
+ })
+})
+
+// ── FILE_EXT constant ─────────────────────────────────────────────────────────
+
+describe('FILE_EXT', () => {
+ it('is a non-empty string', () => {
+ expect(typeof FILE_EXT).toBe('string')
+ expect(FILE_EXT.length).toBeGreaterThan(0)
+ })
+
+ it('does not contain a leading dot', () => {
+ expect(FILE_EXT.startsWith('.')).toBe(false)
+ })
+})
+
+// ── CURRENT_VERSION constant ───────────────────────────────────────────────────
+
+describe('CURRENT_VERSION', () => {
+ it('is a positive integer', () => {
+ expect(Number.isInteger(CURRENT_VERSION)).toBe(true)
+ expect(CURRENT_VERSION).toBeGreaterThan(0)
+ })
+
+ it('matches what serialize writes into the doc', () => {
+ const doc = JSON.parse(serialize(FULL_STATE))
+ expect(doc.version).toBe(CURRENT_VERSION)
+ })
+
+ it('matches what deserialize expects as the current version', () => {
+ // A file at CURRENT_VERSION should load without migrations or warnings
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const migrationSpy = vi.fn(d => d)
+ deserialize(makeV1Doc({ version: CURRENT_VERSION }), { migrations: [migrationSpy] })
+ expect(migrationSpy).not.toHaveBeenCalled()
+ expect(warnSpy).not.toHaveBeenCalled()
+ warnSpy.mockRestore()
+ })
+})
diff --git a/src-frontend/src/store.js b/src-frontend/src/store.js
index 8a5cd033..b10ef5dd 100644
--- a/src-frontend/src/store.js
+++ b/src-frontend/src/store.js
@@ -1,10 +1,10 @@
// Central app state — plain React state lifted to App.jsx.
// This file defines the shapes / defaults.
-export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG']
+export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG','ColorIsolate']
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
-export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch']
+export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch','gradient_cross_hatch']
// Per-strategy secondary parameter exposed as a slider.
// Strategies not listed here have no secondary parameter.
@@ -15,25 +15,68 @@ export const FILL_STRATEGY_PARAMS = {
hint: 'Number of concentric ring emitters' },
flow: { label: 'Bend', min: 0.0, max: 2.0, step: 0.1, default: 1.0,
hint: '0 = straight lines · 1 = default ±45° · 2 = wild curves' },
- gradient_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25,
- hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
+ gradient_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25,
+ hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
+ gradient_cross_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25,
+ hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
}
// Strategies that use the angle slider
-export const FILL_USES_ANGLE = new Set(['hatch', 'zigzag', 'flow', 'gradient_hatch'])
+export const FILL_USES_ANGLE = new Set(['hatch', 'zigzag', 'flow', 'gradient_hatch', 'gradient_cross_hatch'])
+
+export function rgbToHsv(r, g, b) {
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min
+ let h = 0
+ if (d > 0) {
+ if (max === r) h = 60 * (((g - b) / d) % 6)
+ else if (max === g) h = 60 * ((b - r) / d + 2)
+ else h = 60 * ((r - g) / d + 4)
+ }
+ if (h < 0) h += 360
+ return { h, s: max === 0 ? 0 : d / max, v: max }
+}
+
+export function buildColorIsolateFilter(ciColor, hueTol, satMin, valMin) {
+ const r = parseInt(ciColor.slice(1, 3), 16) / 255
+ const g = parseInt(ciColor.slice(3, 5), 16) / 255
+ const b = parseInt(ciColor.slice(5, 7), 16) / 255
+ const { h } = rgbToHsv(r, g, b)
+ return {
+ enabled: true,
+ hue_min: (h - hueTol + 360) % 360,
+ hue_max: (h + hueTol) % 360,
+ sat_min: satMin, sat_max: 1.0,
+ val_min: valMin, val_max: 1.0,
+ }
+}
export function defaultKernelProps() {
+ const ci_color = '#e63946', ci_hue_tolerance = 20, ci_sat_min = 0.2, ci_val_min = 0.15
return {
kernel: 'Luminance', weight: 1.0, invert: false,
blur_radius: 0.0, sat_min_value: 0.1,
canny_low: 50.0, canny_high: 150.0,
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
+ color_filter: buildColorIsolateFilter(ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min),
+ ci_color, ci_hue_tolerance, ci_sat_min, ci_val_min,
}
}
let _nodeSeq = 0
export function newNodeId(kind) { return `${kind.toLowerCase()}_${++_nodeSeq}` }
+// After loading a saved graph, advance _nodeSeq past any IDs already in use
+// so newly-created nodes don't collide with loaded ones.
+export function bumpNodeSeq(nodes) {
+ for (const n of nodes) {
+ const m = n.id?.match(/_(\d+)$/)
+ if (m) _nodeSeq = Math.max(_nodeSeq, parseInt(m[1], 10))
+ }
+}
+
+// Reset the counter to a known value. Test-only — not for production use.
+export function _resetNodeSeq(to = 0) { _nodeSeq = to }
+
export function defaultColorFilter() {
return { enabled: false, hue_min: 0, hue_max: 360, sat_min: 0, sat_max: 1, val_min: 0, val_max: 1 }
}
@@ -45,6 +88,10 @@ export function defaultFillParams() {
}
}
+export function defaultPenOutputParams() {
+ return { pen_color: [20, 20, 20], pen_label: 'Pen 1', pen_order: 0 }
+}
+
export function defaultHullParams() {
return {
threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
@@ -59,21 +106,20 @@ export function defaultGraph() {
{ id: 'source', kind: 'Source', x: 60, y: 160 },
{ id: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() },
{ id: 'hull', kind: 'Hull', x: 560, y: 160, ...defaultHullParams() },
- { id: 'fill', kind: 'Fill', x: 840, y: 160, ...defaultFillParams() },
+ { id: 'fill', kind: 'Fill', x: 840, y: 160, ...defaultFillParams() },
+ { id: 'pen1', kind: 'PenOutput', x: 1110, y: 160, ...defaultPenOutputParams() },
],
edges: [
{ from: 'source', to: kId, port: 0 },
{ from: kId, to: 'hull', port: 0 },
{ from: 'hull', to: 'fill', port: 0 },
+ { from: 'fill', to: 'pen1', port: 0 },
],
}
}
export function defaultPass(index) {
- const colors = [[20,20,20],[60,100,220],[200,60,60]]
return {
- label: `Pass ${index + 1}`,
- penColor: colors[index] ?? [128,128,128],
graph: defaultGraph(),
nodePreviews: {},
// runtime
diff --git a/src-frontend/vite.config.js b/src-frontend/vite.config.js
index b64b7a0a..3c8ed442 100644
--- a/src-frontend/vite.config.js
+++ b/src-frontend/vite.config.js
@@ -4,6 +4,10 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
+ test: {
+ environment: 'node',
+ include: ['src/**/*.test.js'],
+ },
// Tauri expects a fixed port and doesn't use HTTPS in dev
server: {
port: 1420,
diff --git a/src/detect.rs b/src/detect.rs
index 84835dd1..4c0d9e3b 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -12,6 +12,7 @@ pub enum DetectionKernel {
Canny, // thinned 1-px edges via Gaussian + Sobel NMS + hysteresis
Saturation, // HSV saturation (vivid-colour regions as ink)
XDoG, // Extended Difference of Gaussians — coherent edges from photos
+ ColorIsolate, // HSV range selection: 0=selected colour, 255=not selected
}
impl DetectionKernel {
@@ -24,12 +25,13 @@ impl DetectionKernel {
DetectionKernel::Canny => "Canny",
DetectionKernel::Saturation => "Saturation",
DetectionKernel::XDoG => "XDoG",
+ DetectionKernel::ColorIsolate => "ColorIsolate",
}
}
pub fn all() -> &'static [DetectionKernel] {
use DetectionKernel::*;
- &[Luminance, Sobel, ColorGradient, Laplacian, Canny, Saturation, XDoG]
+ &[Luminance, Sobel, ColorGradient, Laplacian, Canny, Saturation, XDoG, ColorIsolate]
}
}
@@ -50,6 +52,10 @@ pub struct DetectionLayer {
pub xdog_sigma2: f32,
pub xdog_tau: f32,
pub xdog_phi: f32,
+ // ColorIsolate: HSV range selection (full range = select everything)
+ pub ci_hue_min: f32, pub ci_hue_max: f32,
+ pub ci_sat_min: f32, pub ci_sat_max: f32,
+ pub ci_val_min: f32, pub ci_val_max: f32,
}
impl Default for DetectionLayer {
@@ -65,6 +71,9 @@ impl Default for DetectionLayer {
xdog_sigma2: 1.6,
xdog_tau: 0.98,
xdog_phi: 10.0,
+ ci_hue_min: 0.0, ci_hue_max: 360.0,
+ ci_sat_min: 0.0, ci_sat_max: 1.0,
+ ci_val_min: 0.0, ci_val_max: 1.0,
}
}
}
@@ -92,6 +101,7 @@ pub fn apply_layer(rgb: &RgbImage, layer: &DetectionLayer) -> Vec