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