feat: project save/load, NodeGraph pan/zoom perf fix, test suite
NodeGraph: bypass React re-renders on pan/zoom by driving the world-div CSS transform directly via a DOM ref (worldRef + applyTransform). Removes setZoom/setPan from the onWheel and pan-drag onMove hot paths. Project save/load (.trac3r): - Versioned JSON format with app discriminator, saved_at timestamp, and a MIGRATIONS array for forward/backward compatibility - serialize/deserialize in project.js; deserialize accepts injectable migrations and currentVersion for testability - Two new Tauri commands: write_project_file / read_project_file - Save/Open Project buttons in App.jsx toolbar; Ctrl+S / Ctrl+Shift+S - bumpNodeSeq in store.js advances the node-ID counter past loaded IDs - _resetNodeSeq exported for test isolation Test suite (72 tests, vitest): - serialize, deserialize happy path, all optional-field defaults - 10 validation error cases (non-JSON, null, array, wrong app, bad version) - migration engine: no-op on current version, forward-compat warn, multi-hop chains, field rename, field default, gap error - round-trip: full state, Combine multi-port, ColorIsolate, XDoG params - forward compatibility: unknown fields survive migration spreads - bumpNodeSeq: 8 invariant tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
361
src-frontend/package-lock.json
generated
361
src-frontend/package-lock.json
generated
@@ -24,7 +24,8 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -838,6 +839,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.4",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||||
@@ -1140,6 +1148,24 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -1247,6 +1386,16 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -1338,6 +1487,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1423,6 +1582,13 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1618,6 +1784,16 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -1628,6 +1804,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2284,6 +2470,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -2354,6 +2551,13 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2524,6 +2728,13 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2534,6 +2745,20 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.4",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||||
@@ -2555,6 +2780,23 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -2572,6 +2814,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -2728,6 +3070,23 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import PerfPanel from './components/PerfPanel.jsx'
|
|||||||
import Slider from './components/Slider.jsx'
|
import Slider from './components/Slider.jsx'
|
||||||
import { defaultPass, defaultGcodeConfig, PAPER_SIZES } from './store.js'
|
import { defaultPass, defaultGcodeConfig, PAPER_SIZES } from './store.js'
|
||||||
import * as tauri from './hooks/useTauri.js'
|
import * as tauri from './hooks/useTauri.js'
|
||||||
|
import { serialize, deserialize } from './project.js'
|
||||||
import { useFps } from './hooks/useFps.js'
|
import { useFps } from './hooks/useFps.js'
|
||||||
|
|
||||||
const VIEW_MODES = ['source', 'detection', 'contours', 'gcode']
|
const VIEW_MODES = ['source', 'detection', 'contours', 'gcode']
|
||||||
@@ -13,7 +14,7 @@ const VIEW_MODES = ['source', 'detection', 'contours', 'gcode']
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [image, setImage] = useState(null)
|
const [image, setImage] = useState(null)
|
||||||
const [passes, setPasses] = useState([defaultPass(0)])
|
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 [gcodeConfig, setGcodeConfig] = useState(defaultGcodeConfig())
|
||||||
const [viewMode, setViewMode] = useState('source')
|
const [viewMode, setViewMode] = useState('source')
|
||||||
const [displayB64, setDisplayB64] = useState(null) // current image shown in viewport
|
const [displayB64, setDisplayB64] = useState(null) // current image shown in viewport
|
||||||
@@ -26,8 +27,25 @@ export default function App() {
|
|||||||
const fps = useFps()
|
const fps = useFps()
|
||||||
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(320)
|
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)
|
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
|
// Long-task observer — fires whenever the JS main thread blocks > 50ms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const obs = new PerformanceObserver(list => {
|
const obs = new PerformanceObserver(list => {
|
||||||
@@ -58,13 +76,15 @@ export default function App() {
|
|||||||
window.addEventListener('mouseup', onUp)
|
window.addEventListener('mouseup', onUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always-fresh ref so debounced callbacks never close over stale passes
|
// Always-fresh refs so debounced callbacks never close over stale state
|
||||||
const passesRef = useRef(passes)
|
const passesRef = useRef(passes)
|
||||||
const imageRef = useRef(image)
|
const imageRef = useRef(image)
|
||||||
const activePasRef = useRef(activePass)
|
const dpiRef = useRef(dpi)
|
||||||
|
const gcodeConfigRef = useRef(gcodeConfig)
|
||||||
passesRef.current = passes
|
passesRef.current = passes
|
||||||
imageRef.current = image
|
imageRef.current = image
|
||||||
activePasRef.current = activePass
|
dpiRef.current = dpi
|
||||||
|
gcodeConfigRef.current = gcodeConfig
|
||||||
|
|
||||||
// Debounce timers: { 'idx-detection': timer, 'idx-fill': timer }
|
// Debounce timers: { 'idx-detection': timer, 'idx-fill': timer }
|
||||||
const debounceTimers = useRef({})
|
const debounceTimers = useRef({})
|
||||||
@@ -84,13 +104,13 @@ export default function App() {
|
|||||||
setDisplayB64(image.preview_b64)
|
setDisplayB64(image.preview_b64)
|
||||||
break
|
break
|
||||||
case 'detection':
|
case 'detection':
|
||||||
setDisplayB64(passes[activePass]?.vizB64 ?? null)
|
setDisplayB64(passes[0]?.vizB64 ?? null)
|
||||||
break
|
break
|
||||||
case 'contours':
|
case 'contours':
|
||||||
if (passes[activePass]?.hullCount > 0) {
|
if (passes[0]?.hullCount > 0) {
|
||||||
try {
|
try {
|
||||||
const tv = performance.now()
|
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) }))
|
setPerfData(pd => ({ ...(pd ?? {}), js_viz: Math.round(performance.now() - tv) }))
|
||||||
setDisplayB64(b64)
|
setDisplayB64(b64)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -105,7 +125,7 @@ export default function App() {
|
|||||||
if (passes.some(p => p.strokeCount > 0)) {
|
if (passes.some(p => p.strokeCount > 0)) {
|
||||||
try {
|
try {
|
||||||
const tv = performance.now()
|
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) }))
|
setPerfData(pd => ({ ...(pd ?? {}), js_viz: Math.round(performance.now() - tv) }))
|
||||||
setDisplayB64(b64)
|
setDisplayB64(b64)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -119,7 +139,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
refresh()
|
refresh()
|
||||||
}, [viewMode, activePass, image, passes[activePass]?.vizB64, passes[activePass]?.hullCount, totalStrokeCount])
|
}, [viewMode, image, passes[0]?.vizB64, passes[0]?.hullCount, totalStrokeCount])
|
||||||
|
|
||||||
// ── File open ──────────────────────────────────────────────────────────────
|
// ── File open ──────────────────────────────────────────────────────────────
|
||||||
async function openImage() {
|
async function openImage() {
|
||||||
@@ -134,7 +154,7 @@ export default function App() {
|
|||||||
setViewMode('source')
|
setViewMode('source')
|
||||||
setStrokes(null)
|
setStrokes(null)
|
||||||
setGlobalStatus(`${info.width} × ${info.height}px`)
|
setGlobalStatus(`${info.width} × ${info.height}px`)
|
||||||
for (let i = 0; i < passesRef.current.length; i++) processPass(i, true)
|
processPass(0, true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setGlobalStatus(`Error loading image: ${e}`)
|
setGlobalStatus(`Error loading image: ${e}`)
|
||||||
}
|
}
|
||||||
@@ -147,14 +167,15 @@ export default function App() {
|
|||||||
if (!imageRef.current) return
|
if (!imageRef.current) return
|
||||||
if (!silent) setBusy(true)
|
if (!silent) setBusy(true)
|
||||||
const pass = passesRef.current[idx]
|
const pass = passesRef.current[idx]
|
||||||
// Reset strokeCount NOW so the gcode viz useEffect sees 0 strokes and
|
// Reset counts so viewport doesn't show stale data during reprocessing.
|
||||||
// doesn't fire getGcodeViz while generateFill is about to start.
|
|
||||||
updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 })
|
updatePass(idx, { status: 'Processing…', vizB64: null, hullCount: 0, strokeCount: 0 })
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
try {
|
try {
|
||||||
const result = await tauri.processPass({
|
const result = await tauri.processPass({
|
||||||
pass_index: idx,
|
pass_index: idx,
|
||||||
graph: pass.graph,
|
graph: pass.graph,
|
||||||
|
dpi: dpiRef.current,
|
||||||
|
img_w_mm: gcodeConfigRef.current.img_w_mm,
|
||||||
})
|
})
|
||||||
const js_process = Math.round(performance.now() - t0)
|
const js_process = Math.round(performance.now() - t0)
|
||||||
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
setPerfData(pd => ({ ...(pd ?? {}), process: result.timings, js_process }))
|
||||||
@@ -165,8 +186,7 @@ export default function App() {
|
|||||||
strokeCount: result.stroke_count,
|
strokeCount: result.stroke_count,
|
||||||
nodePreviews: result.node_previews ?? {},
|
nodePreviews: result.node_previews ?? {},
|
||||||
})
|
})
|
||||||
const colors = passesRef.current.map(p => p.penColor)
|
tauri.getAllStrokes().then(s => setStrokes(s)).catch(() => {})
|
||||||
tauri.getAllStrokes(colors).then(s => setStrokes(s)).catch(() => {})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updatePass(idx, { status: `Error: ${e}` })
|
updatePass(idx, { status: `Error: ${e}` })
|
||||||
setGlobalStatus(`Process error: ${e}`)
|
setGlobalStatus(`Process error: ${e}`)
|
||||||
@@ -175,31 +195,21 @@ export default function App() {
|
|||||||
}, []) // stable — uses refs
|
}, []) // stable — uses refs
|
||||||
|
|
||||||
// ── Debounced auto-reprocess triggered by slider changes ───────────────────
|
// ── Debounced auto-reprocess triggered by slider changes ───────────────────
|
||||||
const scheduleProcess = useCallback((idx) => {
|
const scheduleProcess = useCallback(() => {
|
||||||
const key = `${idx}-detect`
|
clearTimeout(debounceTimers.current['detect'])
|
||||||
clearTimeout(debounceTimers.current[key])
|
debounceTimers.current['detect'] = setTimeout(() => processPass(0, true), 400)
|
||||||
debounceTimers.current[key] = setTimeout(() => processPass(idx, true), 400)
|
|
||||||
}, [processPass])
|
}, [processPass])
|
||||||
|
|
||||||
// ── Export ─────────────────────────────────────────────────────────────────
|
useEffect(() => {
|
||||||
async function exportActivePass() {
|
if (imageRef.current) scheduleProcess()
|
||||||
const pass = passes[activePass]
|
}, [dpi, gcodeConfig.img_w_mm])
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── Export ─────────────────────────────────────────────────────────────────
|
||||||
async function exportAll() {
|
async function exportAll() {
|
||||||
const dir = await tauri.pickFolder()
|
const dir = await tauri.pickFolder()
|
||||||
if (!dir) return
|
if (!dir) return
|
||||||
try {
|
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}`)
|
setGlobalStatus(`Saved ${saved.length} file(s) to ${dir}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setGlobalStatus(`Export error: ${e}`)
|
setGlobalStatus(`Export error: ${e}`)
|
||||||
@@ -208,6 +218,78 @@ export default function App() {
|
|||||||
|
|
||||||
function setGcode(patch) { setGcodeConfig(c => ({ ...c, ...patch })) }
|
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() {
|
async function dumpDebugState() {
|
||||||
try {
|
try {
|
||||||
const configs = passes.map(p => ({
|
const configs = passes.map(p => ({
|
||||||
@@ -230,16 +312,32 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
||||||
<span className="font-bold text-sm text-indigo-400 tracking-tight">Trac3r</span>
|
<span className="font-bold text-sm text-indigo-400 tracking-tight shrink-0">Trac3r</span>
|
||||||
|
{projectPath && (
|
||||||
|
<span className="text-neutral-500 text-xs truncate max-w-[140px]" title={projectPath}>
|
||||||
|
{projectPath.split('/').pop()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button onClick={dumpDebugState}
|
<button onClick={dumpDebugState}
|
||||||
className="px-2 py-1 rounded bg-amber-800 hover:bg-amber-700 text-amber-200 text-xs transition-colors"
|
className="px-2 py-1 rounded bg-amber-800 hover:bg-amber-700 text-amber-200 text-xs transition-colors"
|
||||||
title="Export debug state to trac3r_debug.json next to the image">
|
title="Export debug state">
|
||||||
Dump
|
Dump
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={loadProject} disabled={busy}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs transition-colors disabled:opacity-40"
|
||||||
|
title="Open a saved project (.trac3r)">
|
||||||
|
Open Project
|
||||||
|
</button>
|
||||||
|
<button onClick={() => saveProject(false)} disabled={busy}
|
||||||
|
className="px-2 py-1 rounded bg-indigo-800 hover:bg-indigo-700 text-indigo-200 text-xs transition-colors disabled:opacity-40"
|
||||||
|
title="Save project (Ctrl+S). Ctrl+Shift+S to Save As.">
|
||||||
|
{projectPath ? 'Save' : 'Save As…'}
|
||||||
|
</button>
|
||||||
<button onClick={openImage} disabled={busy}
|
<button onClick={openImage} disabled={busy}
|
||||||
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs transition-colors disabled:opacity-40">
|
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs transition-colors disabled:opacity-40"
|
||||||
Open…
|
title="Open a source image">
|
||||||
|
Open Image…
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => window.location.reload()}
|
<button onClick={() => window.location.reload()}
|
||||||
className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-500 hover:text-neutral-300 text-xs transition-colors"
|
className="px-2 py-1 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-500 hover:text-neutral-300 text-xs transition-colors"
|
||||||
@@ -255,52 +353,91 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pass tabs */}
|
{/* Scrollable sidebar content */}
|
||||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-neutral-800 bg-neutral-950/50">
|
<div className="flex-1 overflow-y-auto flex flex-col">
|
||||||
{passes.map((p, i) => {
|
|
||||||
const color = `rgb(${p.penColor.join(',')})`
|
{/* Status */}
|
||||||
return (
|
<PassPanel pass={passes[0]} />
|
||||||
<button key={i}
|
|
||||||
onClick={() => setActivePass(i)}
|
<div className="px-3 py-2 space-y-4">
|
||||||
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${
|
|
||||||
i === activePass
|
{/* Graph */}
|
||||||
? 'bg-neutral-700 text-neutral-100'
|
<div className="space-y-0.5">
|
||||||
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Graph</p>
|
||||||
}`}
|
<Slider label="Card width" value={nodeWidth} min={160} max={800} step={10}
|
||||||
>
|
onChange={v => setNodeWidth(v)} unit="px" />
|
||||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{passes.length < 3 && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const idx = passes.length
|
|
||||||
setPasses(ps => [...ps, defaultPass(idx)])
|
|
||||||
setActivePass(idx)
|
|
||||||
}}
|
|
||||||
className="ml-auto px-1.5 py-0.5 text-neutral-600 hover:text-indigo-400 text-base transition-colors leading-none"
|
|
||||||
>+</button>
|
|
||||||
)}
|
|
||||||
{passes.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setPasses(ps => ps.filter((_, i) => i !== activePass))
|
|
||||||
setActivePass(ap => Math.max(0, ap - 1))
|
|
||||||
}}
|
|
||||||
className="px-1.5 py-0.5 text-neutral-700 hover:text-red-400 text-xs transition-colors"
|
|
||||||
>✕</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active pass config — scrollable */}
|
{/* Pipeline */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="space-y-0.5">
|
||||||
<PassPanel
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Pipeline</p>
|
||||||
pass={passes[activePass]}
|
<Slider label="DPI" value={dpi} min={50} max={600} step={25}
|
||||||
onChange={p => updatePass(activePass, p)}
|
onChange={v => setDpi(v)} />
|
||||||
onDetectionChange={() => scheduleProcess(activePass)}
|
</div>
|
||||||
/>
|
|
||||||
|
{/* Paper */}
|
||||||
|
<div>
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Paper</p>
|
||||||
|
<div className="flex gap-1 flex-wrap mb-1">
|
||||||
|
{PAPER_SIZES.map(ps => {
|
||||||
|
const isPortrait = Math.abs(gcodeConfig.paper_w_mm - ps.w) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.h) < 1
|
||||||
|
const isLandscape = Math.abs(gcodeConfig.paper_w_mm - ps.h) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.w) < 1
|
||||||
|
return (
|
||||||
|
<button key={ps.name}
|
||||||
|
onClick={() => {
|
||||||
|
const portrait = gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm
|
||||||
|
setGcode(portrait
|
||||||
|
? { paper_w_mm: ps.w, paper_h_mm: ps.h }
|
||||||
|
: { paper_w_mm: ps.h, paper_h_mm: ps.w })
|
||||||
|
}}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
isPortrait || isLandscape
|
||||||
|
? 'bg-indigo-700 text-white' : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>{ps.name}</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setGcode({ paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm })}
|
||||||
|
title={gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm ? 'Switch to landscape' : 'Switch to portrait'}
|
||||||
|
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-400 hover:bg-neutral-700 transition-colors"
|
||||||
|
>Rotate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placement */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Placement</p>
|
||||||
|
<Slider label="Width mm" value={gcodeConfig.img_w_mm} min={10} max={2000} step={1}
|
||||||
|
onChange={v => setGcode({ img_w_mm: v })} />
|
||||||
|
<Slider label="Offset X" value={gcodeConfig.offset_x_mm} min={-500} max={500} step={1} unit="mm"
|
||||||
|
onChange={v => setGcode({ offset_x_mm: v })} />
|
||||||
|
<Slider label="Offset Y" value={gcodeConfig.offset_y_mm} min={-500} max={500} step={1} unit="mm"
|
||||||
|
onChange={v => setGcode({ offset_y_mm: v })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plotter */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Plotter</p>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-amber-500" />
|
||||||
|
</div>
|
||||||
|
<Slider label="Pen speed" value={gcodeConfig.feed_draw} min={100} max={5000} step={100}
|
||||||
|
onChange={v => setGcode({ feed_draw: v })} unit=" mm/m" />
|
||||||
|
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
|
||||||
|
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<div>
|
||||||
|
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Export</p>
|
||||||
|
<button onClick={exportAll} disabled={!passes.some(p => p.strokeCount > 0)}
|
||||||
|
className="w-full px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40 transition-colors">
|
||||||
|
Export G-code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -316,7 +453,7 @@ export default function App() {
|
|||||||
{/* Top bar — accent colors match the section dots in the left panel */}
|
{/* Top bar — accent colors match the section dots in the left panel */}
|
||||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-neutral-800 bg-neutral-900/80">
|
||||||
{VIEW_MODES.map(m => {
|
{VIEW_MODES.map(m => {
|
||||||
const accent = { detection: '#6366f1', contours: '#14b8a6', fill: '#a855f7', gcode: '#f59e0b' }[m]
|
const accent = { detection: '#6366f1', contours: '#14b8a6', gcode: '#f59e0b' }[m]
|
||||||
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
|
const label = m === 'gcode' ? 'G-code' : m.charAt(0).toUpperCase() + m.slice(1)
|
||||||
return (
|
return (
|
||||||
<button key={m}
|
<button key={m}
|
||||||
@@ -347,13 +484,14 @@ export default function App() {
|
|||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
{viewMode === 'detection' ? (
|
{viewMode === 'detection' ? (
|
||||||
<NodeGraph
|
<NodeGraph
|
||||||
graph={passes[activePass].graph}
|
graph={passes[0].graph}
|
||||||
onChange={graph => {
|
onChange={graph => {
|
||||||
updatePass(activePass, { graph })
|
updatePass(0, { graph })
|
||||||
scheduleProcess(activePass)
|
scheduleProcess()
|
||||||
}}
|
}}
|
||||||
nodePreviews={passes[activePass].nodePreviews}
|
nodePreviews={passes[0].nodePreviews}
|
||||||
sourceImageB64={image?.preview_b64 ?? null}
|
sourceImageB64={image?.preview_b64 ?? null}
|
||||||
|
nodeWidth={nodeWidth}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Viewport
|
<Viewport
|
||||||
@@ -375,79 +513,6 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar */}
|
|
||||||
<div className="border-t border-neutral-800 bg-neutral-900/80 px-4 py-2">
|
|
||||||
<div className="flex items-start gap-6 flex-wrap">
|
|
||||||
|
|
||||||
{/* Paper */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Paper</p>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{PAPER_SIZES.map(ps => {
|
|
||||||
const isPortrait = Math.abs(gcodeConfig.paper_w_mm - ps.w) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.h) < 1
|
|
||||||
const isLandscape = Math.abs(gcodeConfig.paper_w_mm - ps.h) < 1 && Math.abs(gcodeConfig.paper_h_mm - ps.w) < 1
|
|
||||||
return (
|
|
||||||
<button key={ps.name}
|
|
||||||
onClick={() => {
|
|
||||||
// Keep current orientation when switching size
|
|
||||||
const portrait = gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm
|
|
||||||
setGcode(portrait
|
|
||||||
? { paper_w_mm: ps.w, paper_h_mm: ps.h }
|
|
||||||
: { paper_w_mm: ps.h, paper_h_mm: ps.w })
|
|
||||||
}}
|
|
||||||
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
|
||||||
isPortrait || isLandscape
|
|
||||||
? 'bg-indigo-700 text-white' : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
|
||||||
}`}
|
|
||||||
>{ps.name}</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{/* Portrait / Landscape toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setGcode({ paper_w_mm: gcodeConfig.paper_h_mm, paper_h_mm: gcodeConfig.paper_w_mm })}
|
|
||||||
title={gcodeConfig.paper_w_mm <= gcodeConfig.paper_h_mm ? 'Switch to landscape' : 'Switch to portrait'}
|
|
||||||
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-400 hover:bg-neutral-700 transition-colors"
|
|
||||||
>Rotate</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Placement */}
|
|
||||||
<div className="space-y-0.5 flex-1 min-w-48">
|
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider mb-1.5">Placement</p>
|
|
||||||
<Slider label="Width mm" value={gcodeConfig.img_w_mm} min={10} max={2000} step={1}
|
|
||||||
onChange={v => setGcode({ img_w_mm: v })} />
|
|
||||||
<Slider label="Offset X" value={gcodeConfig.offset_x_mm} min={-500} max={500} step={1} unit="mm"
|
|
||||||
onChange={v => setGcode({ offset_x_mm: v })} />
|
|
||||||
<Slider label="Offset Y" value={gcodeConfig.offset_y_mm} min={-500} max={500} step={1} unit="mm"
|
|
||||||
onChange={v => setGcode({ offset_y_mm: v })} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plotter */}
|
|
||||||
<div className="space-y-0.5 flex-1 min-w-40">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Plotter</p>
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-amber-500" />
|
|
||||||
</div>
|
|
||||||
<Slider label="Pen speed" value={gcodeConfig.feed_draw} min={100} max={5000} step={100}
|
|
||||||
onChange={v => setGcode({ feed_draw: v })} unit=" mm/m" />
|
|
||||||
<Slider label="Travel speed" value={gcodeConfig.feed_travel} min={100} max={10000} step={100}
|
|
||||||
onChange={v => setGcode({ feed_travel: v })} unit=" mm/m" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Export */}
|
|
||||||
<div className="flex flex-col gap-1.5 justify-end">
|
|
||||||
<p className="text-neutral-500 text-xs font-semibold uppercase tracking-wider">Export</p>
|
|
||||||
<button onClick={exportActivePass} disabled={passes[activePass]?.strokeCount === 0}
|
|
||||||
className="px-3 py-1.5 rounded bg-neutral-700 hover:bg-neutral-600 text-xs disabled:opacity-40 transition-colors">
|
|
||||||
This pass
|
|
||||||
</button>
|
|
||||||
<button onClick={exportAll} disabled={!passes.some(p => p.strokeCount > 0)}
|
|
||||||
className="px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 text-xs text-white disabled:opacity-40 transition-colors">
|
|
||||||
All passes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import Slider from './Slider.jsx'
|
import Slider from './Slider.jsx'
|
||||||
|
|
||||||
export default function ColorFilter({ filter, onChange }) {
|
export default function ColorFilter({ filter, onChange, alwaysOn = false }) {
|
||||||
function set(patch) { onChange({ ...filter, ...patch }) }
|
function set(patch) { onChange({ ...filter, ...patch }) }
|
||||||
|
const showSliders = alwaysOn || filter.enabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
{!alwaysOn && (
|
||||||
<label className="flex items-center gap-2 cursor-pointer mb-2">
|
<label className="flex items-center gap-2 cursor-pointer mb-2">
|
||||||
<input type="checkbox" checked={filter.enabled}
|
<input type="checkbox" checked={filter.enabled}
|
||||||
onChange={e => set({ enabled: e.target.checked })}
|
onChange={e => set({ enabled: e.target.checked })}
|
||||||
className="accent-indigo-500" />
|
className="accent-indigo-500" />
|
||||||
<span className="text-neutral-300 text-xs">Enable color filter</span>
|
<span className="text-neutral-300 text-xs">Enable color filter</span>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{filter.enabled && (
|
{showSliders && (
|
||||||
<div className="space-y-1 pl-1 border-l-2 border-neutral-800">
|
<div className="space-y-1 pl-1 border-l-2 border-neutral-800">
|
||||||
<div className="text-neutral-500 text-xs mb-1">Hue (0–360°)</div>
|
<div className="text-neutral-500 text-xs mb-1">Hue (0–360°)</div>
|
||||||
<Slider label="Min" value={filter.hue_min} min={0} max={360} step={1} unit="°"
|
<Slider label="Min" value={filter.hue_min} min={0} max={360} step={1} unit="°"
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||||
import Slider from './Slider.jsx'
|
import Slider from './Slider.jsx'
|
||||||
import { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultFillParams, defaultColorFilter, newNodeId, FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE } from '../store.js'
|
import { KERNELS, BLEND_MODES, defaultKernelProps, defaultHullParams, defaultFillParams, defaultPenOutputParams, defaultColorFilter, newNodeId, FILL_STRATEGIES, FILL_STRATEGY_PARAMS, FILL_USES_ANGLE, rgbToHsv, buildColorIsolateFilter } from '../store.js'
|
||||||
import ColorFilter from './ColorFilter.jsx'
|
import ColorFilter from './ColorFilter.jsx'
|
||||||
|
|
||||||
// ── Layout constants ───────────────────────────────────────────────────────────
|
// ── Layout constants ───────────────────────────────────────────────────────────
|
||||||
const NODE_W = 220
|
|
||||||
const PORT_R = 6
|
const PORT_R = 6
|
||||||
const PORT_TOP = 18 // y from node top to first/only port centre
|
const PORT_TOP = 18 // y from node top to first/only port centre
|
||||||
const PORT_STRIDE = 26 // vertical stride between combine input ports
|
const PORT_STRIDE = 26 // vertical stride between combine input ports
|
||||||
@@ -28,8 +27,8 @@ const PARAM_META = {
|
|||||||
xdog_phi: { label: 'φ', min: 1, max: 100, step: 1 },
|
xdog_phi: { label: 'φ', min: 1, max: 100, step: 1 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function outPort(node) {
|
function outPort(node, nw) {
|
||||||
return { x: node.x + NODE_W, y: node.y + PORT_TOP }
|
return { x: node.x + nw, y: node.y + PORT_TOP }
|
||||||
}
|
}
|
||||||
function inPort(node, idx) {
|
function inPort(node, idx) {
|
||||||
return { x: node.x, y: node.y + PORT_TOP + idx * PORT_STRIDE }
|
return { x: node.x, y: node.y + PORT_TOP + idx * PORT_STRIDE }
|
||||||
@@ -39,27 +38,80 @@ function bezier(from, to) {
|
|||||||
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`
|
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Compatibility helpers ──────────────────────────────────────────────────────
|
||||||
|
// Kernel nodes use their upstream response map when one is present, falling back
|
||||||
|
// to raw source RGB when connected only to Source (which produces no map).
|
||||||
|
function outputType(kind) {
|
||||||
|
if (kind === 'Source' || kind === 'Kernel' || kind === 'Combine') return 'map'
|
||||||
|
if (kind === 'Hull') return 'hulls'
|
||||||
|
if (kind === 'Fill') return 'fill'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function inputType(kind) {
|
||||||
|
if (kind === 'Kernel' || kind === 'Combine' || kind === 'Hull') return 'map'
|
||||||
|
if (kind === 'Fill') return 'hulls'
|
||||||
|
if (kind === 'PenOutput') return 'fill'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function isCompatible(fromKind, toKind, existingEdges, fromId, toId) {
|
||||||
|
if (outputType(fromKind) !== inputType(toKind)) return false
|
||||||
|
// cycle check: can toId reach fromId through existing edges?
|
||||||
|
const visited = new Set()
|
||||||
|
const queue = [toId]
|
||||||
|
while (queue.length) {
|
||||||
|
const cur = queue.shift()
|
||||||
|
if (cur === fromId) return false
|
||||||
|
if (visited.has(cur)) continue
|
||||||
|
visited.add(cur)
|
||||||
|
existingEdges.filter(e => e.from === cur).forEach(e => queue.push(e.to))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB64 }) {
|
export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB64, nodeWidth = 220 }) {
|
||||||
const canvasRef = useRef(null)
|
const canvasRef = useRef(null)
|
||||||
const [pan, setPan] = useState({ x: 40, y: 40 })
|
const worldRef = useRef(null)
|
||||||
const [zoom, setZoom] = useState(1)
|
|
||||||
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
|
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
|
||||||
|
|
||||||
// Refs mirror current state so event handlers don't need to be recreated
|
// Pan/zoom live only in refs — no React re-render on scroll/drag
|
||||||
const panRef = useRef({ x: 40, y: 40 })
|
const panRef = useRef({ x: 40, y: 40 })
|
||||||
const zoomRef = useRef(1)
|
const zoomRef = useRef(1)
|
||||||
const graphRef = useRef(graph)
|
const graphRef = useRef(graph)
|
||||||
const onChangeRef = useRef(onChange)
|
const onChangeRef = useRef(onChange)
|
||||||
panRef.current = pan
|
|
||||||
zoomRef.current = zoom
|
|
||||||
graphRef.current = graph
|
graphRef.current = graph
|
||||||
onChangeRef.current = onChange
|
onChangeRef.current = onChange
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
if (!worldRef.current) return
|
||||||
|
const { x, y } = panRef.current
|
||||||
|
worldRef.current.style.transform = `translate(${x}px, ${y}px) scale(${zoomRef.current})`
|
||||||
|
}
|
||||||
|
|
||||||
// Drag state stored in refs so handlers remain stable
|
// Drag state stored in refs so handlers remain stable
|
||||||
const panDragRef = useRef(null) // { startX, startY } — canvas-space origin
|
const panDragRef = useRef(null) // { startX, startY } — canvas-space origin
|
||||||
const nodeDragRef = useRef(null) // { nodeId, startNodeX/Y, startClientX/Y }
|
const nodeDragRef = useRef(null) // { nodeId, startNodeX/Y, startClientX/Y }
|
||||||
const wireRef = useRef(null) // same shape as wire state
|
const wireRef = useRef(null) // same shape as wire state (drag-initiated)
|
||||||
|
const clickWireRef = useRef(null) // same shape as wire state (click-initiated)
|
||||||
|
|
||||||
|
// Eyedropper sample mode: which ColorIsolate node is waiting for a pixel pick
|
||||||
|
const [sampleNodeId, setSampleNodeId] = useState(null)
|
||||||
|
const sampleNodeIdRef = useRef(null)
|
||||||
|
sampleNodeIdRef.current = sampleNodeId
|
||||||
|
|
||||||
|
// Offscreen canvas holding decoded source image pixels for eyedropper sampling
|
||||||
|
const sourceSampleCanvasRef = useRef(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceImageB64) { sourceSampleCanvasRef.current = null; return }
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const c = document.createElement('canvas')
|
||||||
|
c.width = img.naturalWidth; c.height = img.naturalHeight
|
||||||
|
c.getContext('2d').drawImage(img, 0, 0)
|
||||||
|
sourceSampleCanvasRef.current = c
|
||||||
|
}
|
||||||
|
img.src = `data:image/jpeg;base64,${sourceImageB64}`
|
||||||
|
}, [sourceImageB64])
|
||||||
|
|
||||||
// ── Wheel zoom — zoom to cursor, computed from refs to handle rapid scroll ──
|
// ── Wheel zoom — zoom to cursor, computed from refs to handle rapid scroll ──
|
||||||
const onWheel = useCallback(e => {
|
const onWheel = useCallback(e => {
|
||||||
@@ -70,16 +122,14 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
|
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
|
||||||
const z = zoomRef.current
|
const z = zoomRef.current
|
||||||
const p = panRef.current
|
const p = panRef.current
|
||||||
const nz = Math.min(Math.max(z * factor, 0.15), 5)
|
const nz = Math.min(Math.max(z * factor, 0.05), 30)
|
||||||
const np = {
|
const np = {
|
||||||
x: cx - (cx - p.x) * (nz / z),
|
x: cx - (cx - p.x) * (nz / z),
|
||||||
y: cy - (cy - p.y) * (nz / z),
|
y: cy - (cy - p.y) * (nz / z),
|
||||||
}
|
}
|
||||||
// Update refs immediately so back-to-back wheel events are coherent
|
|
||||||
zoomRef.current = nz
|
zoomRef.current = nz
|
||||||
panRef.current = np
|
panRef.current = np
|
||||||
setZoom(nz)
|
applyTransform()
|
||||||
setPan(np)
|
|
||||||
}, []) // stable — reads from refs
|
}, []) // stable — reads from refs
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,9 +142,8 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onMove(e) {
|
function onMove(e) {
|
||||||
if (panDragRef.current) {
|
if (panDragRef.current) {
|
||||||
const np = { x: e.clientX - panDragRef.current.startX, y: e.clientY - panDragRef.current.startY }
|
panRef.current = { x: e.clientX - panDragRef.current.startX, y: e.clientY - panDragRef.current.startY }
|
||||||
panRef.current = np
|
applyTransform()
|
||||||
setPan(np)
|
|
||||||
}
|
}
|
||||||
if (nodeDragRef.current) {
|
if (nodeDragRef.current) {
|
||||||
const { nodeId, startNodeX, startNodeY, startClientX, startClientY } = nodeDragRef.current
|
const { nodeId, startNodeX, startNodeY, startClientX, startClientY } = nodeDragRef.current
|
||||||
@@ -113,14 +162,30 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
wireRef.current = { ...wireRef.current, mouseX: mx, mouseY: my }
|
wireRef.current = { ...wireRef.current, mouseX: mx, mouseY: my }
|
||||||
setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w)
|
setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w)
|
||||||
}
|
}
|
||||||
|
// Click wire follows mouse too (for visual feedback)
|
||||||
|
if (clickWireRef.current) {
|
||||||
|
const r = canvasRef.current.getBoundingClientRect()
|
||||||
|
const p = panRef.current
|
||||||
|
const z = zoomRef.current
|
||||||
|
const mx = (e.clientX - r.left - p.x) / z
|
||||||
|
const my = (e.clientY - r.top - p.y) / z
|
||||||
|
clickWireRef.current = { ...clickWireRef.current, mouseX: mx, mouseY: my }
|
||||||
|
setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function onUp() {
|
function onUp() {
|
||||||
panDragRef.current = null
|
panDragRef.current = null
|
||||||
nodeDragRef.current = null
|
nodeDragRef.current = null
|
||||||
if (wireRef.current) { wireRef.current = null; setWire(null) }
|
// Only clear drag wire on mouse up — click wire persists until click-complete or cancel
|
||||||
|
if (wireRef.current) { wireRef.current = null; setWire(clickWireRef.current) }
|
||||||
}
|
}
|
||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
if (e.key === 'Escape') { wireRef.current = null; setWire(null) }
|
if (e.key === 'Escape') {
|
||||||
|
wireRef.current = null
|
||||||
|
clickWireRef.current = null
|
||||||
|
setWire(null)
|
||||||
|
setSampleNodeId(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('mousemove', onMove)
|
window.addEventListener('mousemove', onMove)
|
||||||
window.addEventListener('mouseup', onUp)
|
window.addEventListener('mouseup', onUp)
|
||||||
@@ -140,42 +205,139 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
panDragRef.current = { startX: e.clientX - panRef.current.x, startY: e.clientY - panRef.current.y }
|
panDragRef.current = { startX: e.clientX - panRef.current.x, startY: e.clientY - panRef.current.y }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Canvas background click → cancel click-wire / sample mode ───────────
|
||||||
|
function onCanvasClick(e) {
|
||||||
|
if (e.target !== canvasRef.current && !e.target.dataset.canvas) return
|
||||||
|
if (clickWireRef.current) { clickWireRef.current = null; setWire(null) }
|
||||||
|
if (sampleNodeIdRef.current) setSampleNodeId(null)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Wire ───────────────────────────────────────────────────────────────────
|
// ── Wire ───────────────────────────────────────────────────────────────────
|
||||||
function startWire(e, fromId) {
|
function startWire(e, fromId) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const node = graphRef.current.nodes.find(n => n.id === fromId)
|
const node = graphRef.current.nodes.find(n => n.id === fromId)
|
||||||
const p = outPort(node)
|
const p = outPort(node, nodeWidth)
|
||||||
const state = { fromId, fromX: p.x, fromY: p.y, mouseX: p.x, mouseY: p.y }
|
const state = { fromId, fromX: p.x, fromY: p.y, mouseX: p.x, mouseY: p.y }
|
||||||
wireRef.current = state
|
wireRef.current = state
|
||||||
setWire(state)
|
setWire(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click-to-connect: output port onClick starts a click wire
|
||||||
|
function startClickWire(e, fromId) {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Don't start if a drag wire is active
|
||||||
|
if (wireRef.current) return
|
||||||
|
// If a click wire is already active, cancel it (toggle off)
|
||||||
|
if (clickWireRef.current) {
|
||||||
|
clickWireRef.current = null
|
||||||
|
setWire(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const node = graphRef.current.nodes.find(n => n.id === fromId)
|
||||||
|
const p = outPort(node, nodeWidth)
|
||||||
|
const state = { fromId, fromX: p.x, fromY: p.y, mouseX: p.x, mouseY: p.y }
|
||||||
|
clickWireRef.current = state
|
||||||
|
setWire(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared logic for completing a wire connection (used by both drag and click)
|
||||||
|
function completeConnection(fromId, toId, port) {
|
||||||
|
const g = graphRef.current
|
||||||
|
const fromNode = g.nodes.find(n => n.id === fromId)
|
||||||
|
const toNode = g.nodes.find(n => n.id === toId)
|
||||||
|
if (!fromNode || !toNode) return
|
||||||
|
if (!isCompatible(fromNode.kind, toNode.kind, g.edges, fromId, toId)) return
|
||||||
|
const filtered = g.edges.filter(ed => !(ed.to === toId && ed.port === port))
|
||||||
|
if (!filtered.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)) {
|
||||||
|
onChangeRef.current({ ...g, edges: [...filtered, { from: fromId, to: toId, port }] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function endWire(e, toId, port) {
|
function endWire(e, toId, port) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!wireRef.current) return
|
if (!wireRef.current) return
|
||||||
const { fromId } = wireRef.current
|
const { fromId } = wireRef.current
|
||||||
if (fromId !== toId) {
|
if (fromId !== toId) {
|
||||||
const g = graphRef.current
|
completeConnection(fromId, toId, port)
|
||||||
const filtered = g.edges.filter(ed => !(ed.to === toId && ed.port === port))
|
|
||||||
if (!filtered.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)) {
|
|
||||||
onChangeRef.current({ ...g, edges: [...filtered, { from: fromId, to: toId, port }] })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
wireRef.current = null
|
wireRef.current = null
|
||||||
setWire(null)
|
setWire(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click-to-connect: input port onClick completes a click wire
|
||||||
|
function endClickWire(e, toId, port) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!clickWireRef.current) return
|
||||||
|
const { fromId } = clickWireRef.current
|
||||||
|
if (fromId !== toId) {
|
||||||
|
completeConnection(fromId, toId, port)
|
||||||
|
}
|
||||||
|
clickWireRef.current = null
|
||||||
|
setWire(null)
|
||||||
|
}
|
||||||
|
|
||||||
function removeEdge(idx) {
|
function removeEdge(idx) {
|
||||||
const g = graphRef.current
|
const g = graphRef.current
|
||||||
onChangeRef.current({ ...g, edges: g.edges.filter((_, i) => i !== idx) })
|
onChangeRef.current({ ...g, edges: g.edges.filter((_, i) => i !== idx) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Double-click to disconnect all edges on a port ─────────────────────────
|
||||||
|
function disconnectOutputPort(e, nodeId) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
const g = graphRef.current
|
||||||
|
onChangeRef.current({ ...g, edges: g.edges.filter(ed => ed.from !== nodeId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectInputPort(e, nodeId, portIdx) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
const g = graphRef.current
|
||||||
|
onChangeRef.current({ ...g, edges: g.edges.filter(ed => !(ed.to === nodeId && ed.port === portIdx)) })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Node mutations ─────────────────────────────────────────────────────────
|
// ── Node mutations ─────────────────────────────────────────────────────────
|
||||||
function updateNode(id, patch) {
|
function updateNode(id, patch) {
|
||||||
const g = graphRef.current
|
const g = graphRef.current
|
||||||
onChangeRef.current({ ...g, nodes: g.nodes.map(n => n.id === id ? { ...n, ...patch } : n) })
|
onChangeRef.current({ ...g, nodes: g.nodes.map(n => n.id === id ? { ...n, ...patch } : n) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update ColorIsolate helpers and rebuild the color_filter sent to backend.
|
||||||
|
function updateColorIsolate(id, patch) {
|
||||||
|
const node = graphRef.current.nodes.find(n => n.id === id)
|
||||||
|
if (!node) return
|
||||||
|
const merged = { ...node, ...patch }
|
||||||
|
const color_filter = buildColorIsolateFilter(
|
||||||
|
merged.ci_color ?? '#e63946',
|
||||||
|
merged.ci_hue_tolerance ?? 20,
|
||||||
|
merged.ci_sat_min ?? 0.2,
|
||||||
|
merged.ci_val_min ?? 0.15,
|
||||||
|
)
|
||||||
|
updateNode(id, { ...patch, color_filter })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample a pixel from the source image at the given click position (screen coords).
|
||||||
|
function sampleColorAt(e, sourceNode) {
|
||||||
|
const c = sourceSampleCanvasRef.current
|
||||||
|
if (!c) return
|
||||||
|
const r = canvasRef.current.getBoundingClientRect()
|
||||||
|
const wx = (e.clientX - r.left - panRef.current.x) / zoomRef.current
|
||||||
|
const wy = (e.clientY - r.top - panRef.current.y) / zoomRef.current
|
||||||
|
// Node body: 8px h-padding, 36px header, 6px v-padding before image
|
||||||
|
const imgW = nodeWidth - 16
|
||||||
|
const imgH = imgW * (c.height / c.width)
|
||||||
|
const fracX = (wx - sourceNode.x - 8) / imgW
|
||||||
|
const fracY = (wy - sourceNode.y - 36 - 6) / imgH
|
||||||
|
if (fracX < 0 || fracX > 1 || fracY < 0 || fracY > 1) return
|
||||||
|
const px = Math.floor(fracX * c.width)
|
||||||
|
const py = Math.floor(fracY * c.height)
|
||||||
|
const d = c.getContext('2d').getImageData(px, py, 1, 1).data
|
||||||
|
const hex = '#' + [d[0], d[1], d[2]].map(v => v.toString(16).padStart(2, '0')).join('')
|
||||||
|
const nodeId = sampleNodeIdRef.current
|
||||||
|
if (nodeId) updateColorIsolate(nodeId, { ci_color: hex })
|
||||||
|
setSampleNodeId(null)
|
||||||
|
}
|
||||||
function deleteNode(id) {
|
function deleteNode(id) {
|
||||||
const g = graphRef.current
|
const g = graphRef.current
|
||||||
onChangeRef.current({
|
onChangeRef.current({
|
||||||
@@ -186,12 +348,13 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
function addNode(kind) {
|
function addNode(kind) {
|
||||||
const r = canvasRef.current.getBoundingClientRect()
|
const r = canvasRef.current.getBoundingClientRect()
|
||||||
const p = panRef.current; const z = zoomRef.current
|
const p = panRef.current; const z = zoomRef.current
|
||||||
const x = (r.width / 2 - p.x) / z - NODE_W / 2
|
const x = (r.width / 2 - p.x) / z - nodeWidth / 2
|
||||||
const y = (r.height / 2 - p.y) / z - 60
|
const y = (r.height / 2 - p.y) / z - 60
|
||||||
const id = newNodeId(kind)
|
const id = newNodeId(kind)
|
||||||
const node = kind === 'Kernel' ? { id, kind, x, y, ...defaultKernelProps() }
|
const node = kind === 'Kernel' ? { id, kind, x, y, ...defaultKernelProps() }
|
||||||
: kind === 'Hull' ? { id, kind, x, y, ...defaultHullParams() }
|
: kind === 'Hull' ? { id, kind, x, y, ...defaultHullParams() }
|
||||||
: kind === 'Fill' ? { id, kind, x, y, ...defaultFillParams() }
|
: kind === 'Fill' ? { id, kind, x, y, ...defaultFillParams() }
|
||||||
|
: kind === 'PenOutput' ? { id, kind, x, y, ...defaultPenOutputParams() }
|
||||||
: { id, kind, x, y, blend_mode: 'Average', inputCount: 2 }
|
: { id, kind, x, y, blend_mode: 'Average', inputCount: 2 }
|
||||||
const g = graphRef.current
|
const g = graphRef.current
|
||||||
onChangeRef.current({ ...g, nodes: [...g.nodes, node] })
|
onChangeRef.current({ ...g, nodes: [...g.nodes, node] })
|
||||||
@@ -225,33 +388,40 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
function renderNode(node) {
|
function renderNode(node) {
|
||||||
const isFixed = node.kind === 'Source'
|
const isFixed = node.kind === 'Source'
|
||||||
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2)
|
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2)
|
||||||
: (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull' || node.kind === 'Fill') ? 1 : 0
|
: (node.kind === 'Kernel' || node.kind === 'Output' || node.kind === 'Hull' || node.kind === 'Fill' || node.kind === 'PenOutput') ? 1 : 0
|
||||||
const hasOut = node.kind !== 'Output' && node.kind !== 'Hull' && node.kind !== 'Fill'
|
const hasOut = node.kind !== 'Output' && node.kind !== 'PenOutput'
|
||||||
|
|
||||||
const preview = node.kind === 'Source' ? sourceImageB64 : nodePreviews?.[node.id]
|
const preview = node.kind === 'Source' ? sourceImageB64 : nodePreviews?.[node.id]
|
||||||
|
|
||||||
const accentColor = node.kind === 'Source' ? '#7c3aed'
|
const accentColor = node.kind === 'Source' ? '#7c3aed'
|
||||||
: node.kind === 'Hull' ? '#0d9488'
|
: node.kind === 'Hull' ? '#0d9488'
|
||||||
: node.kind === 'Fill' ? '#9333ea'
|
: node.kind === 'Fill' ? '#9333ea'
|
||||||
|
: node.kind === 'PenOutput' ? '#d97706'
|
||||||
: '#374151'
|
: '#374151'
|
||||||
const headerBg = node.kind === 'Source' ? '#2e1065'
|
const headerBg = node.kind === 'Source' ? '#2e1065'
|
||||||
: node.kind === 'Hull' ? '#042f2e'
|
: node.kind === 'Hull' ? '#042f2e'
|
||||||
: node.kind === 'Fill' ? '#3b0764'
|
: node.kind === 'Fill' ? '#3b0764'
|
||||||
|
: node.kind === 'PenOutput' ? '#451a03'
|
||||||
: '#1e293b'
|
: '#1e293b'
|
||||||
|
|
||||||
|
// Determine if a pending wire (drag or click) is active
|
||||||
|
const wireActive = !!(wireRef.current || clickWireRef.current) || !!wire
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id} style={{ position: 'absolute', left: node.x, top: node.y, width: NODE_W }}>
|
<div key={node.id} style={{ position: 'absolute', left: node.x, top: node.y, width: nodeWidth }}>
|
||||||
|
|
||||||
{/* Input ports */}
|
{/* Input ports */}
|
||||||
{Array.from({ length: inputCnt }, (_, i) => (
|
{Array.from({ length: inputCnt }, (_, i) => (
|
||||||
<div key={i}
|
<div key={i}
|
||||||
onMouseUp={e => endWire(e, node.id, i)}
|
onMouseUp={e => endWire(e, node.id, i)}
|
||||||
|
onClick={e => endClickWire(e, node.id, i)}
|
||||||
|
onDoubleClick={e => disconnectInputPort(e, node.id, i)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R,
|
position: 'absolute', left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R,
|
||||||
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
|
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
|
||||||
background: wire ? '#14b8a6' : '#1e3a3a',
|
background: wireActive ? '#14b8a6' : '#1e3a3a',
|
||||||
border: `2px solid #14b8a6`, cursor: 'crosshair',
|
border: `2px solid #14b8a6`, cursor: 'crosshair',
|
||||||
boxShadow: wire ? '0 0 8px #14b8a6aa' : 'none',
|
boxShadow: wireActive ? '0 0 8px #14b8a6aa' : 'none',
|
||||||
transition: 'background 0.12s, box-shadow 0.12s',
|
transition: 'background 0.12s, box-shadow 0.12s',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -259,7 +429,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
|
|
||||||
{/* Output port */}
|
{/* Output port */}
|
||||||
{hasOut && (
|
{hasOut && (
|
||||||
<div onMouseDown={e => startWire(e, node.id)}
|
<div
|
||||||
|
onMouseDown={e => startWire(e, node.id)}
|
||||||
|
onClick={e => startClickWire(e, node.id)}
|
||||||
|
onDoubleClick={e => disconnectOutputPort(e, node.id)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', right: -PORT_R, top: PORT_TOP - PORT_R,
|
position: 'absolute', right: -PORT_R, top: PORT_TOP - PORT_R,
|
||||||
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
|
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
|
||||||
@@ -284,6 +457,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
{node.kind === 'Source' ? 'Source'
|
{node.kind === 'Source' ? 'Source'
|
||||||
: node.kind === 'Hull' ? 'Hull'
|
: node.kind === 'Hull' ? 'Hull'
|
||||||
: node.kind === 'Fill' ? (node.strategy ?? 'Fill')
|
: node.kind === 'Fill' ? (node.strategy ?? 'Fill')
|
||||||
|
: node.kind === 'PenOutput' ? (node.pen_label || 'Pen')
|
||||||
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
|
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
|
||||||
: node.kind === 'Combine' ? 'Combine'
|
: node.kind === 'Combine' ? 'Combine'
|
||||||
: 'Output'}
|
: 'Output'}
|
||||||
@@ -310,6 +484,39 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
>{k}</button>
|
>{k}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{node.kernel === 'ColorIsolate' ? (
|
||||||
|
<div onMouseDown={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{/* Color target + eyedropper */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input type="color"
|
||||||
|
value={node.ci_color ?? '#e63946'}
|
||||||
|
onChange={e => updateColorIsolate(node.id, { ci_color: e.target.value })}
|
||||||
|
style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent', padding: 0, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 10, color: '#94a3b8', flex: 1 }}>Target color</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSampleNodeId(id => id === node.id ? null : node.id)}
|
||||||
|
title="Click to eyedrop a color from the source image"
|
||||||
|
style={{
|
||||||
|
width: 24, height: 24, borderRadius: 4, border: 'none', cursor: 'pointer', fontSize: 13,
|
||||||
|
background: sampleNodeId === node.id ? '#4f46e5' : '#1e293b',
|
||||||
|
color: sampleNodeId === node.id ? '#fff' : '#94a3b8',
|
||||||
|
}}
|
||||||
|
>🔍</button>
|
||||||
|
</div>
|
||||||
|
{sampleNodeId === node.id && (
|
||||||
|
<div style={{ fontSize: 9, color: '#818cf8', fontStyle: 'italic' }}>
|
||||||
|
Click a pixel on the Source image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Slider label="Hue ±" value={node.ci_hue_tolerance ?? 20} min={1} max={90} step={1} unit="°"
|
||||||
|
onChange={v => updateColorIsolate(node.id, { ci_hue_tolerance: v })} />
|
||||||
|
<Slider label="Min sat" value={node.ci_sat_min ?? 0.2} min={0} max={1} step={0.01}
|
||||||
|
onChange={v => updateColorIsolate(node.id, { ci_sat_min: v })} />
|
||||||
|
<Slider label="Min brightness" value={node.ci_val_min ?? 0.15} min={0} max={1} step={0.01}
|
||||||
|
onChange={v => updateColorIsolate(node.id, { ci_val_min: v })} />
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
<Slider label="Weight" value={node.weight ?? 1} min={0} max={2} step={0.05}
|
<Slider label="Weight" value={node.weight ?? 1} min={0} max={2} step={0.05}
|
||||||
onChange={v => updateNode(node.id, { weight: v })} />
|
onChange={v => updateNode(node.id, { weight: v })} />
|
||||||
{(KERNEL_PARAMS[node.kernel] ?? []).map(p => {
|
{(KERNEL_PARAMS[node.kernel] ?? []).map(p => {
|
||||||
@@ -325,6 +532,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
Invert
|
Invert
|
||||||
</label>
|
</label>
|
||||||
</>)}
|
</>)}
|
||||||
|
</>)}
|
||||||
|
|
||||||
{node.kind === 'Combine' && (<>
|
{node.kind === 'Combine' && (<>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||||
@@ -368,10 +576,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div onMouseDown={e => e.stopPropagation()}>
|
<div onMouseDown={e => e.stopPropagation()}>
|
||||||
<ColorFilter
|
{/* Color isolation is now handled by a ColorIsolate Kernel node upstream */}
|
||||||
filter={node.color_filter ?? defaultColorFilter()}
|
|
||||||
onChange={cf => updateNode(node.id, { color_filter: cf })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
@@ -412,11 +617,54 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
onChange={v => updateNode(node.id, { smooth_iters: v })} />
|
onChange={v => updateNode(node.id, { smooth_iters: v })} />
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{/* Preview thumbnail */}
|
{node.kind === 'PenOutput' && (<>
|
||||||
{preview && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<img src={`data:image/jpeg;base64,${preview}`} alt="" draggable={false}
|
<input type="color"
|
||||||
style={{ width: '100%', borderRadius: 4, marginTop: 2, display: 'block' }}
|
value={'#' + (node.pen_color ?? [20,20,20]).map(c => c.toString(16).padStart(2,'0')).join('')}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onChange={e => {
|
||||||
|
const h = e.target.value.slice(1)
|
||||||
|
updateNode(node.id, { pen_color: [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)] })
|
||||||
|
}}
|
||||||
|
style={{ width: 24, height: 24, border: 'none', cursor: 'pointer', background: 'transparent', borderRadius: 4, padding: 0 }}
|
||||||
/>
|
/>
|
||||||
|
<input type="text"
|
||||||
|
value={node.pen_label ?? ''}
|
||||||
|
placeholder="Pen name"
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onChange={e => updateNode(node.id, { pen_label: e.target.value })}
|
||||||
|
style={{ flex: 1, background: 'transparent', border: 'none', borderBottom: '1px solid #374151', color: '#e2e8f0', fontSize: 11, outline: 'none', padding: '1px 0' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 10, color: '#6b7280' }}>Export order</span>
|
||||||
|
<input type="number" min={0} max={99}
|
||||||
|
value={node.pen_order ?? 0}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onChange={e => updateNode(node.id, { pen_order: Math.max(0, parseInt(e.target.value) || 0) })}
|
||||||
|
style={{ width: 44, background: '#1e293b', border: '1px solid #374151', borderRadius: 3, color: '#e2e8f0', fontSize: 10, padding: '2px 4px', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* Preview thumbnail — Source node gets eyedropper overlay in sample mode */}
|
||||||
|
{preview && (
|
||||||
|
<div style={{ position: 'relative', marginTop: 2 }}>
|
||||||
|
<img src={`data:image/jpeg;base64,${preview}`} alt="" draggable={false}
|
||||||
|
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
||||||
|
/>
|
||||||
|
{node.kind === 'Source' && sampleNodeId && (
|
||||||
|
<div
|
||||||
|
onClick={e => { e.stopPropagation(); sampleColorAt(e, node) }}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: 4, cursor: 'crosshair', zIndex: 20,
|
||||||
|
background: 'rgba(99,102,241,0.12)',
|
||||||
|
boxShadow: 'inset 0 0 0 2px #6366f1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,12 +679,13 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
return (
|
return (
|
||||||
<div ref={canvasRef}
|
<div ref={canvasRef}
|
||||||
onMouseDown={onCanvasMouseDown}
|
onMouseDown={onCanvasMouseDown}
|
||||||
|
onClick={onCanvasClick}
|
||||||
data-canvas="1"
|
data-canvas="1"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: '100%', height: '100%',
|
position: 'relative', width: '100%', height: '100%',
|
||||||
overflow: 'hidden', background: '#0a0a14',
|
overflow: 'hidden', background: '#0a0a14',
|
||||||
userSelect: 'none', WebkitUserSelect: 'none',
|
userSelect: 'none', WebkitUserSelect: 'none',
|
||||||
cursor: panDragRef.current ? 'grabbing' : 'default',
|
cursor: sampleNodeId ? 'crosshair' : panDragRef.current ? 'grabbing' : 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@@ -446,21 +695,22 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
['Combine', '#374151', '#94a3b8'],
|
['Combine', '#374151', '#94a3b8'],
|
||||||
['Hull', '#0d9488', '#5eead4'],
|
['Hull', '#0d9488', '#5eead4'],
|
||||||
['Fill', '#7c3aed', '#c4b5fd'],
|
['Fill', '#7c3aed', '#c4b5fd'],
|
||||||
|
['PenOutput', '#d97706', '#fcd34d'],
|
||||||
].map(([kind, border, color]) => (
|
].map(([kind, border, color]) => (
|
||||||
<button key={kind} onClick={() => addNode(kind)}
|
<button key={kind} onClick={() => addNode(kind)}
|
||||||
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: `1px solid ${border}`, background: '#1e293b', color }}
|
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: `1px solid ${border}`, background: '#1e293b', color }}
|
||||||
>+ {kind}</button>
|
>{kind === 'PenOutput' ? '+ Pen' : `+ ${kind}`}</button>
|
||||||
))}
|
))}
|
||||||
<span style={{ fontSize: 10, color: '#2d3748', paddingLeft: 4 }}>
|
<span style={{ fontSize: 10, color: '#2d3748', paddingLeft: 4 }}>
|
||||||
scroll=zoom · drag=pan · click wire=delete
|
scroll=zoom · drag=pan · click wire=delete
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* World transform */}
|
{/* World transform — driven imperatively via worldRef to avoid React re-renders on pan/zoom */}
|
||||||
<div data-canvas="1"
|
<div ref={worldRef} data-canvas="1"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', left: 0, top: 0, width: WORLD, height: WORLD,
|
position: 'absolute', left: 0, top: 0, width: WORLD, height: WORLD,
|
||||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
transform: `translate(${panRef.current.x}px, ${panRef.current.y}px) scale(${zoomRef.current})`,
|
||||||
transformOrigin: '0 0',
|
transformOrigin: '0 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -472,7 +722,7 @@ export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB6
|
|||||||
if (!fn_ || !tn) return null
|
if (!fn_ || !tn) return null
|
||||||
return (
|
return (
|
||||||
<path key={idx}
|
<path key={idx}
|
||||||
d={bezier(outPort(fn_), inPort(tn, edge.port))}
|
d={bezier(outPort(fn_, nodeWidth), inPort(tn, edge.port))}
|
||||||
stroke="#6366f1" strokeWidth={1.5} fill="none" opacity={0.75}
|
stroke="#6366f1" strokeWidth={1.5} fill="none" opacity={0.75}
|
||||||
strokeLinecap="round" style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
|
strokeLinecap="round" style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
|
||||||
onClick={() => removeEdge(idx)}
|
onClick={() => removeEdge(idx)}
|
||||||
|
|||||||
@@ -1,36 +1,6 @@
|
|||||||
export default function PassPanel({
|
export default function PassPanel({ pass }) {
|
||||||
pass, onChange,
|
|
||||||
onDetectionChange,
|
|
||||||
}) {
|
|
||||||
function set(patch) { onChange({ ...pass, ...patch }) }
|
|
||||||
function setDetection(patch) { onChange({ ...pass, ...patch }); onDetectionChange?.() }
|
|
||||||
|
|
||||||
const colorHex = '#' + pass.penColor.map(c => c.toString(16).padStart(2, '0')).join('')
|
|
||||||
const isProcessing = pass.status === 'Processing…'
|
|
||||||
const isFilling = pass.status === 'Generating fill…'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* Pass header */}
|
|
||||||
<div className="px-3 py-2 flex items-center gap-2 border-b border-neutral-800">
|
|
||||||
<input type="color" value={colorHex}
|
|
||||||
onChange={e => {
|
|
||||||
const h = e.target.value.slice(1)
|
|
||||||
set({ penColor: [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)] })
|
|
||||||
}}
|
|
||||||
className="w-6 h-6 rounded cursor-pointer border-0 bg-transparent"
|
|
||||||
/>
|
|
||||||
<input type="text" value={pass.label}
|
|
||||||
onChange={e => set({ label: e.target.value })}
|
|
||||||
className="flex-1 bg-transparent text-neutral-200 text-sm font-medium outline-none border-b border-transparent focus:border-neutral-600"
|
|
||||||
/>
|
|
||||||
{(isProcessing || isFilling) && (
|
|
||||||
<span className="text-indigo-400 text-xs animate-pulse shrink-0">
|
|
||||||
{isProcessing ? 'detecting…' : 'filling…'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="px-3 py-2 min-h-8">
|
<div className="px-3 py-2 min-h-8">
|
||||||
<p className={`text-xs ${pass.status?.startsWith('Error') ? 'text-red-400' : 'text-neutral-500'}`}>
|
<p className={`text-xs ${pass.status?.startsWith('Error') ? 'text-red-400' : 'text-neutral-500'}`}>
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
const { zoom, pan } = stateRef.current
|
const { zoom, pan } = stateRef.current
|
||||||
const W = canvas.width
|
const W = canvas.width
|
||||||
const H = canvas.height
|
const H = canvas.height
|
||||||
const iw = imgSize?.width ?? 512
|
// gcode/fill views use the scaled pipeline dimensions from strokes payload;
|
||||||
const ih = imgSize?.height ?? 512
|
// 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 fit = Math.min(W / iw, H / ih) * 0.92
|
||||||
const scale = fit * zoom
|
const scale = fit * zoom
|
||||||
const ox = W / 2 - iw * scale / 2 + pan.x
|
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)
|
drawPaperOutline(ctx, iw, ih, scale, ox, oy)
|
||||||
} else {
|
} 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,
|
// so that negative ox/oy when zoomed in are handled correctly by the canvas,
|
||||||
// not clipped by the parent's overflow:hidden.
|
// not clipped by the parent's overflow:hidden.
|
||||||
if (svgImg) svgImg.style.display = 'none'
|
if (svgImg) svgImg.style.display = 'none'
|
||||||
@@ -89,8 +92,8 @@ export default function Viewport({ imageB64, strokes, imgSize, viewMode, gcodeCo
|
|||||||
ctx.fillStyle = '#1a1a1a'
|
ctx.fillStyle = '#1a1a1a'
|
||||||
ctx.fillRect(ox, oy, iw * scale, ih * scale)
|
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) {
|
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 }))
|
p.strokes.map(s => ({ color: p.color, points: s }))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create offscreen canvas at 4× image resolution so zooming in stays sharp.
|
// Offscreen canvas sized to stroke coordinate space (pipeline dims, after DPI scaling).
|
||||||
// octx.scale(4,4) keeps all stroke coordinates in image-pixel space unchanged.
|
const sw = strokes.img_width ?? imgSize.width
|
||||||
|
const sh = strokes.img_height ?? imgSize.height
|
||||||
const off = document.createElement('canvas')
|
const off = document.createElement('canvas')
|
||||||
off.width = imgSize.width * 4
|
off.width = sw * 4
|
||||||
off.height = imgSize.height * 4
|
off.height = sh * 4
|
||||||
const octx = off.getContext('2d')
|
const octx = off.getContext('2d')
|
||||||
octx.fillStyle = '#f5f0e8'
|
octx.fillStyle = '#f5f0e8'
|
||||||
octx.fillRect(0, 0, off.width, off.height)
|
octx.fillRect(0, 0, off.width, off.height)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
|
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
|
// Wraps invoke with response-size logging so IPC payload bloat is visible in console
|
||||||
async function tracedInvoke(name, args) {
|
async function tracedInvoke(name, args) {
|
||||||
@@ -21,24 +22,20 @@ export async function processPass(payload) {
|
|||||||
return tracedInvoke('process_pass', { payload })
|
return tracedInvoke('process_pass', { payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllStrokes(passColors) {
|
export async function getAllStrokes() {
|
||||||
return tracedInvoke('get_all_strokes', { passColors })
|
return tracedInvoke('get_all_strokes', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGcodeViz(passColors) {
|
export async function getGcodeViz() {
|
||||||
return tracedInvoke('get_gcode_viz', { passColors })
|
return tracedInvoke('get_gcode_viz', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPassViz(passIndex, mode) {
|
export async function getPassViz(passIndex, mode) {
|
||||||
return tracedInvoke('get_pass_viz', { passIndex, mode })
|
return tracedInvoke('get_pass_viz', { passIndex, mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportGcode(passIndex, gcodeConfig) {
|
export async function exportAllGcode(gcodeConfig, outDir) {
|
||||||
return tracedInvoke('export_gcode', { passIndex, gcodeConfig })
|
return tracedInvoke('export_all_gcode', { gcodeConfig, outDir })
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportAllGcode(passColors, gcodeConfig, outDir) {
|
|
||||||
return tracedInvoke('export_all_gcode', { passColors, gcodeConfig, outDir })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportDebugState(passConfigs) {
|
export async function exportDebugState(passConfigs) {
|
||||||
@@ -64,3 +61,27 @@ export async function pickSaveFile(defaultName) {
|
|||||||
export async function pickFolder() {
|
export async function pickFolder() {
|
||||||
return openDialog({ directory: true })
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
78
src-frontend/src/project.js
Normal file
78
src-frontend/src/project.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
605
src-frontend/src/project.test.js
Normal file
605
src-frontend/src/project.test.js
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// Central app state — plain React state lifted to App.jsx.
|
// Central app state — plain React state lifted to App.jsx.
|
||||||
// This file defines the shapes / defaults.
|
// 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 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.
|
// Per-strategy secondary parameter exposed as a slider.
|
||||||
// Strategies not listed here have no secondary parameter.
|
// Strategies not listed here have no secondary parameter.
|
||||||
@@ -17,23 +17,66 @@ export const FILL_STRATEGY_PARAMS = {
|
|||||||
hint: '0 = straight lines · 1 = default ±45° · 2 = wild curves' },
|
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,
|
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' },
|
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
|
// 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() {
|
export function defaultKernelProps() {
|
||||||
|
const ci_color = '#e63946', ci_hue_tolerance = 20, ci_sat_min = 0.2, ci_val_min = 0.15
|
||||||
return {
|
return {
|
||||||
kernel: 'Luminance', weight: 1.0, invert: false,
|
kernel: 'Luminance', weight: 1.0, invert: false,
|
||||||
blur_radius: 0.0, sat_min_value: 0.1,
|
blur_radius: 0.0, sat_min_value: 0.1,
|
||||||
canny_low: 50.0, canny_high: 150.0,
|
canny_low: 50.0, canny_high: 150.0,
|
||||||
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.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
|
let _nodeSeq = 0
|
||||||
export function newNodeId(kind) { return `${kind.toLowerCase()}_${++_nodeSeq}` }
|
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() {
|
export function defaultColorFilter() {
|
||||||
return { enabled: false, hue_min: 0, hue_max: 360, sat_min: 0, sat_max: 1, val_min: 0, val_max: 1 }
|
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() {
|
export function defaultHullParams() {
|
||||||
return {
|
return {
|
||||||
threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
|
threshold: 128, min_area: 4, rdp_epsilon: 1.5, connectivity: 'four',
|
||||||
@@ -60,20 +107,19 @@ export function defaultGraph() {
|
|||||||
{ id: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() },
|
{ id: kId, kind: 'Kernel', x: 310, y: 100, ...defaultKernelProps() },
|
||||||
{ id: 'hull', kind: 'Hull', x: 560, y: 160, ...defaultHullParams() },
|
{ 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: [
|
edges: [
|
||||||
{ from: 'source', to: kId, port: 0 },
|
{ from: 'source', to: kId, port: 0 },
|
||||||
{ from: kId, to: 'hull', port: 0 },
|
{ from: kId, to: 'hull', port: 0 },
|
||||||
{ from: 'hull', to: 'fill', port: 0 },
|
{ from: 'hull', to: 'fill', port: 0 },
|
||||||
|
{ from: 'fill', to: 'pen1', port: 0 },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultPass(index) {
|
export function defaultPass(index) {
|
||||||
const colors = [[20,20,20],[60,100,220],[200,60,60]]
|
|
||||||
return {
|
return {
|
||||||
label: `Pass ${index + 1}`,
|
|
||||||
penColor: colors[index] ?? [128,128,128],
|
|
||||||
graph: defaultGraph(),
|
graph: defaultGraph(),
|
||||||
nodePreviews: {},
|
nodePreviews: {},
|
||||||
// runtime
|
// runtime
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.js'],
|
||||||
|
},
|
||||||
// Tauri expects a fixed port and doesn't use HTTPS in dev
|
// Tauri expects a fixed port and doesn't use HTTPS in dev
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub enum DetectionKernel {
|
|||||||
Canny, // thinned 1-px edges via Gaussian + Sobel NMS + hysteresis
|
Canny, // thinned 1-px edges via Gaussian + Sobel NMS + hysteresis
|
||||||
Saturation, // HSV saturation (vivid-colour regions as ink)
|
Saturation, // HSV saturation (vivid-colour regions as ink)
|
||||||
XDoG, // Extended Difference of Gaussians — coherent edges from photos
|
XDoG, // Extended Difference of Gaussians — coherent edges from photos
|
||||||
|
ColorIsolate, // HSV range selection: 0=selected colour, 255=not selected
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DetectionKernel {
|
impl DetectionKernel {
|
||||||
@@ -24,12 +25,13 @@ impl DetectionKernel {
|
|||||||
DetectionKernel::Canny => "Canny",
|
DetectionKernel::Canny => "Canny",
|
||||||
DetectionKernel::Saturation => "Saturation",
|
DetectionKernel::Saturation => "Saturation",
|
||||||
DetectionKernel::XDoG => "XDoG",
|
DetectionKernel::XDoG => "XDoG",
|
||||||
|
DetectionKernel::ColorIsolate => "ColorIsolate",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all() -> &'static [DetectionKernel] {
|
pub fn all() -> &'static [DetectionKernel] {
|
||||||
use 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_sigma2: f32,
|
||||||
pub xdog_tau: f32,
|
pub xdog_tau: f32,
|
||||||
pub xdog_phi: 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 {
|
impl Default for DetectionLayer {
|
||||||
@@ -65,6 +71,9 @@ impl Default for DetectionLayer {
|
|||||||
xdog_sigma2: 1.6,
|
xdog_sigma2: 1.6,
|
||||||
xdog_tau: 0.98,
|
xdog_tau: 0.98,
|
||||||
xdog_phi: 10.0,
|
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<u8> {
|
|||||||
DetectionKernel::Canny => canny(rgb, layer.canny_low, layer.canny_high),
|
DetectionKernel::Canny => canny(rgb, layer.canny_low, layer.canny_high),
|
||||||
DetectionKernel::Saturation => invert(saturation_filtered(rgb, sigma, layer.sat_min_value)),
|
DetectionKernel::Saturation => invert(saturation_filtered(rgb, sigma, layer.sat_min_value)),
|
||||||
DetectionKernel::XDoG => xdog(rgb, sigma.max(0.1), layer.xdog_sigma2.max(sigma + 0.1), layer.xdog_tau, layer.xdog_phi),
|
DetectionKernel::XDoG => xdog(rgb, sigma.max(0.1), layer.xdog_sigma2.max(sigma + 0.1), layer.xdog_tau, layer.xdog_phi),
|
||||||
|
DetectionKernel::ColorIsolate => color_isolate(rgb, layer),
|
||||||
};
|
};
|
||||||
if layer.invert {
|
if layer.invert {
|
||||||
for v in &mut response { *v = 255 - *v; }
|
for v in &mut response { *v = 255 - *v; }
|
||||||
@@ -276,6 +286,42 @@ fn saturation_filtered(rgb: &RgbImage, sigma: f32, min_value: f32) -> Vec<u8> {
|
|||||||
blurred.into_iter().map(|v| (v * 255.0) as u8).collect()
|
blurred.into_iter().map(|v| (v * 255.0) as u8).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ColorIsolate ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Select pixels whose HSV values fall within the specified ranges.
|
||||||
|
/// Output: 0 = selected (ink), 255 = not selected (background).
|
||||||
|
/// Hue wraps: if hue_min > hue_max the range crosses 0° (e.g. 330–30 captures reds).
|
||||||
|
fn color_isolate(rgb: &RgbImage, layer: &DetectionLayer) -> Vec<u8> {
|
||||||
|
rgb.pixels().map(|p| {
|
||||||
|
let r = p[0] as f32 / 255.0;
|
||||||
|
let g = p[1] as f32 / 255.0;
|
||||||
|
let b = p[2] as f32 / 255.0;
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let delta = max - min;
|
||||||
|
let h = if delta < 1e-6 {
|
||||||
|
0.0_f32
|
||||||
|
} else if (max - r).abs() < 1e-6 {
|
||||||
|
60.0 * ((g - b) / delta).rem_euclid(6.0)
|
||||||
|
} else if (max - g).abs() < 1e-6 {
|
||||||
|
60.0 * ((b - r) / delta + 2.0)
|
||||||
|
} else {
|
||||||
|
60.0 * ((r - g) / delta + 4.0)
|
||||||
|
};
|
||||||
|
let s = if max < 1e-6 { 0.0 } else { delta / max };
|
||||||
|
let v = max;
|
||||||
|
let in_hue = if layer.ci_hue_min <= layer.ci_hue_max {
|
||||||
|
h >= layer.ci_hue_min && h <= layer.ci_hue_max
|
||||||
|
} else {
|
||||||
|
h >= layer.ci_hue_min || h <= layer.ci_hue_max
|
||||||
|
};
|
||||||
|
let selected = in_hue
|
||||||
|
&& s >= layer.ci_sat_min && s <= layer.ci_sat_max
|
||||||
|
&& v >= layer.ci_val_min && v <= layer.ci_val_max;
|
||||||
|
if selected { 0u8 } else { 255u8 }
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
// ── XDoG ───────────────────────────────────────────────────────────────────────
|
// ── XDoG ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Extended Difference of Gaussians.
|
/// Extended Difference of Gaussians.
|
||||||
@@ -442,6 +488,11 @@ pub enum NodeKind {
|
|||||||
smooth_rdp: f32,
|
smooth_rdp: f32,
|
||||||
smooth_iters: u32,
|
smooth_iters: u32,
|
||||||
},
|
},
|
||||||
|
PenOutput {
|
||||||
|
color: [u8; 3],
|
||||||
|
label: String,
|
||||||
|
order: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -530,9 +581,22 @@ pub fn evaluate_graph(
|
|||||||
let result: Option<Vec<u8>> = match &node.kind {
|
let result: Option<Vec<u8>> = match &node.kind {
|
||||||
NodeKind::Source => None,
|
NodeKind::Source => None,
|
||||||
NodeKind::Kernel(layer) => {
|
NodeKind::Kernel(layer) => {
|
||||||
let raw = apply_layer(rgb, layer);
|
// If an upstream response map exists (e.g. from a Combine node),
|
||||||
|
// convert it to a grayscale RgbImage and apply the kernel to that
|
||||||
|
// instead of the original source. This lets you chain transforms:
|
||||||
|
// Luminance → Combine → Sobel finds edges in the blended map.
|
||||||
|
let upstream = incoming[id].iter()
|
||||||
|
.find_map(|(fid, _)| outputs.get(fid));
|
||||||
|
let raw = if let Some(up) = upstream {
|
||||||
|
let gray_rgb = RgbImage::from_fn(rgb.width(), rgb.height(), |x, y| {
|
||||||
|
let v = up[(y * rgb.width() + x) as usize];
|
||||||
|
image::Rgb([v, v, v])
|
||||||
|
});
|
||||||
|
apply_layer(&gray_rgb, layer)
|
||||||
|
} else {
|
||||||
|
apply_layer(rgb, layer)
|
||||||
|
};
|
||||||
let w = layer.weight;
|
let w = layer.weight;
|
||||||
// w=0 → full background, w=1 → identity, w>1 → amplify toward ink
|
|
||||||
Some(if (w - 1.0).abs() < 1e-6 {
|
Some(if (w - 1.0).abs() < 1e-6 {
|
||||||
raw
|
raw
|
||||||
} else {
|
} else {
|
||||||
@@ -562,6 +626,8 @@ pub fn evaluate_graph(
|
|||||||
}
|
}
|
||||||
// Fill nodes are processed in lib.rs after hull extraction.
|
// Fill nodes are processed in lib.rs after hull extraction.
|
||||||
NodeKind::Fill { .. } => None,
|
NodeKind::Fill { .. } => None,
|
||||||
|
// PenOutput nodes are processed in lib.rs — they own a fill result and carry pen metadata.
|
||||||
|
NodeKind::PenOutput { .. } => None,
|
||||||
};
|
};
|
||||||
if let Some(map) = result {
|
if let Some(map) = result {
|
||||||
outputs.insert(id, map);
|
outputs.insert(id, map);
|
||||||
@@ -577,7 +643,7 @@ pub fn evaluate_graph(
|
|||||||
|
|
||||||
// Final response: prefer an explicit Output node; fall back to the upstream
|
// Final response: prefer an explicit Output node; fall back to the upstream
|
||||||
// map of the first Hull node (which was stored under the Hull node's id).
|
// map of the first Hull node (which was stored under the Hull node's id).
|
||||||
// Fill nodes produce no output here.
|
// Fill and PenOutput nodes produce no output here.
|
||||||
let response = graph.nodes.iter()
|
let response = graph.nodes.iter()
|
||||||
.find(|n| matches!(n.kind, NodeKind::Output))
|
.find(|n| matches!(n.kind, NodeKind::Output))
|
||||||
.and_then(|n| raw_maps.get(&n.id).cloned())
|
.and_then(|n| raw_maps.get(&n.id).cloned())
|
||||||
|
|||||||
19
src/fill.rs
19
src/fill.rs
@@ -167,6 +167,24 @@ pub fn gradient_hatch(
|
|||||||
FillResult { hull_id: hull.id, strokes }
|
FillResult { hull_id: hull.id, strokes }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two perpendicular gradient-hatch passes combined.
|
||||||
|
/// Dark areas get dense lines in both directions; light areas get sparse lines in both.
|
||||||
|
pub fn gradient_cross_hatch(
|
||||||
|
hull: &Hull,
|
||||||
|
response: &[u8],
|
||||||
|
img_width: u32,
|
||||||
|
spacing_px: f32,
|
||||||
|
angle_deg: f32,
|
||||||
|
min_scale: f32,
|
||||||
|
) -> FillResult {
|
||||||
|
let pass1 = gradient_hatch(hull, response, img_width, spacing_px, angle_deg, min_scale);
|
||||||
|
let pass2 = gradient_hatch(hull, response, img_width, spacing_px, angle_deg + 90.0, min_scale);
|
||||||
|
FillResult {
|
||||||
|
hull_id: hull.id,
|
||||||
|
strokes: pass1.strokes.into_iter().chain(pass2.strokes).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Outline ────────────────────────────────────────────────────────────────────
|
// ── Outline ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The simplified contour as a single closed stroke.
|
/// The simplified contour as a single closed stroke.
|
||||||
@@ -1599,6 +1617,7 @@ mod tests {
|
|||||||
xdog_sigma2: lj["xdog_sigma2"].as_f64().unwrap_or(1.6) as f32,
|
xdog_sigma2: lj["xdog_sigma2"].as_f64().unwrap_or(1.6) as f32,
|
||||||
xdog_tau: lj["xdog_tau"].as_f64().unwrap_or(0.98) as f32,
|
xdog_tau: lj["xdog_tau"].as_f64().unwrap_or(0.98) as f32,
|
||||||
xdog_phi: lj["xdog_phi"].as_f64().unwrap_or(10.0) as f32,
|
xdog_phi: lj["xdog_phi"].as_f64().unwrap_or(10.0) as f32,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let response = crate::detect::apply_stack(&img, &crate::detect::DetectionParams { layers: vec![layer] });
|
let response = crate::detect::apply_stack(&img, &crate::detect::DetectionParams { layers: vec![layer] });
|
||||||
let hull_params = crate::hulls::HullParams {
|
let hull_params = crate::hulls::HullParams {
|
||||||
|
|||||||
762
src/lib.rs
762
src/lib.rs
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ fn main() {
|
|||||||
blur_radius: 0.0, sat_min_value: 0.1,
|
blur_radius: 0.0, sat_min_value: 0.1,
|
||||||
canny_low: 50.0, canny_high: 150.0,
|
canny_low: 50.0, canny_high: 150.0,
|
||||||
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
|
xdog_sigma2: 1.6, xdog_tau: 0.98, xdog_phi: 10.0,
|
||||||
|
..Default::default()
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let response = apply_stack(&rgb, ¶ms);
|
let response = apply_stack(&rgb, ¶ms);
|
||||||
|
|||||||
Reference in New Issue
Block a user