feat: app state, stream embedding, G-code render wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-08 22:37:35 +02:00
parent dd409f56c0
commit 38d99bb0dc
5 changed files with 118 additions and 9 deletions

View file

@ -4,7 +4,6 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>G-Code Overlay</title> <title>G-Code Overlay</title>
<link rel="stylesheet" href="/src/styles.css" />
</head> </head>
<body> <body>
<div id="stage"> <div id="stage">

5
public/config.json Normal file
View file

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

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,22 +1,58 @@
import './styles.css'; import './styles.css';
import { loadConfig } from './config';
import { createState } from './app/state';
import { computeOverlayRect, syncOverlay } from './app/layout'; import { computeOverlayRect, syncOverlay } from './app/layout';
import { drawOverlay, clearCanvas } from './render/renderer';
import type { RenderStyle } from './types';
const stage = document.getElementById('stage') as HTMLDivElement; const stage = document.getElementById('stage') as HTMLDivElement;
const stream = document.getElementById('stream') as HTMLImageElement; const stream = document.getElementById('stream') as HTMLImageElement;
const overlay = document.getElementById('overlay') as HTMLCanvasElement; 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 proj = state.projected();
drawOverlay(ctx, proj, style);
}
function resize(): void { function resize(): void {
const rect = computeOverlayRect( const rect = computeOverlayRect(stream.naturalWidth, stream.naturalHeight, stage.clientWidth, stage.clientHeight);
stream.naturalWidth,
stream.naturalHeight,
stage.clientWidth,
stage.clientHeight,
);
syncOverlay(overlay, rect); syncOverlay(overlay, rect);
render();
} }
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
stream.addEventListener('load', resize); stream.addEventListener('load', resize);
resize();
console.log('layout ready'); 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();
});
// expose for the UI modules added in later tasks
export const app = { state, render, resize, overlay, stage, stream };
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();