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:
2026-04-27 17:57:08 -07:00
parent eef77a6f4c
commit 889ff386c5
16 changed files with 2458 additions and 504 deletions

View File

@@ -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",

View File

@@ -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"
} }
} }

View File

@@ -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>
) )

View File

@@ -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 (0360°)</div> <div className="text-neutral-500 text-xs mb-1">Hue (0360°)</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="°"

View File

@@ -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)}

View File

@@ -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'}`}>

View File

@@ -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)

View File

@@ -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 })
}

View 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,
}
}

View 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()
})
})

View File

@@ -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

View File

@@ -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,

View File

@@ -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. 33030 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())

View File

@@ -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 {

File diff suppressed because it is too large Load Diff

View File

@@ -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, &params); let response = apply_stack(&rgb, &params);