@chestnutlabs/gcode-parser 0.1.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.
package/dist/parse.js ADDED
@@ -0,0 +1,651 @@
1
+ /**
2
+ * Pure G-code parse core (DD-003 §14 phase 1, issue #44; async driver added in phase 2, #45).
3
+ *
4
+ * Ports the inherited xyz-tools interpreter semantics — motion handling (G0/G1),
5
+ * arc flattening (G2/G3 incl. R-mode and full circles), units (G20/G21), homing
6
+ * (G28), tool selection (T0–T7), travel/extrusion path splitting, and the
7
+ * layer-indexing rules (0.05 tolerance, non-planar clearing) — but writes the
8
+ * DD-001 `ToolpathIR` SoA buffers **directly** through a budget-aware growable
9
+ * writer, with **real** per-segment `srcByte`, extrusion deltas, and modal
10
+ * feed rate (the #29 adapter had to report those `unavailable`).
11
+ *
12
+ * Provenance: machine-state semantics adapted from the inherited MIT source
13
+ * (`src/interpreter.ts`, `src/gcode-parser.ts`, `src/indexers.ts` @ the founding
14
+ * baseline) — recorded in docs/UPSTREAM_PROVENANCE.md. Deliberate quirk-for-quirk
15
+ * fidelity (e.g. truthy `x || state.x` in arcs, tool captured at path start) is
16
+ * required by the golden-equivalence gate; do not "fix" behavior here without a
17
+ * reviewed golden regeneration.
18
+ *
19
+ * Two drivers share one engine:
20
+ * - `parseGcodeToIR` — synchronous (tests, Node tooling).
21
+ * - `parseGcodeToIRAsync` — the DD-003 §5.2 cooperative driver: processes at most
22
+ * `yieldIntervalMs` of CPU work between explicit MessageChannel macrotask yields
23
+ * so a queued worker `cancel` message can actually be dispatched; reports
24
+ * progress and honors a cancellation hook.
25
+ *
26
+ * Byte offsets assume single-byte (ASCII/UTF-8) G-code, which holds for slicer
27
+ * output; multi-byte comment bytes would skew offsets and are a documented
28
+ * limitation until the streaming decoder lands (phase 3).
29
+ */
30
+ import { IR_SCHEMA_VERSION, MoveKind, buildSourceIndex, computeSegmentBounds } from '@chestnutlabs/toolpath-core';
31
+ import { BudgetExceededError, SegmentWriter } from './growable.js';
32
+ export const DEFAULT_LIMITS = {
33
+ maxInputBytes: 512 * 1024 * 1024,
34
+ maxSegments: 20_000_000,
35
+ maxBufferBytes: 1536 * 1024 * 1024,
36
+ maxLineLength: 64 * 1024,
37
+ maxWarnings: 10_000
38
+ };
39
+ const LAYER_TOLERANCE = 0.05;
40
+ const UNRESOLVED_LAYER = 0xffffffff;
41
+ const SPLIT_LETTERS = /([a-zA-Z])/g;
42
+ /** Port of the inherited lexer: trim, comment split on ';', letter/value pairs. */
43
+ function lexLine(line) {
44
+ const input = line.trim();
45
+ const cmd = input.split(';')[0];
46
+ const parts = cmd
47
+ .split(SPLIT_LETTERS)
48
+ .slice(1)
49
+ .map((s) => s.trim());
50
+ const gcode = !parts.length ? '' : `${parts[0]?.toLowerCase()}${Number(parts[1])}`;
51
+ // Faithful replication of the inherited parseParams reduce (odd indices are values):
52
+ const params = {};
53
+ const rest = parts.slice(2);
54
+ for (let idx = 0; idx < rest.length; idx++) {
55
+ if (idx % 2 === 0)
56
+ continue;
57
+ const key = rest[idx - 1].toLowerCase();
58
+ const code = key.charCodeAt(0);
59
+ if ((code >= 97 && code <= 122) || (code >= 65 && code <= 90)) {
60
+ params[key] = parseFloat(rest[idx]);
61
+ }
62
+ }
63
+ return { gcode, params };
64
+ }
65
+ /** Internal: build an engine over decoded text (streaming passes '' and feeds lines itself). */
66
+ export function createEngine(input, opts) {
67
+ const limits = { ...DEFAULT_LIMITS, ...opts.limits };
68
+ const tolerance = opts.minLayerThreshold ?? LAYER_TOLERANCE;
69
+ const text = typeof input === 'string' ? input : new TextDecoder().decode(input);
70
+ let byteLength = typeof input === 'string' ? text.length : input.byteLength;
71
+ const warnings = new Map();
72
+ const stats = {
73
+ bytes: byteLength,
74
+ lines: 0,
75
+ commands: 0,
76
+ segments: 0,
77
+ points: 0,
78
+ retractions: 0,
79
+ deretractions: 0,
80
+ feedrateChanges: 0,
81
+ others: 0,
82
+ extrusionDistance: 0,
83
+ warningsByCode: {}
84
+ };
85
+ const warn = (code, message, srcByte) => {
86
+ stats.warningsByCode[code] = (stats.warningsByCode[code] ?? 0) + 1;
87
+ const existing = warnings.get(code);
88
+ if (existing) {
89
+ existing.count = (existing.count ?? 1) + 1;
90
+ }
91
+ else if (warnings.size < limits.maxWarnings) {
92
+ warnings.set(code, { code, message, severity: 'warn', srcByte, count: 1 });
93
+ }
94
+ };
95
+ const writer = new SegmentWriter(limits.maxBufferBytes);
96
+ // Machine state (port of State.initial + Interpreter fields).
97
+ let sx = 0;
98
+ let sy = 0;
99
+ let sz = 0;
100
+ let tool = 0;
101
+ let units = 'mm';
102
+ let unitsSeen = false;
103
+ let modalFeed = NaN;
104
+ // Floating origin (DD-001 §4.6): fixed at the first emitted segment's start.
105
+ let originSet = false;
106
+ let ox = 0;
107
+ let oy = 0;
108
+ let oz = 0;
109
+ // Path + layer tracking (port of Job/LayersIndexer semantics).
110
+ let path = null;
111
+ let prevX = 0;
112
+ let prevY = 0;
113
+ let prevZ = 0;
114
+ const layers = [];
115
+ let layersActive = true;
116
+ let layersCleared = false;
117
+ let carryLayer = 0;
118
+ let unindexedPaths = 0;
119
+ const toolsSeen = new Set();
120
+ let stopReason;
121
+ let truncatedAtByte;
122
+ let currentSrcByte = 0;
123
+ const emitSegment = (x, y, z, eDelta, kind) => {
124
+ if (writer.count >= limits.maxSegments) {
125
+ stopReason = {
126
+ code: 'E_LIMIT_SEGMENTS',
127
+ message: `segment limit ${limits.maxSegments} reached`,
128
+ srcByte: currentSrcByte
129
+ };
130
+ return false;
131
+ }
132
+ if (!originSet) {
133
+ originSet = true;
134
+ ox = prevX;
135
+ oy = prevY;
136
+ oz = prevZ;
137
+ }
138
+ try {
139
+ writer.push({
140
+ x0: prevX - ox,
141
+ y0: prevY - oy,
142
+ z0: prevZ - oz,
143
+ x1: x - ox,
144
+ y1: y - oy,
145
+ z1: z - oz,
146
+ e: eDelta,
147
+ feedrate: modalFeed,
148
+ kind,
149
+ tool: path?.tool ?? tool,
150
+ layer: UNRESOLVED_LAYER,
151
+ srcByte: currentSrcByte
152
+ });
153
+ }
154
+ catch (err) {
155
+ if (err instanceof BudgetExceededError) {
156
+ stopReason = { code: 'E_LIMIT_BUFFER_BYTES', message: err.message, srcByte: currentSrcByte };
157
+ return false;
158
+ }
159
+ if (err instanceof RangeError) {
160
+ // Engine-level allocation failure (§7.2 amendment): mapped to the same
161
+ // structured bounded result, never an unhandled exception or dead worker.
162
+ stopReason = {
163
+ code: 'E_LIMIT_BUFFER_BYTES',
164
+ message: `platform allocation failure: ${err.message}`,
165
+ srcByte: currentSrcByte
166
+ };
167
+ return false;
168
+ }
169
+ throw err;
170
+ }
171
+ if (path && Math.abs(z - prevZ) > tolerance) {
172
+ path.nonPlanar = true;
173
+ }
174
+ prevX = x;
175
+ prevY = y;
176
+ prevZ = z;
177
+ if (path) {
178
+ path.pointCount++;
179
+ }
180
+ return true;
181
+ };
182
+ /** Port of Job.finishPath + Job.indexPath (LayersIndexer.sortIn) + adapter carry-forward. */
183
+ const finishPath = () => {
184
+ if (path === null)
185
+ return;
186
+ const segEnd = writer.count - 1;
187
+ if (segEnd < path.segStart) {
188
+ path = null;
189
+ return;
190
+ }
191
+ let resolved;
192
+ if (layersActive) {
193
+ // Non-planar check: extrusion path with a consecutive z step beyond tolerance.
194
+ if (path.type === 'extrusion' && path.nonPlanar) {
195
+ // NonPlanarExtrusionError semantics: clear the layer index entirely.
196
+ layers.length = 0;
197
+ layersActive = false;
198
+ layersCleared = true;
199
+ warn('layers-cleared-non-planar', 'Non-planar extrusion detected; layer index cleared.', currentSrcByte);
200
+ }
201
+ else {
202
+ if (path.type === 'extrusion') {
203
+ const newZ = path.startZ;
204
+ const last = layers[layers.length - 1];
205
+ if (!last || newZ - last.z > tolerance) {
206
+ const lastZ = last?.z || 0;
207
+ layers.push({ z: newZ, height: newZ - lastZ, segStart: path.segStart, segEnd });
208
+ }
209
+ }
210
+ const last = layers[layers.length - 1];
211
+ if (last) {
212
+ resolved = layers.length - 1;
213
+ if (segEnd > last.segEnd)
214
+ last.segEnd = segEnd;
215
+ if (path.segStart < last.segStart)
216
+ last.segStart = path.segStart;
217
+ }
218
+ }
219
+ }
220
+ if (resolved !== undefined) {
221
+ carryLayer = resolved;
222
+ }
223
+ else {
224
+ unindexedPaths++;
225
+ }
226
+ writer.setLayerRange(path.segStart, segEnd, resolved ?? carryLayer);
227
+ toolsSeen.add(path.tool);
228
+ path = null;
229
+ };
230
+ const breakPath = (type) => {
231
+ finishPath();
232
+ path = {
233
+ type,
234
+ tool,
235
+ segStart: writer.count,
236
+ startZ: sz,
237
+ nonPlanar: false,
238
+ pointCount: 1
239
+ };
240
+ // breakPath adds the current position as the path's starting vertex.
241
+ prevX = sx;
242
+ prevY = sy;
243
+ prevZ = sz;
244
+ };
245
+ const g0 = (p) => {
246
+ const { x, y, z, e, f } = p;
247
+ if (x === undefined && y === undefined && z === undefined) {
248
+ if (e > 0)
249
+ stats.retractions++;
250
+ else if (e < 0)
251
+ stats.deretractions++;
252
+ else if (f !== undefined)
253
+ stats.feedrateChanges++;
254
+ else
255
+ stats.others++;
256
+ if (f !== undefined)
257
+ modalFeed = f;
258
+ return true;
259
+ }
260
+ stats.points++;
261
+ if (f !== undefined)
262
+ modalFeed = f;
263
+ const pathType = e > 0 ? 'extrusion' : 'travel';
264
+ if (path === null || path.type !== pathType) {
265
+ breakPath(pathType);
266
+ }
267
+ if (e > 0)
268
+ stats.extrusionDistance += e;
269
+ sx = x ?? sx;
270
+ sy = y ?? sy;
271
+ sz = z ?? sz;
272
+ return emitSegment(sx, sy, sz, e ?? 0, pathType === 'extrusion' ? MoveKind.Extrude : MoveKind.Travel);
273
+ };
274
+ const g2 = (p, cw) => {
275
+ const { x, y, z, e, f } = p;
276
+ let { i, j, r } = p;
277
+ if (f !== undefined)
278
+ modalFeed = f;
279
+ const pathType = e ? 'extrusion' : 'travel';
280
+ if (path === null || path.type !== pathType) {
281
+ breakPath(pathType);
282
+ }
283
+ if (e > 0)
284
+ stats.extrusionDistance += e;
285
+ if (r) {
286
+ const deltaX = x - sx; // assume abs mode (inherited)
287
+ const deltaY = y - sy;
288
+ const minR = Math.sqrt(Math.pow(deltaX / 2, 2) + Math.pow(deltaY / 2, 2));
289
+ r = Math.max(r, minR);
290
+ const dSquared = Math.pow(deltaX, 2) + Math.pow(deltaY, 2);
291
+ const hSquared = Math.pow(r, 2) - dSquared / 4;
292
+ let hDivD = Math.sqrt(hSquared / dSquared);
293
+ if ((cw && r < 0.0) || (!cw && r > 0.0))
294
+ hDivD = -hDivD;
295
+ i = deltaX / 2 + deltaY * hDivD;
296
+ j = deltaY / 2 - deltaX * hDivD;
297
+ }
298
+ const wholeCircle = sx == x && sy == y;
299
+ const centerX = sx + i;
300
+ const centerY = sy + j;
301
+ const arcRadius = Math.sqrt(i * i + j * j);
302
+ const arcCurrentAngle = Math.atan2(-j, -i);
303
+ const finalTheta = Math.atan2(y - centerY, x - centerX);
304
+ let totalArc;
305
+ if (wholeCircle) {
306
+ totalArc = 2 * Math.PI;
307
+ }
308
+ else {
309
+ totalArc = cw ? arcCurrentAngle - finalTheta : finalTheta - arcCurrentAngle;
310
+ if (totalArc < 0.0)
311
+ totalArc += 2 * Math.PI;
312
+ }
313
+ let totalSegments = (arcRadius * totalArc) / 0.5;
314
+ if (units == 'in')
315
+ totalSegments *= 25;
316
+ if (totalSegments < 1)
317
+ totalSegments = 1;
318
+ let arcAngleIncrement = totalArc / totalSegments;
319
+ arcAngleIncrement *= cw ? -1 : 1;
320
+ const zDist = sz - (z || sz);
321
+ const zStep = zDist / totalSegments;
322
+ const kind = (pathType === 'extrusion' ? MoveKind.Extrude : MoveKind.Travel) | MoveKind.ArcSegment;
323
+ const eachE = e !== undefined ? e / Math.max(1, Math.ceil(totalSegments)) : 0;
324
+ let px;
325
+ let py;
326
+ let pz = sz;
327
+ let currentAngle = arcCurrentAngle;
328
+ for (let moveIdx = 0; moveIdx < totalSegments - 1; moveIdx++) {
329
+ currentAngle += arcAngleIncrement;
330
+ px = centerX + arcRadius * Math.cos(currentAngle);
331
+ py = centerY + arcRadius * Math.sin(currentAngle);
332
+ pz += zStep;
333
+ if (!emitSegment(px, py, pz, eachE, kind))
334
+ return false;
335
+ }
336
+ sx = x || sx;
337
+ sy = y || sy;
338
+ sz = z || sz;
339
+ return emitSegment(sx, sy, sz, eachE, kind);
340
+ };
341
+ const onComment = opts.onComment;
342
+ const onCommand = opts.onCommand;
343
+ const processLine = (rawLine, offset) => {
344
+ stats.lines++;
345
+ currentSrcByte = offset;
346
+ if (rawLine.length > limits.maxLineLength) {
347
+ warn('line-too-long', `line exceeds maxLineLength=${limits.maxLineLength}; skipped`, offset);
348
+ return;
349
+ }
350
+ // DD-005 §4.3 read-only observation points — no effect on lexing or state.
351
+ if (onComment !== undefined) {
352
+ const ci = rawLine.indexOf(';');
353
+ if (ci !== -1)
354
+ onComment(rawLine.slice(ci + 1), offset);
355
+ }
356
+ const cmd = lexLine(rawLine);
357
+ if (cmd.gcode === '')
358
+ return;
359
+ stats.commands++;
360
+ if (onCommand !== undefined) {
361
+ onCommand({ gcode: cmd.gcode, params: cmd.params, rawLine, srcByte: offset, segIndex: writer.count });
362
+ }
363
+ let ok = true;
364
+ switch (cmd.gcode) {
365
+ case 'g0':
366
+ case 'g1':
367
+ ok = g0(cmd.params);
368
+ break;
369
+ case 'g2':
370
+ ok = g2(cmd.params, true);
371
+ break;
372
+ case 'g3':
373
+ ok = g2(cmd.params, false);
374
+ break;
375
+ case 'g20':
376
+ units = 'in';
377
+ unitsSeen = true;
378
+ break;
379
+ case 'g21':
380
+ units = 'mm';
381
+ unitsSeen = true;
382
+ break;
383
+ case 'g28':
384
+ sx = 0;
385
+ sy = 0;
386
+ sz = 0;
387
+ break;
388
+ case 't0':
389
+ case 't1':
390
+ case 't2':
391
+ case 't3':
392
+ case 't4':
393
+ case 't5':
394
+ case 't6':
395
+ case 't7':
396
+ tool = Number(cmd.gcode.slice(1));
397
+ break;
398
+ default:
399
+ warn('unsupported-command', `unsupported command '${cmd.gcode}' preserved as metadata`, offset);
400
+ }
401
+ if (!ok) {
402
+ truncatedAtByte = offset;
403
+ }
404
+ };
405
+ const finish = (cancelledAtByte) => {
406
+ if (stopReason !== undefined && truncatedAtByte === undefined) {
407
+ truncatedAtByte = currentSrcByte;
408
+ }
409
+ if (cancelledAtByte !== undefined && stopReason === undefined && truncatedAtByte === undefined) {
410
+ truncatedAtByte = cancelledAtByte;
411
+ }
412
+ finishPath();
413
+ if (layersCleared) {
414
+ writer.clearLayers();
415
+ carryLayer = 0;
416
+ }
417
+ const segments = writer.finalize();
418
+ stats.segments = segments.count;
419
+ const origin = { x: ox, y: oy, z: oz };
420
+ const { bounds, boundsWithTravel } = computeSegmentBounds(segments, origin);
421
+ const sourceIndex = buildSourceIndex(segments.srcByte, segments.count);
422
+ // Layer table for the IR (absolute z; seg ranges accumulated above).
423
+ const irLayers = layersCleared || layers.length === 0
424
+ ? segments.count > 0
425
+ ? [{ z: oz + segments.z1[0], segStart: 0, segEnd: segments.count - 1 }]
426
+ : []
427
+ : layers.map((l) => ({ z: l.z, segStart: l.segStart, segEnd: l.segEnd }));
428
+ const layersCapability = layersCleared || layers.length === 0 ? 'unavailable' : unindexedPaths > 0 ? 'inferred' : 'known';
429
+ const capabilities = {
430
+ geometry: 'known',
431
+ moveKind: 'known',
432
+ tools: 'known',
433
+ layers: layersCapability,
434
+ extrusionDelta: 'known',
435
+ feedrate: 'known',
436
+ sourcePositions: 'known',
437
+ featureRoles: 'unavailable',
438
+ objects: 'unavailable'
439
+ };
440
+ if (layersCapability === 'unavailable') {
441
+ warn('layers-unavailable', 'No planar layer index; all segments assigned to layer 0.');
442
+ }
443
+ else if (unindexedPaths > 0) {
444
+ warn('layers-partially-inferred', `${unindexedPaths} path(s) not in the layer index; layer carried forward.`);
445
+ }
446
+ const tools = [...toolsSeen].sort((a, b) => a - b).map((id) => ({ id }));
447
+ const complete = stopReason === undefined && cancelledAtByte === undefined;
448
+ const ir = {
449
+ header: {
450
+ irSchemaVersion: IR_SCHEMA_VERSION,
451
+ parserVersion: opts.parserVersion ?? '@chestnutlabs/gcode-parser@0.0.0',
452
+ source: { id: opts.sourceId, byteLength },
453
+ units,
454
+ unitsSource: unitsSeen ? 'known' : 'inferred',
455
+ originOffset: origin,
456
+ complete,
457
+ truncatedAtByte: complete ? undefined : truncatedAtByte,
458
+ dialects: [],
459
+ warnings: [...warnings.values()],
460
+ capabilities
461
+ },
462
+ segments,
463
+ layers: irLayers,
464
+ tools,
465
+ objects: [],
466
+ bounds,
467
+ boundsWithTravel,
468
+ sourceIndex
469
+ };
470
+ if (stopReason !== undefined) {
471
+ stats.stopReason = stopReason;
472
+ }
473
+ return { ir, stats };
474
+ };
475
+ // Force a mid-path snapshot cut once the open path grows this large — a
476
+ // single unbroken extrusion (vase/spiral mode) would otherwise never emit a
477
+ // preview. Mid-path segments carry UNRESOLVED_LAYER sentinels: preview-grade.
478
+ const FORCE_CUT_SEGMENTS = 65_536;
479
+ const snapshot = (fromSeg) => {
480
+ // Path-aligned cut: segments in the open path still carry UNRESOLVED_LAYER.
481
+ let cut = path === null ? writer.count : path.segStart;
482
+ if (cut <= fromSeg && writer.count - fromSeg >= FORCE_CUT_SEGMENTS) {
483
+ cut = writer.count;
484
+ }
485
+ if (cut <= fromSeg || !originSet)
486
+ return null;
487
+ let channels;
488
+ try {
489
+ channels = writer.snapshotRange(fromSeg, cut);
490
+ }
491
+ catch (err) {
492
+ if (err instanceof BudgetExceededError || err instanceof RangeError)
493
+ return null; // skip, never stop the parse
494
+ throw err;
495
+ }
496
+ const origin = { x: ox, y: oy, z: oz };
497
+ const { bounds, boundsWithTravel } = computeSegmentBounds(channels, origin);
498
+ const lastZ = channels.count > 0 ? oz + channels.z1[channels.count - 1] : oz;
499
+ const slice = {
500
+ header: {
501
+ irSchemaVersion: IR_SCHEMA_VERSION,
502
+ parserVersion: opts.parserVersion ?? '@chestnutlabs/gcode-parser@0.0.0',
503
+ source: { id: opts.sourceId, byteLength },
504
+ units,
505
+ unitsSource: unitsSeen ? 'known' : 'inferred',
506
+ originOffset: origin,
507
+ complete: false, // a snapshot is by definition not the whole toolpath
508
+ dialects: [],
509
+ warnings: [],
510
+ capabilities: {
511
+ geometry: 'known',
512
+ moveKind: 'known',
513
+ tools: 'known',
514
+ // Single coarse layer entry + globally-indexed layer channel: honest
515
+ // "approximated" — the final IR carries the real table.
516
+ layers: 'approximated',
517
+ extrusionDelta: 'known',
518
+ feedrate: 'known',
519
+ sourcePositions: 'unavailable', // no per-slice source index is built
520
+ featureRoles: 'unavailable',
521
+ objects: 'unavailable'
522
+ }
523
+ },
524
+ segments: channels,
525
+ layers: channels.count > 0 ? [{ z: lastZ, segStart: 0, segEnd: channels.count - 1 }] : [],
526
+ tools: [...toolsSeen].sort((a, b) => a - b).map((id) => ({ id })),
527
+ objects: [],
528
+ bounds,
529
+ boundsWithTravel,
530
+ sourceIndex: { byteOffsets: new Uint32Array(0), segmentIndices: new Uint32Array(0) }
531
+ };
532
+ return { slice, upTo: cut };
533
+ };
534
+ return {
535
+ text,
536
+ limits,
537
+ processLine,
538
+ stopped: () => stopReason !== undefined,
539
+ snapshot,
540
+ markInputTooBig: () => {
541
+ stopReason = {
542
+ code: 'E_LIMIT_INPUT_BYTES',
543
+ message: `input of ${byteLength} B exceeds maxInputBytes=${limits.maxInputBytes} B`,
544
+ srcByte: 0
545
+ };
546
+ truncatedAtByte = 0;
547
+ },
548
+ setSourceBytes: (bytes) => {
549
+ byteLength = bytes;
550
+ stats.bytes = bytes;
551
+ },
552
+ finish
553
+ };
554
+ }
555
+ /** Parse a whole G-code text into a ToolpathIR. Pure and synchronous. */
556
+ export function parseGcodeToIR(input, opts = {}) {
557
+ const engine = createEngine(input, opts);
558
+ const byteLength = typeof input === 'string' ? input.length : input.byteLength;
559
+ if (byteLength > engine.limits.maxInputBytes) {
560
+ engine.markInputTooBig();
561
+ return engine.finish();
562
+ }
563
+ const text = engine.text;
564
+ const len = text.length;
565
+ let offset = 0;
566
+ while (offset < len && !engine.stopped()) {
567
+ let nl = text.indexOf('\n', offset);
568
+ if (nl === -1)
569
+ nl = len;
570
+ engine.processLine(text.slice(offset, nl), offset);
571
+ offset = nl + 1;
572
+ }
573
+ return engine.finish();
574
+ }
575
+ /** Explicit macrotask yield via MessageChannel (DD-003 §5.2 — setTimeout is clamped). */
576
+ function yieldMacrotask() {
577
+ return new Promise((resolve) => {
578
+ const ch = new MessageChannel();
579
+ ch.port1.onmessage = () => {
580
+ ch.port1.close();
581
+ ch.port2.close();
582
+ resolve();
583
+ };
584
+ ch.port2.postMessage(null);
585
+ });
586
+ }
587
+ /**
588
+ * Cooperative async driver (DD-003 §5.2): identical parsing semantics, but the
589
+ * loop processes at most `yieldIntervalMs` (default 50) of CPU work between
590
+ * explicit macrotask yields so queued `cancel` messages are dispatched. Progress
591
+ * and cancellation are surfaced through hooks.
592
+ */
593
+ export async function parseGcodeToIRAsync(input, opts = {}, hooks = {}) {
594
+ const engine = createEngine(input, opts);
595
+ const byteLength = typeof input === 'string' ? input.length : input.byteLength;
596
+ if (byteLength > engine.limits.maxInputBytes) {
597
+ engine.markInputTooBig();
598
+ return { ...engine.finish(), cancelled: false };
599
+ }
600
+ const yieldEvery = hooks.yieldIntervalMs ?? 50;
601
+ const partial = makePartialEmitter(engine, hooks);
602
+ const text = engine.text;
603
+ const len = text.length;
604
+ let offset = 0;
605
+ let sliceStart = Date.now();
606
+ while (offset < len && !engine.stopped()) {
607
+ let nl = text.indexOf('\n', offset);
608
+ if (nl === -1)
609
+ nl = len;
610
+ engine.processLine(text.slice(offset, nl), offset);
611
+ offset = nl + 1;
612
+ if (Date.now() - sliceStart >= yieldEvery) {
613
+ hooks.onProgress?.(Math.min(offset, len), byteLength, 0);
614
+ partial(Math.min(offset, len));
615
+ await yieldMacrotask();
616
+ if (hooks.shouldCancel?.()) {
617
+ return { ...engine.finish(Math.min(offset, len)), cancelled: true };
618
+ }
619
+ sliceStart = Date.now();
620
+ }
621
+ }
622
+ hooks.onProgress?.(len, byteLength, 0);
623
+ return { ...engine.finish(), cancelled: false };
624
+ }
625
+ /**
626
+ * Shared partial-snapshot emitter for the async and streaming drivers (#60):
627
+ * called at cooperative yield points; emits a delta once bytesProcessed passes
628
+ * `partialMinBytes` and at most every `partialIntervalMs`.
629
+ */
630
+ export function makePartialEmitter(engine, hooks) {
631
+ const onPartial = hooks.onPartial;
632
+ if (onPartial === undefined)
633
+ return () => undefined;
634
+ const minBytes = hooks.partialMinBytes ?? 0;
635
+ const intervalMs = hooks.partialIntervalMs ?? 1000;
636
+ let sentSegs = 0;
637
+ let lastEmit = Date.now();
638
+ return (bytesProcessed) => {
639
+ if (bytesProcessed < minBytes)
640
+ return;
641
+ const now = Date.now();
642
+ if (now - lastEmit < intervalMs)
643
+ return;
644
+ const snap = engine.snapshot(sentSegs);
645
+ if (snap === null)
646
+ return;
647
+ lastEmit = now;
648
+ sentSegs = snap.upTo;
649
+ onPartial(snap.slice, snap.upTo);
650
+ };
651
+ }