From 38d99bb0dca1876475f32fea753b07cab4010e7a Mon Sep 17 00:00:00 2001 From: sjat Date: Mon, 8 Jun 2026 22:37:35 +0200 Subject: [PATCH] feat: app state, stream embedding, G-code render wiring Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html | 1 - public/config.json | 5 +++++ src/app/state.test.ts | 36 ++++++++++++++++++++++++++++++ src/app/state.ts | 33 +++++++++++++++++++++++++++ src/main.ts | 52 ++++++++++++++++++++++++++++++++++++------- 5 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 public/config.json create mode 100644 src/app/state.test.ts create mode 100644 src/app/state.ts diff --git a/index.html b/index.html index d8e7e68..55ecca8 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,6 @@ G-Code Overlay -
diff --git a/public/config.json b/public/config.json new file mode 100644 index 0000000..f56dedc --- /dev/null +++ b/public/config.json @@ -0,0 +1,5 @@ +{ + "streamUrl": "", + "calibration": null, + "renderDefaults": { "cutColor": "#00e5ff", "rapidColor": "#ff9800", "lineWidth": 1.5 } +} diff --git a/src/app/state.test.ts b/src/app/state.test.ts new file mode 100644 index 0000000..73bb312 --- /dev/null +++ b/src/app/state.test.ts @@ -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([]); + }); +}); diff --git a/src/app/state.ts b/src/app/state.ts new file mode 100644 index 0000000..b08f67c --- /dev/null +++ b/src/app/state.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index 428548a..9e09683 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,22 +1,58 @@ 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 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 proj = state.projected(); + drawOverlay(ctx, proj, style); +} function resize(): void { - const rect = computeOverlayRect( - stream.naturalWidth, - stream.naturalHeight, - stage.clientWidth, - stage.clientHeight, - ); + const rect = computeOverlayRect(stream.naturalWidth, stream.naturalHeight, stage.clientWidth, stage.clientHeight); syncOverlay(overlay, rect); + render(); } window.addEventListener('resize', 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 { + 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();