Compare commits
10 commits
f22a0c16df
...
845e92e56f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
845e92e56f | ||
|
|
3538d5ccb9 | ||
|
|
fac9168c00 | ||
|
|
dbb302f765 | ||
|
|
5d80273717 | ||
|
|
d1577dd569 | ||
|
|
1df5509b29 | ||
|
|
38d99bb0dc | ||
|
|
dd409f56c0 | ||
|
|
96221a217d |
18 changed files with 1176 additions and 3 deletions
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# G-Code Overlay
|
||||
|
||||
Overlays a G-code toolpath on the CNC router's live camera stream, so you can reality-check a job before/while it runs and see where the tool will and won't go (e.g. for placing screws/clamps).
|
||||
|
||||
Pure client-side static web app — TypeScript + Vite + Canvas 2D, no backend. The G-code file is parsed locally in the browser; nothing is uploaded.
|
||||
|
||||
## Develop
|
||||
|
||||
- `npm install`
|
||||
- `npm run dev` — local dev server
|
||||
- `npm test` — run the unit tests
|
||||
- `npm run build` — type-check + production build to `dist/`
|
||||
|
||||
## Deploy
|
||||
|
||||
Serve the built `dist/` as static files. Configure via `public/config.json` (bundled into the build):
|
||||
|
||||
- `streamUrl` — the camera stream URL. The page shows it in an `<img>` element, which works directly for an **MJPEG** stream. If the stream is **HLS** or **WebRTC**, swap the `<img id="stream">` in `index.html` for a `<video>` element and add the appropriate playback wiring (e.g. hls.js for HLS).
|
||||
- `calibration` — produced once via the in-app Calibration panel (see below). Paste the generated JSON here to persist it for all viewers. Calibration is stored in **normalized [0,1] camera-frame coordinates**, so a single calibration works for every viewer regardless of screen size.
|
||||
- `renderDefaults` — `cutColor`, `rapidColor`, `lineWidth` for the overlay.
|
||||
|
||||
## Use
|
||||
|
||||
1. Open a local G-code file with the **Open G-code** button.
|
||||
2. **Calibrate** (one-time, persisted in `config.json`): jog the CNC spindle to a known X/Y, enter those coordinates, then click the spindle tip in the video. Repeat for at least 4 well-spread points (near the table corners gives the best accuracy). Click **Compute homography** — the per-point error (px) flags any misclick. Paste the generated JSON into `public/config.json`.
|
||||
3. **Align** the toolpath to the material: drag it to move, Shift-drag to rotate, use **Set origin by clicking video**, or type a numeric X/Y/rotation. Then reality-check the overlay against the live cut.
|
||||
|
||||
## Notes
|
||||
|
||||
- A perspective (homography) transform assumes straight cuts stay straight. If a wide-angle camera bows straight lines, the calibration per-point error will reveal it; lens-distortion correction is a possible future addition.
|
||||
- Supported G-code: G0/G1/G2/G3 motion, G90/G91, G20/G21 units, arcs via I/J or R. Cutting moves are drawn solid, rapids dashed.
|
||||
13
index.html
13
index.html
|
|
@ -6,6 +6,19 @@
|
|||
<title>G-Code Overlay</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<img id="stream" alt="CNC camera stream" />
|
||||
<canvas id="overlay"></canvas>
|
||||
</div>
|
||||
<aside id="panel">
|
||||
<h1>G-Code Overlay</h1>
|
||||
<section>
|
||||
<label class="filebtn">Open G-code<input id="gcode-file" type="file" accept=".nc,.gcode,.tap,.txt,.ngc" hidden /></label>
|
||||
<p id="status">No file loaded.</p>
|
||||
</section>
|
||||
<section id="align-panel"></section>
|
||||
<section id="calib-panel"></section>
|
||||
</aside>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
495
package-lock.json
generated
495
package-lock.json
generated
|
|
@ -8,11 +8,205 @@
|
|||
"name": "gcode-overlay",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
||||
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
|
||||
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
|
|
@ -381,6 +575,23 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
|
|
@ -833,6 +1044,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
|
|
@ -867,6 +1087,32 @@
|
|||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -884,6 +1130,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
|
|
@ -893,6 +1145,18 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
|
|
@ -969,12 +1233,79 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -984,6 +1315,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -1008,6 +1345,18 @@
|
|||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
|
|
@ -1057,6 +1406,24 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
||||
|
|
@ -1101,6 +1468,18 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
|
@ -1128,6 +1507,12 @@
|
|||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
@ -1167,6 +1552,48 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
|
||||
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.2"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
|
||||
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
|
@ -1180,6 +1607,15 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
|
@ -1326,6 +1762,50 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -1341,6 +1821,21 @@
|
|||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
|
|
|||
5
public/config.json
Normal file
5
public/config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"streamUrl": "",
|
||||
"calibration": null,
|
||||
"renderDefaults": { "cutColor": "#00e5ff", "rapidColor": "#ff9800", "lineWidth": 1.5 }
|
||||
}
|
||||
121
src/app/alignment-ui.ts
Normal file
121
src/app/alignment-ui.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { Vec2, Mat3, Alignment } from '../types';
|
||||
import { imageToMachine, alignmentFromOrigin } from '../geometry/transform';
|
||||
|
||||
export interface AlignmentDeps {
|
||||
panel: HTMLElement;
|
||||
overlay: HTMLCanvasElement;
|
||||
getAlignment: () => Alignment;
|
||||
getHomography: () => Mat3 | null;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
/** Convert a mouse event to normalized [0,1] coords over the overlay canvas box. */
|
||||
function normFromEvent(overlay: HTMLCanvasElement, ev: MouseEvent): Vec2 {
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
return [
|
||||
(ev.clientX - rect.left) / rect.width,
|
||||
(ev.clientY - rect.top) / rect.height,
|
||||
];
|
||||
}
|
||||
|
||||
export function mountAlignment(deps: AlignmentDeps): void {
|
||||
const { panel, overlay } = deps;
|
||||
panel.innerHTML = `
|
||||
<h2>Alignment</h2>
|
||||
<div class="row">X <input id="al-x" type="number" step="1" /> Y <input id="al-y" type="number" step="1" /></div>
|
||||
<div class="row">Rotation° <input id="al-rot" type="number" step="0.5" /></div>
|
||||
<div class="row"><button id="al-origin">Set origin by clicking video</button></div>
|
||||
<div class="row"><small>Or drag the toolpath to move; Shift-drag to rotate. Arrow keys nudge 1 mm.</small></div>
|
||||
`;
|
||||
|
||||
const xEl = panel.querySelector('#al-x') as HTMLInputElement;
|
||||
const yEl = panel.querySelector('#al-y') as HTMLInputElement;
|
||||
const rotEl = panel.querySelector('#al-rot') as HTMLInputElement;
|
||||
|
||||
const sync = () => {
|
||||
const a = deps.getAlignment();
|
||||
xEl.value = a.tx.toFixed(1);
|
||||
yEl.value = a.ty.toFixed(1);
|
||||
rotEl.value = ((a.rot * 180) / Math.PI).toFixed(1);
|
||||
};
|
||||
sync();
|
||||
|
||||
const commitNumeric = () => {
|
||||
const a = deps.getAlignment();
|
||||
a.tx = parseFloat(xEl.value) || 0;
|
||||
a.ty = parseFloat(yEl.value) || 0;
|
||||
a.rot = ((parseFloat(rotEl.value) || 0) * Math.PI) / 180;
|
||||
deps.onChange();
|
||||
};
|
||||
for (const el of [xEl, yEl, rotEl]) el.addEventListener('change', commitNumeric);
|
||||
|
||||
// Click-to-set-origin
|
||||
let armOrigin = false;
|
||||
(panel.querySelector('#al-origin') as HTMLButtonElement).addEventListener('click', () => {
|
||||
armOrigin = true;
|
||||
overlay.classList.add('interactive');
|
||||
});
|
||||
|
||||
// Drag to move / Shift-drag to rotate
|
||||
let dragging = false;
|
||||
let lastMachine: Vec2 | null = null;
|
||||
|
||||
overlay.addEventListener('mousedown', (ev) => {
|
||||
const H = deps.getHomography();
|
||||
if (!H) return;
|
||||
// Another module (e.g. calibration) has armed the overlay for this click; don't hijack it.
|
||||
if (!armOrigin && overlay.classList.contains('interactive')) return;
|
||||
if (armOrigin) {
|
||||
const a = deps.getAlignment();
|
||||
const m = imageToMachine(H, normFromEvent(overlay, ev));
|
||||
Object.assign(a, alignmentFromOrigin(m, a.rot));
|
||||
armOrigin = false;
|
||||
overlay.classList.remove('interactive');
|
||||
sync();
|
||||
deps.onChange();
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
overlay.classList.add('interactive');
|
||||
lastMachine = imageToMachine(H, normFromEvent(overlay, ev));
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (ev) => {
|
||||
if (!dragging) return;
|
||||
const H = deps.getHomography();
|
||||
if (!H || !lastMachine) return;
|
||||
const a = deps.getAlignment();
|
||||
const cur = imageToMachine(H, normFromEvent(overlay, ev));
|
||||
if (ev.shiftKey) {
|
||||
// rotate about the work origin's current machine position (tx,ty)
|
||||
const angle = (p: Vec2) => Math.atan2(p[1] - a.ty, p[0] - a.tx);
|
||||
a.rot += angle(cur) - angle(lastMachine);
|
||||
} else {
|
||||
a.tx += cur[0] - lastMachine[0];
|
||||
a.ty += cur[1] - lastMachine[1];
|
||||
}
|
||||
lastMachine = cur;
|
||||
sync();
|
||||
deps.onChange();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (!dragging) return; // only release the overlay if THIS module was dragging
|
||||
dragging = false;
|
||||
overlay.classList.remove('interactive');
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (document.activeElement instanceof HTMLInputElement) return;
|
||||
const a = deps.getAlignment();
|
||||
const step = 1;
|
||||
if (ev.key === 'ArrowLeft') a.tx -= step;
|
||||
else if (ev.key === 'ArrowRight') a.tx += step;
|
||||
else if (ev.key === 'ArrowUp') a.ty -= step;
|
||||
else if (ev.key === 'ArrowDown') a.ty += step;
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
sync();
|
||||
deps.onChange();
|
||||
});
|
||||
}
|
||||
22
src/app/calibration-ui.test.ts
Normal file
22
src/app/calibration-ui.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { residuals } from './calibration-ui';
|
||||
import { estimateHomography } from '../geometry/homography';
|
||||
import type { Vec2 } from '../types';
|
||||
|
||||
describe('calibration residuals', () => {
|
||||
it('reports near-zero error for a clean fit', () => {
|
||||
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100]];
|
||||
const image: Vec2[] = [[10, 10], [210, 12], [208, 210], [8, 208]];
|
||||
const H = estimateHomography(machine, image);
|
||||
const errs = residuals(H, machine, image);
|
||||
expect(Math.max(...errs)).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it('flags a mismatched point with a large residual', () => {
|
||||
const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50]];
|
||||
const image: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [80, 20]]; // last is wrong
|
||||
const H = estimateHomography(machine, image);
|
||||
const errs = residuals(H, machine, image);
|
||||
expect(errs[4]!).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
116
src/app/calibration-ui.ts
Normal file
116
src/app/calibration-ui.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { Vec2, Mat3 } from '../types';
|
||||
import { estimateHomography, applyHomography } from '../geometry/homography';
|
||||
|
||||
/** Per-point reprojection error in pixels: |H·machine − image|. */
|
||||
export function residuals(H: Mat3, machine: Vec2[], image: Vec2[]): number[] {
|
||||
if (machine.length !== image.length) throw new Error('residuals: machine/image length mismatch');
|
||||
return machine.map((m, i) => {
|
||||
const p = applyHomography(H, m);
|
||||
return Math.hypot(p[0] - image[i]![0], p[1] - image[i]![1]);
|
||||
});
|
||||
}
|
||||
|
||||
interface CalibPoint {
|
||||
machine: Vec2;
|
||||
image: Vec2;
|
||||
}
|
||||
|
||||
export interface CalibrationDeps {
|
||||
panel: HTMLElement;
|
||||
overlay: HTMLCanvasElement;
|
||||
onHomography: (H: Mat3, points: CalibPoint[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the calibration panel. Flow: enter machine X/Y → click "Click point in video" →
|
||||
* click the spindle tip in the video → repeat ≥4× → "Compute".
|
||||
*
|
||||
* Captured image points and the resulting homography are in normalized [0,1] camera-frame
|
||||
* coordinates (machine-mm → normalized image), so they are display-size independent.
|
||||
* The JSON output structure stays the same (imagePoints now hold normalized values).
|
||||
*/
|
||||
export function mountCalibration(deps: CalibrationDeps): void {
|
||||
const { panel, overlay } = deps;
|
||||
const points: CalibPoint[] = [];
|
||||
let pendingMachine: Vec2 | null = null;
|
||||
|
||||
panel.innerHTML = `
|
||||
<h2>Calibration</h2>
|
||||
<div class="row"><small>Jog the spindle to a known X/Y, enter it, then click the tip in the video.</small></div>
|
||||
<div class="row">
|
||||
X <input id="cal-x" type="number" step="0.1" />
|
||||
Y <input id="cal-y" type="number" step="0.1" />
|
||||
<button id="cal-arm">Click point in video</button>
|
||||
</div>
|
||||
<ul id="cal-list"></ul>
|
||||
<div class="row">
|
||||
<button id="cal-compute">Compute homography</button>
|
||||
<button id="cal-clear">Clear</button>
|
||||
</div>
|
||||
<textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea>
|
||||
`;
|
||||
|
||||
const xEl = panel.querySelector('#cal-x') as HTMLInputElement;
|
||||
const yEl = panel.querySelector('#cal-y') as HTMLInputElement;
|
||||
const armBtn = panel.querySelector('#cal-arm') as HTMLButtonElement;
|
||||
const list = panel.querySelector('#cal-list') as HTMLUListElement;
|
||||
const jsonEl = panel.querySelector('#cal-json') as HTMLTextAreaElement;
|
||||
|
||||
const renderList = (errs?: number[]) => {
|
||||
list.innerHTML = points
|
||||
.map((p, i) => `<li>(${p.machine[0]}, ${p.machine[1]}) → img(${p.image[0].toFixed(3)}, ${p.image[1].toFixed(3)})${errs ? ` <small>err ${errs[i]!.toFixed(1)}px</small>` : ''}</li>`)
|
||||
.join('');
|
||||
};
|
||||
|
||||
armBtn.addEventListener('click', () => {
|
||||
pendingMachine = [parseFloat(xEl.value), parseFloat(yEl.value)];
|
||||
if (Number.isNaN(pendingMachine[0]) || Number.isNaN(pendingMachine[1])) {
|
||||
pendingMachine = null;
|
||||
return;
|
||||
}
|
||||
overlay.classList.add('interactive');
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', (ev) => {
|
||||
if (!pendingMachine || !overlay.classList.contains('interactive')) return;
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
// Store normalized [0,1] image coords (fraction of the displayed camera frame).
|
||||
// This makes the calibration independent of display/canvas size.
|
||||
const px: Vec2 = [
|
||||
(ev.clientX - rect.left) / rect.width,
|
||||
(ev.clientY - rect.top) / rect.height,
|
||||
];
|
||||
points.push({ machine: pendingMachine, image: px });
|
||||
pendingMachine = null;
|
||||
overlay.classList.remove('interactive');
|
||||
renderList();
|
||||
});
|
||||
|
||||
(panel.querySelector('#cal-clear') as HTMLButtonElement).addEventListener('click', () => {
|
||||
points.length = 0;
|
||||
jsonEl.value = '';
|
||||
renderList();
|
||||
});
|
||||
|
||||
(panel.querySelector('#cal-compute') as HTMLButtonElement).addEventListener('click', () => {
|
||||
if (points.length < 4) {
|
||||
jsonEl.value = 'Need at least 4 points.';
|
||||
return;
|
||||
}
|
||||
const machine = points.map((p) => p.machine);
|
||||
const image = points.map((p) => p.image);
|
||||
const H = estimateHomography(machine, image);
|
||||
// Per-axis pixel residual (normalized error scaled by canvas width/height) — used only for misclick display.
|
||||
const errsPx = machine.map((m, i) => {
|
||||
const p = applyHomography(H, m);
|
||||
return Math.hypot((p[0] - image[i]![0]) * overlay.width, (p[1] - image[i]![1]) * overlay.height);
|
||||
});
|
||||
renderList(errsPx);
|
||||
jsonEl.value = JSON.stringify(
|
||||
{ imagePoints: image, machinePoints: machine, homography: H },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
deps.onHomography(H, [...points]);
|
||||
});
|
||||
}
|
||||
18
src/app/layout.test.ts
Normal file
18
src/app/layout.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { computeOverlayRect } from './layout';
|
||||
|
||||
describe('computeOverlayRect', () => {
|
||||
it('matches the displayed video box (letterboxed: wide video in tall box)', () => {
|
||||
// video is 16:9 (1600×900 native) shown in a 800×800 box → fits to width, 450 tall, centred
|
||||
const r = computeOverlayRect(1600, 900, 800, 800);
|
||||
expect(r.width).toBeCloseTo(800, 3);
|
||||
expect(r.height).toBeCloseTo(450, 3);
|
||||
expect(r.left).toBeCloseTo(0, 3);
|
||||
expect(r.top).toBeCloseTo(175, 3);
|
||||
});
|
||||
|
||||
it('falls back to the container when the video has no intrinsic size yet', () => {
|
||||
const r = computeOverlayRect(0, 0, 640, 480);
|
||||
expect(r).toEqual({ left: 0, top: 0, width: 640, height: 480 });
|
||||
});
|
||||
});
|
||||
40
src/app/layout.ts
Normal file
40
src/app/layout.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the on-screen rectangle of a media element shown with object-fit: contain
|
||||
* inside a container. Returns container bounds if the media size is unknown.
|
||||
*/
|
||||
export function computeOverlayRect(
|
||||
mediaW: number,
|
||||
mediaH: number,
|
||||
containerW: number,
|
||||
containerH: number,
|
||||
): Rect {
|
||||
if (mediaW <= 0 || mediaH <= 0) {
|
||||
return { left: 0, top: 0, width: containerW, height: containerH };
|
||||
}
|
||||
const scale = Math.min(containerW / mediaW, containerH / mediaH);
|
||||
const width = mediaW * scale;
|
||||
const height = mediaH * scale;
|
||||
return {
|
||||
left: (containerW - width) / 2,
|
||||
top: (containerH - height) / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Position and size a canvas to overlay a media element inside a container. */
|
||||
export function syncOverlay(canvas: HTMLCanvasElement, rect: Rect): void {
|
||||
canvas.style.left = `${rect.left}px`;
|
||||
canvas.style.top = `${rect.top}px`;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
canvas.width = Math.round(rect.width);
|
||||
canvas.height = Math.round(rect.height);
|
||||
}
|
||||
80
src/app/overlay-interaction.test.ts
Normal file
80
src/app/overlay-interaction.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mountAlignment } from './alignment-ui';
|
||||
import { mountCalibration } from './calibration-ui';
|
||||
import type { Mat3, Alignment } from '../types';
|
||||
|
||||
// Identity homography: machine coords == normalized coords for this test, so a
|
||||
// normalized click at fraction f over the 100px box maps to machine value f.
|
||||
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||
|
||||
function makeOverlay(): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 100;
|
||||
c.height = 100;
|
||||
// jsdom returns a zero rect by default; stub a known 100x100 box at the origin.
|
||||
c.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100, x: 0, y: 0, toJSON() {} }) as DOMRect;
|
||||
return c;
|
||||
}
|
||||
|
||||
function mouse(type: string, x: number, y: number, shiftKey = false): MouseEvent {
|
||||
return new MouseEvent(type, { clientX: x, clientY: y, shiftKey, bubbles: true });
|
||||
}
|
||||
|
||||
describe('overlay interaction', () => {
|
||||
let overlay: HTMLCanvasElement;
|
||||
let alignment: Alignment;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
overlay = makeOverlay();
|
||||
document.body.appendChild(overlay);
|
||||
alignment = { tx: 0, ty: 0, rot: 0 };
|
||||
});
|
||||
|
||||
it('dragging the overlay moves the alignment (primary feature is reachable)', () => {
|
||||
const panel = document.createElement('div');
|
||||
mountAlignment({
|
||||
panel,
|
||||
overlay,
|
||||
getAlignment: () => alignment,
|
||||
getHomography: () => IDENT,
|
||||
onChange: () => {},
|
||||
});
|
||||
overlay.dispatchEvent(mouse('mousedown', 50, 50));
|
||||
overlay.dispatchEvent(mouse('mousemove', 60, 50));
|
||||
overlay.dispatchEvent(mouse('mouseup', 60, 50));
|
||||
// 0.50 -> 0.60 over the 100px box == +0.1 machine delta under identity H.
|
||||
expect(alignment.tx).toBeCloseTo(0.1, 6);
|
||||
expect(alignment.ty).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('while calibration is armed, a click is captured by calibration and does NOT drag the alignment', () => {
|
||||
const calibPanel = document.createElement('div');
|
||||
const alignPanel = document.createElement('div');
|
||||
mountCalibration({ panel: calibPanel, overlay, onHomography: () => {} });
|
||||
mountAlignment({
|
||||
panel: alignPanel,
|
||||
overlay,
|
||||
getAlignment: () => alignment,
|
||||
getHomography: () => IDENT,
|
||||
onChange: () => {},
|
||||
});
|
||||
(calibPanel.querySelector('#cal-x') as HTMLInputElement).value = '10';
|
||||
(calibPanel.querySelector('#cal-y') as HTMLInputElement).value = '20';
|
||||
(calibPanel.querySelector('#cal-arm') as HTMLButtonElement).click();
|
||||
expect(overlay.classList.contains('interactive')).toBe(true);
|
||||
|
||||
overlay.dispatchEvent(mouse('mousedown', 30, 40));
|
||||
overlay.dispatchEvent(mouse('mouseup', 30, 40));
|
||||
overlay.dispatchEvent(mouse('click', 30, 40));
|
||||
|
||||
// Calibration owned the gesture: alignment did not move...
|
||||
expect(alignment.tx).toBe(0);
|
||||
expect(alignment.ty).toBe(0);
|
||||
// ...and exactly one calibration point was captured.
|
||||
const list = calibPanel.querySelector('#cal-list') as HTMLUListElement;
|
||||
expect(list.children.length).toBe(1);
|
||||
});
|
||||
});
|
||||
36
src/app/state.test.ts
Normal file
36
src/app/state.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createState } from './state';
|
||||
import type { Mat3 } from '../types';
|
||||
|
||||
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||
|
||||
describe('app state', () => {
|
||||
it('starts with no segments and a zero alignment', () => {
|
||||
const s = createState();
|
||||
expect(s.segments).toEqual([]);
|
||||
expect(s.alignment).toEqual({ tx: 0, ty: 0, rot: 0 });
|
||||
});
|
||||
|
||||
it('loadGcode stores parsed segments and returns warning count', () => {
|
||||
const s = createState();
|
||||
const warnings = s.loadGcode('G21 G90\nG1 X10 Y0');
|
||||
expect(s.segments).toHaveLength(1);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('projected() returns image-space segments when calibrated', () => {
|
||||
const s = createState();
|
||||
s.setHomography(IDENT);
|
||||
s.loadGcode('G21 G90\nG1 X10 Y0');
|
||||
s.alignment.tx = 2;
|
||||
const proj = s.projected();
|
||||
expect(proj[0]!.points[0]).toEqual([2, 0]);
|
||||
expect(proj[0]!.points[1]).toEqual([12, 0]);
|
||||
});
|
||||
|
||||
it('projected() returns [] when not calibrated', () => {
|
||||
const s = createState();
|
||||
s.loadGcode('G1 X10 Y0');
|
||||
expect(s.projected()).toEqual([]);
|
||||
});
|
||||
});
|
||||
33
src/app/state.ts
Normal file
33
src/app/state.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Segment, Alignment, Mat3 } from '../types';
|
||||
import { parseGcode } from '../gcode/parser';
|
||||
import { projectSegments } from '../geometry/transform';
|
||||
|
||||
export interface AppState {
|
||||
segments: Segment[];
|
||||
alignment: Alignment;
|
||||
homography: Mat3 | null;
|
||||
loadGcode(text: string): string[];
|
||||
setHomography(H: Mat3 | null): void;
|
||||
projected(): Segment[];
|
||||
}
|
||||
|
||||
export function createState(): AppState {
|
||||
const state: AppState = {
|
||||
segments: [],
|
||||
alignment: { tx: 0, ty: 0, rot: 0 },
|
||||
homography: null,
|
||||
loadGcode(text: string): string[] {
|
||||
const { segments, warnings } = parseGcode(text);
|
||||
state.segments = segments;
|
||||
return warnings;
|
||||
},
|
||||
setHomography(H: Mat3 | null): void {
|
||||
state.homography = H;
|
||||
},
|
||||
projected(): Segment[] {
|
||||
if (!state.homography) return [];
|
||||
return projectSegments(state.segments, state.alignment, state.homography);
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
83
src/main.ts
83
src/main.ts
|
|
@ -1 +1,82 @@
|
|||
console.log('gcode-overlay booting');
|
||||
import './styles.css';
|
||||
import { loadConfig } from './config';
|
||||
import { createState } from './app/state';
|
||||
import { computeOverlayRect, syncOverlay } from './app/layout';
|
||||
import { drawOverlay, clearCanvas } from './render/renderer';
|
||||
import { mountCalibration } from './app/calibration-ui';
|
||||
import { mountAlignment } from './app/alignment-ui';
|
||||
import type { RenderStyle } from './types';
|
||||
|
||||
const stage = document.getElementById('stage') as HTMLDivElement;
|
||||
const stream = document.getElementById('stream') as HTMLImageElement;
|
||||
const overlay = document.getElementById('overlay') as HTMLCanvasElement;
|
||||
const statusEl = document.getElementById('status') as HTMLParagraphElement;
|
||||
const fileInput = document.getElementById('gcode-file') as HTMLInputElement;
|
||||
const ctx = overlay.getContext('2d')!;
|
||||
|
||||
const state = createState();
|
||||
let style: RenderStyle = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 };
|
||||
|
||||
function render(): void {
|
||||
clearCanvas(ctx, overlay.width, overlay.height);
|
||||
const w = overlay.width;
|
||||
const h = overlay.height;
|
||||
// projected() yields normalized [0,1] image coords; scale to canvas pixels.
|
||||
const proj = state.projected().map((seg) => ({
|
||||
kind: seg.kind,
|
||||
points: seg.points.map((p): [number, number] => [p[0] * w, p[1] * h]),
|
||||
}));
|
||||
drawOverlay(ctx, proj, style);
|
||||
}
|
||||
|
||||
function resize(): void {
|
||||
const rect = computeOverlayRect(stream.naturalWidth, stream.naturalHeight, stage.clientWidth, stage.clientHeight);
|
||||
syncOverlay(overlay, rect);
|
||||
render();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
stream.addEventListener('load', resize);
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const warnings = state.loadGcode(text);
|
||||
statusEl.textContent = `${file.name}: ${state.segments.length} moves` + (warnings.length ? ` (${warnings.length} warnings)` : '');
|
||||
render();
|
||||
});
|
||||
|
||||
|
||||
mountCalibration({
|
||||
panel: document.getElementById('calib-panel') as HTMLElement,
|
||||
overlay,
|
||||
onHomography: (H) => {
|
||||
state.setHomography(H);
|
||||
statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
|
||||
render();
|
||||
},
|
||||
});
|
||||
|
||||
mountAlignment({
|
||||
panel: document.getElementById('align-panel') as HTMLElement,
|
||||
overlay,
|
||||
getAlignment: () => state.alignment,
|
||||
getHomography: () => state.homography,
|
||||
onChange: render,
|
||||
});
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
try {
|
||||
const cfg = await loadConfig('config.json');
|
||||
style = cfg.renderDefaults;
|
||||
if (cfg.streamUrl) stream.src = cfg.streamUrl;
|
||||
if (cfg.calibration) state.setHomography(cfg.calibration.homography);
|
||||
statusEl.textContent = cfg.calibration ? 'Ready. Open a G-code file.' : 'Not calibrated — calibrate first.';
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Config error: ${(e as Error).message}`;
|
||||
}
|
||||
resize();
|
||||
}
|
||||
|
||||
void boot();
|
||||
|
|
|
|||
39
src/render/renderer.test.ts
Normal file
39
src/render/renderer.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { drawOverlay } from './renderer';
|
||||
import type { Segment, RenderStyle } from '../types';
|
||||
|
||||
class FakeCtx {
|
||||
calls: string[] = [];
|
||||
strokeStyle = '';
|
||||
lineWidth = 0;
|
||||
beginPath() { this.calls.push('begin'); }
|
||||
setLineDash(d: number[]) { this.calls.push(`dash:${d.length}`); }
|
||||
moveTo(x: number, y: number) { this.calls.push(`move:${x},${y}`); }
|
||||
lineTo(x: number, y: number) { this.calls.push(`line:${x},${y}`); }
|
||||
stroke() { this.calls.push(`stroke:${this.strokeStyle}`); }
|
||||
}
|
||||
|
||||
const STYLE: RenderStyle = { cutColor: '#cut', rapidColor: '#rapid', lineWidth: 2 };
|
||||
|
||||
describe('drawOverlay', () => {
|
||||
it('strokes a cut as a solid polyline in the cut colour', () => {
|
||||
const ctx = new FakeCtx();
|
||||
const segs: Segment[] = [{ kind: 'cut', points: [[0, 0], [5, 5], [10, 0]] }];
|
||||
drawOverlay(ctx as unknown as CanvasRenderingContext2D, segs, STYLE);
|
||||
expect(ctx.calls).toEqual(['begin', 'dash:0', 'move:0,0', 'line:5,5', 'line:10,0', 'stroke:#cut']);
|
||||
});
|
||||
|
||||
it('strokes a rapid dashed in the rapid colour', () => {
|
||||
const ctx = new FakeCtx();
|
||||
const segs: Segment[] = [{ kind: 'rapid', points: [[0, 0], [5, 0]] }];
|
||||
drawOverlay(ctx as unknown as CanvasRenderingContext2D, segs, STYLE);
|
||||
expect(ctx.calls).toContain('dash:2');
|
||||
expect(ctx.calls).toContain('stroke:#rapid');
|
||||
});
|
||||
|
||||
it('skips degenerate segments', () => {
|
||||
const ctx = new FakeCtx();
|
||||
drawOverlay(ctx as unknown as CanvasRenderingContext2D, [{ kind: 'cut', points: [[1, 1]] }], STYLE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
28
src/render/renderer.ts
Normal file
28
src/render/renderer.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Segment, RenderStyle } from '../types';
|
||||
|
||||
/** Draw already-projected (image-pixel) segments onto a 2D context. */
|
||||
export function drawOverlay(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
projected: Segment[],
|
||||
style: RenderStyle,
|
||||
): void {
|
||||
for (const seg of projected) {
|
||||
if (seg.points.length < 2) continue;
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash(seg.kind === 'rapid' ? [6, 4] : []);
|
||||
ctx.strokeStyle = seg.kind === 'rapid' ? style.rapidColor : style.cutColor;
|
||||
ctx.lineWidth = style.lineWidth;
|
||||
const first = seg.points[0]!;
|
||||
ctx.moveTo(first[0], first[1]);
|
||||
for (let i = 1; i < seg.points.length; i++) {
|
||||
ctx.lineTo(seg.points[i]![0], seg.points[i]![1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the whole canvas. */
|
||||
export function clearCanvas(ctx: CanvasRenderingContext2D, width: number, height: number): void {
|
||||
ctx.setLineDash([]);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
14
src/styles.css
Normal file
14
src/styles.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
* { box-sizing: border-box; }
|
||||
body { margin: 0; display: flex; height: 100vh; font-family: system-ui, sans-serif; background: #111; color: #eee; }
|
||||
#stage { position: relative; flex: 1; overflow: hidden; background: #000; }
|
||||
#stream { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||
#overlay { position: absolute; pointer-events: auto; cursor: default; }
|
||||
#overlay.interactive { cursor: crosshair; }
|
||||
#panel { width: 320px; padding: 16px; overflow-y: auto; background: #1b1b1b; }
|
||||
#panel h1 { font-size: 16px; }
|
||||
.filebtn { display: inline-block; padding: 8px 12px; background: #2962ff; border-radius: 4px; cursor: pointer; }
|
||||
button { padding: 6px 10px; margin: 2px 0; background: #333; color: #eee; border: 1px solid #555; border-radius: 4px; cursor: pointer; }
|
||||
input[type="number"] { width: 80px; background: #222; color: #eee; border: 1px solid #555; border-radius: 4px; padding: 4px; }
|
||||
section { margin-top: 16px; border-top: 1px solid #333; padding-top: 12px; }
|
||||
.row { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
|
||||
small { color: #999; }
|
||||
|
|
@ -19,11 +19,11 @@ export interface Alignment {
|
|||
}
|
||||
|
||||
export interface Calibration {
|
||||
/** Clicked points in the camera image, pixels. */
|
||||
/** Calibration points in normalized [0,1] camera-frame coordinates. */
|
||||
imagePoints: Vec2[];
|
||||
/** Corresponding machine coordinates, mm. */
|
||||
machinePoints: Vec2[];
|
||||
/** machine-mm → image-px. */
|
||||
/** machine-mm → normalized [0,1] image coords. */
|
||||
homography: Mat3;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue