agentic-numerical-control 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ // Reads a G-code program the way a careful machinist would before running it:
2
+ // how big it is, how long it takes, which tools it uses and where they change,
3
+ // whether it fits the machine from the current zero, and anything the
4
+ // controller won't do or that looks risky. Everything here is an estimate from
5
+ // the program text and the controller's own settings; nothing touches the machine.
6
+ const AXES = ["x", "y", "z"];
7
+ const ASSUMED_RAPID = 1000; // mm/min, a cautious hobby-machine figure
8
+ const LINE_SAMPLES = 5;
9
+ const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
10
+ function parseWords(code) {
11
+ const g = [];
12
+ const m = [];
13
+ const v = {};
14
+ for (const [, letter, num] of code.matchAll(/([A-Z])\s*([-+]?(?:\d+\.?\d*|\.\d+))/g)) {
15
+ const n = Number(num);
16
+ if (letter === "G")
17
+ g.push(n);
18
+ else if (letter === "M")
19
+ m.push(n);
20
+ else
21
+ v[letter] = n;
22
+ }
23
+ return { g, m, v };
24
+ }
25
+ /** Grbl and grblHAL both report as "Grbl" in CNCjs; grblHAL says so in its version line. */
26
+ function controllerKind(machine) {
27
+ if (!machine?.controller)
28
+ return "unknown";
29
+ if (!/grbl/i.test(machine.controller))
30
+ return "other";
31
+ return /hal/i.test(machine.version ?? "") ? "grblhal" : "grbl";
32
+ }
33
+ export function analyzeGcode(gcode, machine) {
34
+ const settings = machine?.settings ?? null;
35
+ const setting = (n) => {
36
+ const raw = settings?.[`$${n}`];
37
+ const val = raw === undefined ? NaN : Number(raw);
38
+ return Number.isFinite(val) ? val : null;
39
+ };
40
+ const rapid = { x: setting(110), y: setting(111), z: setting(112) };
41
+ const rapidKnown = rapid.x !== null && rapid.y !== null && rapid.z !== null;
42
+ const found = new Map();
43
+ const flag = (code, severity, message, line) => {
44
+ const c = found.get(code) ?? { severity, message, lines: [] };
45
+ if (c.lines.length < LINE_SAMPLES && c.lines[c.lines.length - 1] !== line)
46
+ c.lines.push(line);
47
+ found.set(code, c);
48
+ };
49
+ let scale = 1; // inch programs are converted to mm
50
+ let units = "mm";
51
+ let absolute = true;
52
+ let plane = 17;
53
+ let motion = 0;
54
+ let feed = null;
55
+ let spindleOn = false;
56
+ let pos = { x: 0, y: 0, z: 0 };
57
+ let moved = false;
58
+ let pendingTool = null;
59
+ let pendingLabel = null;
60
+ let tool = null;
61
+ let lastComment = null;
62
+ const min = { x: Infinity, y: Infinity, z: Infinity };
63
+ const max = { x: -Infinity, y: -Infinity, z: -Infinity };
64
+ const feeds = new Set();
65
+ const speeds = new Set();
66
+ const tools = [];
67
+ let toolChanges = 0;
68
+ let cutLength = 0;
69
+ let rapidLength = 0;
70
+ let cuttingSeconds = 0;
71
+ let rapidSeconds = 0;
72
+ const runs = [];
73
+ const steps = [];
74
+ let step = null;
75
+ const openStep = (line) => {
76
+ step = { tool, label: pendingLabel, firstLine: line, lastLine: line, seconds: 0, cutLength: 0, minZ: null, feedRange: null };
77
+ steps.push(step);
78
+ };
79
+ const include = (p) => {
80
+ for (const a of AXES) {
81
+ if (p[a] < min[a])
82
+ min[a] = p[a];
83
+ if (p[a] > max[a])
84
+ max[a] = p[a];
85
+ }
86
+ };
87
+ const lines = gcode.split(/\r?\n/);
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const lineNo = i + 1;
90
+ const raw = lines[i];
91
+ const comments = [...raw.matchAll(/\(([^)]*)\)|;(.*)$/g)].map((c) => (c[1] ?? c[2] ?? "").trim()).filter(Boolean);
92
+ if (comments.length)
93
+ lastComment = comments.join(" ");
94
+ const code = raw.replace(/\([^)]*\)/g, " ").replace(/;.*$/, "").toUpperCase().replace(/^\s*N\d+/, "");
95
+ if (!code.trim() || code.trim().startsWith("%"))
96
+ continue;
97
+ const { g, m, v } = parseWords(code);
98
+ for (const n of g) {
99
+ if (n === 20) {
100
+ scale = 25.4;
101
+ units = "inch";
102
+ }
103
+ else if (n === 21) {
104
+ scale = 1;
105
+ units = "mm";
106
+ }
107
+ else if (n === 90)
108
+ absolute = true;
109
+ else if (n === 91)
110
+ absolute = false;
111
+ else if (n === 17 || n === 18 || n === 19)
112
+ plane = n;
113
+ else if (n >= 0 && n <= 3 && Number.isInteger(n))
114
+ motion = n;
115
+ else if (n >= 81 && n <= 89) {
116
+ const kind = controllerKind(machine);
117
+ if (kind === "grbl")
118
+ flag("canned-cycle", "problem", "Uses drilling cycles (G81–G89), which Grbl doesn't support. Post the program with cycles expanded to plain moves.", lineNo);
119
+ else if (kind !== "grblhal")
120
+ flag("canned-cycle", "check", "Uses drilling cycles (G81–G89). Plain Grbl doesn't support them; grblHAL does.", lineNo);
121
+ }
122
+ else if (n === 41 || n === 42) {
123
+ flag("cutter-comp", "problem", "Uses cutter radius compensation (G41/G42), which Grbl doesn't support. Post the program with compensation in the computer.", lineNo);
124
+ }
125
+ else if (n === 28 || n === 30) {
126
+ flag("stored-position", "check", "Moves to a stored position (G28/G30). Check where it is set before running.", lineNo);
127
+ }
128
+ else if (n === 53) {
129
+ flag("machine-coords", "check", "Has moves in machine coordinates (G53), which ignore your zero.", lineNo);
130
+ }
131
+ else if (n >= 38 && n < 39) {
132
+ flag("probe", "check", "Probes (G38). Make sure the probe is connected.", lineNo);
133
+ }
134
+ }
135
+ if (v.F !== undefined)
136
+ feed = v.F * scale;
137
+ if (v.S !== undefined && v.S > 0)
138
+ speeds.add(v.S);
139
+ if (v.T !== undefined) {
140
+ pendingTool = v.T;
141
+ // CAM comments usually repeat the tool number ("T1 6 mm flat end mill").
142
+ pendingLabel = lastComment?.replace(/^T\d+\s*[:·-]?\s*/i, "") || null;
143
+ }
144
+ for (const n of m) {
145
+ if (n === 3 || n === 4)
146
+ spindleOn = true;
147
+ else if (n === 5)
148
+ spindleOn = false;
149
+ else if (n === 6) {
150
+ const next = pendingTool;
151
+ if (next !== null && next !== tool) {
152
+ if (tool !== null || steps.length)
153
+ toolChanges++;
154
+ tool = next;
155
+ if (!tools.includes(next))
156
+ tools.push(next);
157
+ step = null; // the next move opens a step for the new tool
158
+ }
159
+ }
160
+ }
161
+ // A T word with no M6 still names the tool, for programs that rely on the sender.
162
+ if (pendingTool !== null && tool === null && !m.includes(6)) {
163
+ tool = pendingTool;
164
+ if (!tools.includes(tool))
165
+ tools.push(tool);
166
+ }
167
+ // G28/G30/G53 moves don't land at the program's own coordinates; skip them.
168
+ if (g.some((n) => n === 28 || n === 30 || n === 53 || n === 92 || n === 10))
169
+ continue;
170
+ const hasAxis = v.X !== undefined || v.Y !== undefined || v.Z !== undefined;
171
+ if (!hasAxis)
172
+ continue;
173
+ const target = { ...pos };
174
+ for (const a of AXES) {
175
+ const val = v[a.toUpperCase()];
176
+ if (val === undefined)
177
+ continue;
178
+ target[a] = absolute ? val * scale : pos[a] + val * scale;
179
+ }
180
+ if (!step)
181
+ openStep(lineNo);
182
+ const s = step;
183
+ const runFeed = motion === 0 ? null : feed;
184
+ const lastRun = runs[runs.length - 1];
185
+ if (!lastRun || lastRun.feed !== runFeed || lastRun.tool !== tool)
186
+ runs.push({ line: lineNo, feed: runFeed, tool });
187
+ s.lastLine = lineNo;
188
+ const dx = target.x - pos.x;
189
+ const dy = target.y - pos.y;
190
+ const dz = target.z - pos.z;
191
+ if (motion === 0) {
192
+ const len = Math.hypot(dx, dy, dz);
193
+ rapidLength += len;
194
+ const t = rapidKnown
195
+ ? Math.max(Math.abs(dx) / rapid.x, Math.abs(dy) / rapid.y, Math.abs(dz) / rapid.z) * 60
196
+ : (len / ASSUMED_RAPID) * 60;
197
+ rapidSeconds += t;
198
+ s.seconds += t;
199
+ if (moved && target.z < 0 && (dx !== 0 || dy !== 0 || dz < 0)) {
200
+ flag("rapid-in-stock", "check", "Has rapid moves below Z 0, which may plunge or travel through the stock at full speed.", lineNo);
201
+ }
202
+ }
203
+ else {
204
+ let len = Math.hypot(dx, dy, dz);
205
+ if ((motion === 2 || motion === 3) && plane === 17) {
206
+ const cx = v.I !== undefined ? pos.x + v.I * scale : null;
207
+ const cy = v.J !== undefined ? pos.y + v.J * scale : null;
208
+ if (cx !== null || cy !== null || v.R !== undefined) {
209
+ const ccx = cx ?? pos.x;
210
+ const ccy = cy ?? pos.y;
211
+ const r = v.R !== undefined ? Math.abs(v.R * scale) : Math.hypot(pos.x - ccx, pos.y - ccy);
212
+ let sweep;
213
+ if (v.R !== undefined) {
214
+ const chord = Math.min(Math.hypot(dx, dy), 2 * r);
215
+ sweep = 2 * Math.asin(chord / (2 * r || 1));
216
+ if (v.R < 0)
217
+ sweep = 2 * Math.PI - sweep;
218
+ }
219
+ else {
220
+ const a0 = Math.atan2(pos.y - ccy, pos.x - ccx);
221
+ const a1 = Math.atan2(target.y - ccy, target.x - ccx);
222
+ sweep = motion === 2 ? a0 - a1 : a1 - a0;
223
+ while (sweep <= 1e-9)
224
+ sweep += 2 * Math.PI;
225
+ // Arc bounds: include the circle's extreme points the sweep passes.
226
+ for (let k = 0; k < 4; k++) {
227
+ const ang = (k * Math.PI) / 2;
228
+ const rel = motion === 2 ? a0 - ang : ang - a0;
229
+ let d = rel % (2 * Math.PI);
230
+ if (d < 0)
231
+ d += 2 * Math.PI;
232
+ if (d < sweep)
233
+ include({ x: ccx + r * Math.cos(ang), y: ccy + r * Math.sin(ang), z: target.z });
234
+ }
235
+ }
236
+ len = Math.hypot(r * sweep, dz);
237
+ }
238
+ }
239
+ cutLength += len;
240
+ s.cutLength += len;
241
+ if (feed === null || feed <= 0) {
242
+ flag("no-feed", "problem", "Has a cutting move before any feed rate (F) is set. Grbl refuses it.", lineNo);
243
+ }
244
+ else {
245
+ feeds.add(round(feed, 1));
246
+ const t = (len / feed) * 60;
247
+ cuttingSeconds += t;
248
+ s.seconds += t;
249
+ const f = feed;
250
+ s.feedRange = s.feedRange ? [Math.min(s.feedRange[0], f), Math.max(s.feedRange[1], f)] : [f, f];
251
+ }
252
+ if (!spindleOn && Math.min(pos.z, target.z) < 0) {
253
+ flag("spindle-off", "problem", "Cuts below Z 0 with the spindle off (no M3 before it).", lineNo);
254
+ }
255
+ }
256
+ include(pos);
257
+ include(target);
258
+ const lowZ = Math.min(pos.z, target.z);
259
+ s.minZ = s.minZ === null ? lowZ : Math.min(s.minZ, lowZ);
260
+ pos = target;
261
+ moved = true;
262
+ }
263
+ if (toolChanges > 0) {
264
+ found.set("tool-changes", {
265
+ severity: "check",
266
+ message: `Changes tool ${toolChanges} time${toolChanges === 1 ? "" : "s"}. Grbl doesn't stop for M6 by itself; CNCjs's tool change setting has to pause the job so you can swap the tool and set its length.`,
267
+ lines: steps.slice(1).map((s) => s.firstLine).slice(0, LINE_SAMPLES),
268
+ });
269
+ }
270
+ const bounds = Number.isFinite(min.x) ? { min: roundVec(min), max: roundVec(max) } : null;
271
+ const fit = fitCheck(bounds, machine, setting);
272
+ if (fit.status === "over") {
273
+ for (const a of fit.axes) {
274
+ found.set(`travel-${a.axis}`, {
275
+ severity: "problem",
276
+ message: `Goes ${round(a.over, 1)} mm past the ${a.axis.toUpperCase()} travel. From the current zero it runs past the machine's ${a.direction} ${a.axis.toUpperCase()} end; move the zero or the stock.`,
277
+ lines: [],
278
+ });
279
+ }
280
+ }
281
+ const checks = [...found.entries()]
282
+ .map(([code, c]) => ({ code, ...c }))
283
+ .sort((a, b) => (a.severity === b.severity ? 0 : a.severity === "problem" ? -1 : 1));
284
+ const feedList = [...feeds].sort((a, b) => a - b);
285
+ return {
286
+ units,
287
+ bounds,
288
+ size: bounds ? roundVec({ x: bounds.max.x - bounds.min.x, y: bounds.max.y - bounds.min.y, z: bounds.max.z - bounds.min.z }) : null,
289
+ deepestZ: bounds ? bounds.min.z : null,
290
+ time: {
291
+ totalSeconds: Math.round(cuttingSeconds + rapidSeconds),
292
+ cuttingSeconds: Math.round(cuttingSeconds),
293
+ rapidSeconds: Math.round(rapidSeconds),
294
+ rapidRate: rapidKnown ? "machine" : "assumed",
295
+ },
296
+ cutLength: round(cutLength, 1),
297
+ rapidLength: round(rapidLength, 1),
298
+ feedRange: feedList.length ? [feedList[0], feedList[feedList.length - 1]] : null,
299
+ spindleSpeeds: [...speeds].sort((a, b) => a - b),
300
+ tools,
301
+ toolChanges,
302
+ steps: steps.map((s) => ({
303
+ ...s,
304
+ seconds: Math.round(s.seconds),
305
+ cutLength: round(s.cutLength, 1),
306
+ minZ: s.minZ === null ? null : round(s.minZ, 3),
307
+ feedRange: s.feedRange ? [round(s.feedRange[0], 1), round(s.feedRange[1], 1)] : null,
308
+ })),
309
+ fit,
310
+ checks,
311
+ runs,
312
+ };
313
+ }
314
+ function roundVec(p) {
315
+ return { x: round(p.x, 3), y: round(p.y, 3), z: round(p.z, 3) };
316
+ }
317
+ /**
318
+ * Whether the program stays inside the machine's travel from the current
319
+ * work zero. Grbl's machine coordinates run from 0 at the homed corner into
320
+ * minus by $130-$132, unless $23 flips an axis to home towards minus. Without
321
+ * homing ($22 off) machine coordinates mean nothing, so the fit is unknown.
322
+ */
323
+ function fitCheck(bounds, machine, setting) {
324
+ const unknown = (reason) => ({ status: "unknown", axes: [], reason });
325
+ if (!bounds)
326
+ return unknown("The program has no moves.");
327
+ if (!machine?.controller)
328
+ return unknown("No machine is connected.");
329
+ if (!/grbl/i.test(machine.controller))
330
+ return unknown("Only Grbl machines report their travel.");
331
+ if (!machine.mpos || !machine.wpos)
332
+ return unknown("The machine hasn't reported its position yet.");
333
+ const travel = { x: setting(130), y: setting(131), z: setting(132) };
334
+ if (travel.x === null || travel.y === null || travel.z === null)
335
+ return unknown("The machine hasn't reported its travel settings ($130–$132).");
336
+ if (!((setting(22) ?? 0) & 1))
337
+ return unknown("Homing is off ($22), so the machine doesn't know where its travel ends.");
338
+ const dirMask = setting(23) ?? 0;
339
+ const axes = [];
340
+ AXES.forEach((a, bit) => {
341
+ const offset = machine.mpos[a] - machine.wpos[a];
342
+ const towardsMinus = (dirMask >> bit) & 1;
343
+ const lo = towardsMinus ? 0 : -travel[a];
344
+ const hi = towardsMinus ? travel[a] : 0;
345
+ const from = bounds.min[a] + offset;
346
+ const to = bounds.max[a] + offset;
347
+ const tolerance = 0.01;
348
+ if (from < lo - tolerance)
349
+ axes.push({ axis: a, over: lo - from, direction: "minus" });
350
+ if (to > hi + tolerance)
351
+ axes.push({ axis: a, over: to - hi, direction: "plus" });
352
+ });
353
+ return axes.length
354
+ ? { status: "over", axes, reason: "From the current zero, the program leaves the machine's travel." }
355
+ : { status: "fits", axes, reason: "From the current zero, the program stays inside the machine's travel." };
356
+ }