Compare commits

...

10 commits

Author SHA1 Message Date
sjat
845e92e56f test: jsdom coverage for overlay drag + calibration/alignment arbitration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:59:49 +02:00
sjat
3538d5ccb9 fix: enable overlay drag interaction; tidy dead export and residual readout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:57:03 +02:00
sjat
fac9168c00 docs: README and deployment config
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:51:47 +02:00
sjat
dbb302f765 fix: guard alignment input against numeric-field editing and calibration clicks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:50:30 +02:00
sjat
5d80273717 feat: per-job alignment (numeric, click-origin, drag/rotate)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:47:08 +02:00
sjat
d1577dd569 fix: make calibration resolution-independent (normalized image coords)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:45:29 +02:00
sjat
1df5509b29 feat: calibration capture UI with reprojection residuals
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:40:32 +02:00
sjat
38d99bb0dc feat: app state, stream embedding, G-code render wiring
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:37:35 +02:00
sjat
dd409f56c0 feat: page layout and overlay canvas sizing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:34:28 +02:00
sjat
96221a217d feat: canvas overlay renderer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:31:56 +02:00
18 changed files with 1176 additions and 3 deletions

31
README.md Normal file
View 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.

View file

@ -6,6 +6,19 @@
<title>G-Code Overlay</title> <title>G-Code Overlay</title>
</head> </head>
<body> <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> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

495
package-lock.json generated
View file

@ -8,11 +8,205 @@
"name": "gcode-overlay", "name": "gcode-overlay",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"jsdom": "^29.1.1",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -381,6 +575,23 @@
"node": ">=12" "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": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@ -833,6 +1044,15 @@
"node": ">=12" "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": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -867,6 +1087,32 @@
"node": ">= 16" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/deep-eql": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -893,6 +1145,18 @@
"node": ">=6" "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": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "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": "^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": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -984,6 +1315,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "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": "^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": { "node_modules/pathe": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@ -1057,6 +1406,24 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/rollup": {
"version": "4.61.1", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
@ -1101,6 +1468,18 @@
"fsevents": "~2.3.2" "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": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@ -1128,6 +1507,12 @@
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -1167,6 +1552,48 @@
"node": ">=14.0.0" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -1180,6 +1607,15 @@
"node": ">=14.17" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -1341,6 +1821,21 @@
"engines": { "engines": {
"node": ">=8" "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
} }
} }
} }

View file

@ -10,6 +10,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"jsdom": "^29.1.1",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"

5
public/config.json Normal file
View file

@ -0,0 +1,5 @@
{
"streamUrl": "",
"calibration": null,
"renderDefaults": { "cutColor": "#00e5ff", "rapidColor": "#ff9800", "lineWidth": 1.5 }
}

121
src/app/alignment-ui.ts Normal file
View 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&nbsp;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();
});
}

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

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

View file

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

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

View file

@ -19,11 +19,11 @@ export interface Alignment {
} }
export interface Calibration { export interface Calibration {
/** Clicked points in the camera image, pixels. */ /** Calibration points in normalized [0,1] camera-frame coordinates. */
imagePoints: Vec2[]; imagePoints: Vec2[];
/** Corresponding machine coordinates, mm. */ /** Corresponding machine coordinates, mm. */
machinePoints: Vec2[]; machinePoints: Vec2[];
/** machine-mm → image-px. */ /** machine-mm → normalized [0,1] image coords. */
homography: Mat3; homography: Mat3;
} }