feat: G-code parser with arcs, units, modal motion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-08 22:21:11 +02:00
parent 4a554e2057
commit c5c1b5e5c2
2 changed files with 176 additions and 0 deletions

50
src/gcode/parser.test.ts Normal file
View file

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

126
src/gcode/parser.ts Normal file
View file

@ -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<string, number> = {};
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<string, number>): 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<string, number>, 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)];
}