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:
parent
4a554e2057
commit
c5c1b5e5c2
2 changed files with 176 additions and 0 deletions
50
src/gcode/parser.test.ts
Normal file
50
src/gcode/parser.test.ts
Normal 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
126
src/gcode/parser.ts
Normal 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)];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue