@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/README.md +33 -0
- package/dist/growable.d.ts +96 -0
- package/dist/growable.d.ts.map +1 -0
- package/dist/growable.js +217 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/parse.d.ts +163 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +651 -0
- package/dist/protocol.d.ts +117 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +29 -0
- package/dist/session.d.ts +73 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +156 -0
- package/dist/streaming.d.ts +42 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +162 -0
- package/dist/worker-core.d.ts +69 -0
- package/dist/worker-core.d.ts.map +1 -0
- package/dist/worker-core.js +309 -0
- package/dist/worker-slim.d.ts +2 -0
- package/dist/worker-slim.d.ts.map +1 -0
- package/dist/worker-slim.js +14 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +34 -0
- package/package.json +56 -0
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
|
+
}
|