diff --git a/src/gcode/parser.test.ts b/src/gcode/parser.test.ts new file mode 100644 index 0000000..5712359 --- /dev/null +++ b/src/gcode/parser.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { parseGcode } from './parser'; + +describe('parseGcode', () => { + it('parses linear cuts and rapids', () => { + const { segments } = parseGcode(` + G21 G90 + G0 X0 Y0 + G1 X10 Y0 + G1 X10 Y10 + `); + expect(segments).toEqual([ + { kind: 'rapid', points: [[0, 0], [0, 0]] }, + { kind: 'cut', points: [[0, 0], [10, 0]] }, + { kind: 'cut', points: [[10, 0], [10, 10]] }, + ]); + }); + + it('honours modal motion (bare coordinate lines repeat last motion)', () => { + const { segments } = parseGcode(`G1 X0 Y0\nX5 Y0\nX5 Y5`); + expect(segments.map((s) => s.kind)).toEqual(['cut', 'cut', 'cut']); + expect(segments[2]!.points[1]).toEqual([5, 5]); + }); + + it('applies inch units (G20) by scaling to mm', () => { + const { segments } = parseGcode(`G20 G90\nG1 X1 Y0`); + expect(segments[0]!.points[1]![0]).toBeCloseTo(25.4, 6); + }); + + it('supports incremental mode (G91)', () => { + const { segments } = parseGcode(`G21 G91\nG1 X10\nG1 X10`); + expect(segments[1]!.points[1]![0]).toBeCloseTo(20, 6); + }); + + it('flattens a G2 arc via I/J into a cut polyline', () => { + // quarter circle, centre at (0,0): (10,0) CW... use G3 (CCW) to (0,10) + const { segments } = parseGcode(`G21 G90\nG0 X10 Y0\nG3 X0 Y10 I-10 J0`); + const arc = segments[1]!; + expect(arc.kind).toBe('cut'); + expect(arc.points[0]).toEqual([10, 0]); + expect(arc.points[arc.points.length - 1]).toEqual([0, 10]); + expect(arc.points.length).toBeGreaterThan(2); + }); + + it('ignores comments and unknown words, records no warnings for M-codes', () => { + const { segments, warnings } = parseGcode(`(setup)\nG21 G90\nM3 S1000 ; spindle on\nG1 X5 Y0 F600`); + expect(segments).toHaveLength(1); + expect(warnings).toHaveLength(0); + }); +}); diff --git a/src/gcode/parser.ts b/src/gcode/parser.ts new file mode 100644 index 0000000..4f98385 --- /dev/null +++ b/src/gcode/parser.ts @@ -0,0 +1,126 @@ +import type { Vec2, Segment } from '../types'; +import { flattenArc } from '../geometry/arc'; + +export interface ParseResult { + segments: Segment[]; + warnings: string[]; +} + +interface State { + x: number; + y: number; + z: number; + absolute: boolean; + scale: number; // 1 for mm, 25.4 for inch + motion: number; // 0,1,2,3 +} + +const WORD_RE = /([A-Za-z])\s*([-+]?[0-9]*\.?[0-9]+)/g; + +function stripComments(line: string): string { + return line.replace(/\(.*?\)/g, '').replace(/;.*$/, '').trim(); +} + +export function parseGcode(text: string): ParseResult { + const segments: Segment[] = []; + const warnings: string[] = []; + const st: State = { x: 0, y: 0, z: 0, absolute: true, scale: 1, motion: 0 }; + + const lines = text.split(/\r?\n/); + for (let lineNo = 0; lineNo < lines.length; lineNo++) { + const clean = stripComments(lines[lineNo]!); + if (!clean) continue; + + const words: Array<[string, number]> = []; + let m: RegExpExecArray | null; + WORD_RE.lastIndex = 0; + while ((m = WORD_RE.exec(clean)) !== null) { + words.push([m[1]!.toUpperCase(), parseFloat(m[2]!)]); + } + + const axis: Record = {}; + let motionThisLine: number | null = null; + for (const [letter, value] of words) { + switch (letter) { + case 'G': + if (value === 0 || value === 1 || value === 2 || value === 3) { + st.motion = value; + motionThisLine = value; + } else if (value === 20) st.scale = 25.4; + else if (value === 21) st.scale = 1; + else if (value === 90) st.absolute = true; + else if (value === 91) st.absolute = false; + // G17/G18/G19 plane select and others: ignored (G17/XY assumed) + break; + case 'X': case 'Y': case 'Z': case 'I': case 'J': case 'R': + axis[letter] = value * st.scale; + break; + // F, S, T, M, N etc. ignored + default: + break; + } + } + + const hasCoord = 'X' in axis || 'Y' in axis || 'Z' in axis; + if (motionThisLine === null && !hasCoord) continue; // no movement on this line + + const start: Vec2 = [st.x, st.y]; + const target = resolveTarget(st, axis); + + switch (st.motion) { + case 0: + segments.push({ kind: 'rapid', points: [start, target] }); + break; + case 1: + segments.push({ kind: 'cut', points: [start, target] }); + break; + case 2: + case 3: { + const center = arcCenter(start, target, axis, st.motion === 2); + if (!center) { + warnings.push(`Line ${lineNo + 1}: could not resolve arc centre`); + segments.push({ kind: 'cut', points: [start, target] }); + } else { + segments.push({ kind: 'cut', points: flattenArc(start, target, center, st.motion === 2) }); + } + break; + } + } + st.x = target[0]; + st.y = target[1]; + if ('Z' in axis) st.z = axis['Z']!; + } + + return { segments, warnings }; +} + +function resolveTarget(st: State, axis: Record): Vec2 { + if (st.absolute) { + return ['X' in axis ? axis['X']! : st.x, 'Y' in axis ? axis['Y']! : st.y] as Vec2; + } + return [st.x + (axis['X'] ?? 0), st.y + (axis['Y'] ?? 0)] as Vec2; +} + +function arcCenter(start: Vec2, end: Vec2, axis: Record, clockwise: boolean): Vec2 | null { + if ('I' in axis || 'J' in axis) { + // I/J are offsets from the start point (already unit-scaled). + return [start[0] + (axis['I'] ?? 0), start[1] + (axis['J'] ?? 0)]; + } + if ('R' in axis) { + return centerFromRadius(start, end, axis['R']!, clockwise); + } + return null; +} + +/** GRBL-style centre-from-radius. R>0 selects the minor arc, R<0 the major arc. */ +function centerFromRadius(start: Vec2, end: Vec2, r: number, clockwise: boolean): Vec2 | null { + const x = end[0] - start[0]; + const y = end[1] - start[1]; + const d2 = x * x + y * y; + const disc = 4 * r * r - d2; + if (disc < 0 || d2 === 0) return null; + let h = Math.sqrt(disc) / Math.sqrt(d2); + if (clockwise) h = -h; + if (r < 0) h = -h; + return [start[0] + 0.5 * (x - y * h), start[1] + 0.5 * (y + x * h)]; +}