@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.
@@ -0,0 +1,309 @@
1
+ import { parseGcodeToIRAsync } from './parse.js';
2
+ import { isBlobLike, isReadableStreamLike, parseGcodeStreamToIR } from './streaming.js';
3
+ import { PARTIAL_PREVIEW_MIN_BYTES, PROTOCOL_VERSION, irTransferList } from './protocol.js';
4
+ const PROGRESS_THROTTLE_MS = 100;
5
+ const DETECT_HEAD_BYTES = 64 * 1024;
6
+ const DETECT_TAIL_BYTES = 16 * 1024;
7
+ /** §4.5 stream replay: max recorded hook events before annotation degrades (visibly). */
8
+ const STREAM_EVENT_CAP = 500_000;
9
+ /**
10
+ * DD-005 §8 (ratified #78): container-extracted plate streams use a LOWER
11
+ * progressive-preview threshold than direct inputs — extraction adds fixed
12
+ * latency and a container already implies a substantial file. Measured: 8 MiB
13
+ * restores container TTFP to ~1.4 s @ a 100 MB plate (vs 3.7 s at 25 MiB) with
14
+ * zero total-time cost. An explicit consumer `partialPreview` setting wins.
15
+ */
16
+ const CONTAINER_PARTIAL_MIN_BYTES = 8 * 1024 * 1024;
17
+ /** Detection windows for sliceable inputs (DD-005 §4.5). Non-seekable streams: head/tail arrive in phase 4. */
18
+ async function detectWindows(input) {
19
+ if (typeof input === 'string') {
20
+ return { headText: input.slice(0, DETECT_HEAD_BYTES), tailText: input.slice(-DETECT_TAIL_BYTES) };
21
+ }
22
+ if (input instanceof Uint8Array) {
23
+ const dec = new TextDecoder();
24
+ return {
25
+ headText: dec.decode(input.subarray(0, DETECT_HEAD_BYTES)),
26
+ tailText: dec.decode(input.subarray(Math.max(0, input.byteLength - DETECT_TAIL_BYTES)))
27
+ };
28
+ }
29
+ if (isBlobLike(input)) {
30
+ const blob = input;
31
+ if (typeof blob.slice === 'function') {
32
+ const head = await blob.slice(0, DETECT_HEAD_BYTES).text();
33
+ const tail = await blob.slice(Math.max(0, blob.size - DETECT_TAIL_BYTES)).text();
34
+ return { headText: head, tailText: tail };
35
+ }
36
+ }
37
+ return { headText: '', tailText: '' };
38
+ }
39
+ /** Route by input form (§4.2): streams/Blobs use the line-drain driver, memory uses the async driver. */
40
+ function dispatchParse(input, opts, hooks) {
41
+ if (isBlobLike(input) || isReadableStreamLike(input)) {
42
+ return parseGcodeStreamToIR(input, opts, hooks);
43
+ }
44
+ return parseGcodeToIRAsync(input, opts, hooks);
45
+ }
46
+ export function createWorkerHandler(post, handlerOpts = {}) {
47
+ let activeId = null;
48
+ let cancelRequested = false;
49
+ return (msg) => {
50
+ if (typeof msg !== 'object' || msg === null || msg.v !== PROTOCOL_VERSION) {
51
+ const id = msg?.id ?? -1;
52
+ post({
53
+ v: PROTOCOL_VERSION,
54
+ type: 'error',
55
+ id,
56
+ error: {
57
+ code: 'E_PROTOCOL',
58
+ message: `unsupported message or protocol version (expected v${PROTOCOL_VERSION})`
59
+ }
60
+ });
61
+ return;
62
+ }
63
+ if (msg.type === 'cancel') {
64
+ if (msg.id === activeId) {
65
+ cancelRequested = true;
66
+ }
67
+ return;
68
+ }
69
+ if (msg.type === 'parse') {
70
+ if (activeId !== null) {
71
+ post({
72
+ v: PROTOCOL_VERSION,
73
+ type: 'error',
74
+ id: msg.id,
75
+ error: { code: 'E_BUSY', message: 'a parse is already in progress on this session' }
76
+ });
77
+ return;
78
+ }
79
+ activeId = msg.id;
80
+ cancelRequested = false;
81
+ const { partialOnCancel, yieldIntervalMs, partialPreview, dialects, dialectConfig, containers, plate, ...parseOpts } = msg.opts ?? {};
82
+ let lastProgress = 0;
83
+ const hooks = {
84
+ yieldIntervalMs,
85
+ shouldCancel: () => cancelRequested,
86
+ onProgress: (bytesProcessed, totalBytes) => {
87
+ const now = Date.now();
88
+ if (now - lastProgress >= PROGRESS_THROTTLE_MS) {
89
+ lastProgress = now;
90
+ post({ v: PROTOCOL_VERSION, type: 'progress', id: msg.id, bytesProcessed, totalBytes, phase: 'parsing' });
91
+ }
92
+ }
93
+ };
94
+ // Progressive preview (#60): on by default with the §5.4 25 MiB threshold;
95
+ // partials transfer zero-copy like the terminal IR.
96
+ if (partialPreview !== false) {
97
+ hooks.partialMinBytes = partialPreview?.minInputBytes ?? PARTIAL_PREVIEW_MIN_BYTES;
98
+ hooks.partialIntervalMs = partialPreview?.intervalMs ?? 1000;
99
+ hooks.onPartial = (slice, cumulativeSegments) => {
100
+ post({ v: PROTOCOL_VERSION, type: 'partial', id: msg.id, slice, cumulativeSegments }, irTransferList(slice));
101
+ };
102
+ }
103
+ void (async () => {
104
+ // Container resolution (DD-005 §4.4): sniff sliceable inputs; on a
105
+ // match the selected plate's stream becomes the parse input and the
106
+ // container's metadata rides beside the IR.
107
+ let input = msg.input;
108
+ let opened = null;
109
+ const containerSel = containers ?? 'auto';
110
+ const containerWarnings = [];
111
+ if (containerSel !== false && (handlerOpts.containers?.length ?? 0) > 0) {
112
+ let bytes = null;
113
+ if (input instanceof Uint8Array)
114
+ bytes = input;
115
+ else if (isBlobLike(input)) {
116
+ const blob = input;
117
+ if (typeof blob.arrayBuffer === 'function')
118
+ bytes = new Uint8Array(await blob.arrayBuffer());
119
+ }
120
+ if (bytes !== null && bytes.byteLength >= 4) {
121
+ const adapter = handlerOpts.containers.find((a) => (containerSel === 'auto' || containerSel.includes(a.id)) && a.sniff(bytes.subarray(0, 8)));
122
+ if (adapter !== undefined) {
123
+ try {
124
+ opened = await adapter.open(bytes);
125
+ }
126
+ catch (err) {
127
+ const code = err instanceof Error && 'code' in err && typeof err.code === 'string'
128
+ ? err.code
129
+ : 'E_CONTAINER';
130
+ post({
131
+ v: PROTOCOL_VERSION,
132
+ type: 'error',
133
+ id: msg.id,
134
+ error: { code, message: err instanceof Error ? err.message : String(err) }
135
+ });
136
+ return;
137
+ }
138
+ const plateIdx = plate ?? 0;
139
+ if (plateIdx < 0 || plateIdx >= opened.plates.length) {
140
+ post({
141
+ v: PROTOCOL_VERSION,
142
+ type: 'error',
143
+ id: msg.id,
144
+ error: {
145
+ code: 'E_CONTAINER_NO_PAYLOAD',
146
+ message: `plate ${plateIdx} does not exist (container has ${opened.plates.length})`
147
+ }
148
+ });
149
+ return;
150
+ }
151
+ if (opened.plates.length > 1 && plate === undefined) {
152
+ containerWarnings.push({
153
+ code: 'container-multiple-plates',
154
+ message: `container has ${opened.plates.length} plates ` +
155
+ `(${opened.plates.map((p) => p.name).join(', ')}); parsing plate 0 — pass {plate} to select`,
156
+ severity: 'warn',
157
+ count: 1
158
+ });
159
+ }
160
+ for (const w of opened.metadata.warnings) {
161
+ containerWarnings.push({ ...w, severity: 'warn', count: 1 });
162
+ }
163
+ input = opened.openPlate(plateIdx);
164
+ // §8: container path defaults to the lower preview threshold
165
+ // unless the consumer configured partialPreview explicitly.
166
+ if (partialPreview === undefined && hooks.onPartial !== undefined) {
167
+ hooks.partialMinBytes = CONTAINER_PARTIAL_MIN_BYTES;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ // Dialect annotation (DD-005 §4.5): composed by the worker entry; the
173
+ // main thread only ever sent serializable IDs/config.
174
+ let run = null;
175
+ const selection = dialects ?? 'auto';
176
+ let slimRequested = false;
177
+ if (selection !== false) {
178
+ if (handlerOpts.dialects !== undefined) {
179
+ const windows = opened === null ? await detectWindows(input) : { headText: '', tailText: '' };
180
+ run = handlerOpts.dialects.createRun({
181
+ selection,
182
+ config: dialectConfig,
183
+ headText: windows.headText,
184
+ tailText: windows.tailText,
185
+ containerMeta: opened?.metadata.raw
186
+ });
187
+ if (run !== null) {
188
+ parseOpts.onComment = run.onComment;
189
+ parseOpts.onCommand = run.onCommand;
190
+ }
191
+ }
192
+ else if (Array.isArray(selection)) {
193
+ slimRequested = true; // explicit request against a slim worker — degrade VISIBLY
194
+ }
195
+ }
196
+ let streamReplay = null;
197
+ if (run === null &&
198
+ selection !== false &&
199
+ handlerOpts.dialects !== undefined &&
200
+ opened === null &&
201
+ isReadableStreamLike(input)) {
202
+ const rec = { events: [], overflow: false, windows: { head: '', tail: '' } };
203
+ streamReplay = rec;
204
+ hooks.captureWindows = rec.windows;
205
+ parseOpts.onComment = (t, b) => {
206
+ if (rec.events.length < STREAM_EVENT_CAP)
207
+ rec.events.push({ kind: 'comment', t, b });
208
+ else
209
+ rec.overflow = true;
210
+ };
211
+ parseOpts.onCommand = (e) => {
212
+ if (rec.events.length < STREAM_EVENT_CAP)
213
+ rec.events.push({ kind: 'command', e });
214
+ else
215
+ rec.overflow = true;
216
+ };
217
+ }
218
+ const result = await dispatchParse(input, parseOpts, hooks);
219
+ if (result.cancelled) {
220
+ const partial = partialOnCancel ? { ir: result.ir, stats: result.stats } : undefined;
221
+ post({ v: PROTOCOL_VERSION, type: 'cancelled', id: msg.id, partial }, partial ? irTransferList(partial.ir) : undefined);
222
+ return;
223
+ }
224
+ if (streamReplay !== null) {
225
+ if (streamReplay.overflow) {
226
+ result.ir.header.warnings.push({
227
+ code: 'dialect-stream-truncated',
228
+ message: 'stream exceeded the dialect replay budget; annotation skipped — ' +
229
+ 'use Uint8Array/Blob input for full annotation of very large files',
230
+ severity: 'warn',
231
+ count: 1
232
+ });
233
+ }
234
+ else {
235
+ run = handlerOpts.dialects.createRun({
236
+ selection: selection,
237
+ config: dialectConfig,
238
+ headText: streamReplay.windows.head,
239
+ tailText: streamReplay.windows.tail
240
+ });
241
+ if (run !== null) {
242
+ for (const ev of streamReplay.events) {
243
+ if (ev.kind === 'comment')
244
+ run.onComment(ev.t, ev.b);
245
+ else
246
+ run.onCommand(ev.e);
247
+ }
248
+ }
249
+ }
250
+ }
251
+ let metadata;
252
+ if (run !== null) {
253
+ metadata = run.finalize(result.ir).metadata;
254
+ }
255
+ // Merge container metadata (§4.4): container-config machine data is
256
+ // 'known' and outranks comment-inferred geometry; ties go to the
257
+ // container (it is the project's own machine config).
258
+ if (opened !== null) {
259
+ metadata = metadata ?? {};
260
+ const cm = opened.metadata.machine;
261
+ if (cm !== undefined && (metadata.machine === undefined || cm.confidence === 'known')) {
262
+ metadata.machine = cm;
263
+ }
264
+ if (opened.metadata.filaments !== undefined && opened.metadata.filaments.length > 0) {
265
+ metadata.filaments = [...(metadata.filaments ?? []), ...opened.metadata.filaments];
266
+ }
267
+ if (Object.keys(opened.metadata.raw).length > 0) {
268
+ metadata.raw = { ...opened.metadata.raw, ...(metadata.raw ?? {}) };
269
+ }
270
+ }
271
+ for (const w of containerWarnings) {
272
+ result.ir.header.warnings.push(w);
273
+ }
274
+ if (slimRequested) {
275
+ result.ir.header.warnings.push({
276
+ code: 'dialects-unavailable',
277
+ message: 'dialect adapters requested but this worker entry bundles none (worker-slim)',
278
+ severity: 'warn',
279
+ count: 1
280
+ });
281
+ }
282
+ // Terminal-message contract (§4.3): complete AND limit-bounded parses both
283
+ // arrive as `done` (limit-bounded: complete:false + stats.stopReason).
284
+ const transfer = irTransferList(result.ir);
285
+ for (const t of metadata?.thumbnails ?? []) {
286
+ transfer.push(t.bytes.buffer);
287
+ }
288
+ post({ v: PROTOCOL_VERSION, type: 'done', id: msg.id, ir: result.ir, stats: result.stats, metadata }, transfer);
289
+ })()
290
+ .catch((err) => {
291
+ // Preserve structured codes (e.g. mid-stream E_CONTAINER_CRC from a
292
+ // plate stream) instead of collapsing everything into E_INTERNAL.
293
+ const code = err instanceof Error && 'code' in err && typeof err.code === 'string'
294
+ ? err.code
295
+ : 'E_INTERNAL';
296
+ post({
297
+ v: PROTOCOL_VERSION,
298
+ type: 'error',
299
+ id: msg.id,
300
+ error: { code, message: err instanceof Error ? err.message : String(err) }
301
+ });
302
+ })
303
+ .finally(() => {
304
+ activeId = null;
305
+ cancelRequested = false;
306
+ });
307
+ }
308
+ };
309
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=worker-slim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-slim.d.ts","sourceRoot":"","sources":["../src/worker-slim.ts"],"names":[],"mappings":""}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Web Worker entry — SLIM (DD-005 §4.5): zero dialect adapters bundled.
3
+ *
4
+ * Smallest payload for consumers that never want annotation. Explicit dialect
5
+ * requests against this entry degrade VISIBLY (`dialects-unavailable` warning
6
+ * on the result), never silently. Use via the session escape hatch:
7
+ * `new GcodeParseSession({ worker: () => new Worker(new URL(
8
+ * '@chestnutlabs/gcode-parser/dist/worker-slim.js', import.meta.url),
9
+ * { type: 'module' }) })`.
10
+ */
11
+ import { createWorkerHandler } from './worker-core.js';
12
+ const post = (msg, transfer) => self.postMessage(msg, transfer);
13
+ const handle = createWorkerHandler(post);
14
+ self.onmessage = (ev) => handle(ev.data);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":""}
package/dist/worker.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Web Worker entry — BATTERIES-INCLUDED (DD-003 §4.4 option (a); DD-005 §4.5).
3
+ *
4
+ * Consumers' bundlers (Vite/webpack/Rollup) bundle this file automatically via
5
+ * `new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })`
6
+ * in `session.ts`. All logic lives in the transport-agnostic `worker-core`;
7
+ * this entry composes it with the built-in dialect adapter set (currently
8
+ * empty — vendor adapters land in DD-005 phases 3–5; the wiring is live so
9
+ * they activate here without API changes). Size-sensitive consumers use
10
+ * `worker-slim.ts`; custom adapters use a consumer-supplied worker entry
11
+ * calling `createWorkerHandler(post, { dialects: createDialectRunner([...]) })`.
12
+ */
13
+ import { createDialectRunner, cura, klipper, marlin, orcaBambu, prusaSlicer, repRap } from '@chestnutlabs/gcode-dialects';
14
+ import { openGcode3mf, sniffGcode3mf } from '@chestnutlabs/gcode-containers';
15
+ import { createWorkerHandler } from './worker-core.js';
16
+ /** Built-in dialect adapter set (DD-005 phases 3–4; phase 5 adds command semantics). */
17
+ const BUILTIN_ADAPTERS = [
18
+ prusaSlicer(),
19
+ orcaBambu(),
20
+ cura(),
21
+ klipper(),
22
+ marlin(),
23
+ repRap()
24
+ ];
25
+ /** Built-in container adapters (DD-005 §4.4): .gcode.3mf. */
26
+ const BUILTIN_CONTAINERS = [
27
+ { id: 'gcode-3mf', sniff: sniffGcode3mf, open: (bytes) => openGcode3mf(bytes) }
28
+ ];
29
+ const post = (msg, transfer) => self.postMessage(msg, transfer);
30
+ const handle = createWorkerHandler(post, {
31
+ dialects: createDialectRunner(BUILTIN_ADAPTERS),
32
+ containers: BUILTIN_CONTAINERS
33
+ });
34
+ self.onmessage = (ev) => handle(ev.data);
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@chestnutlabs/gcode-parser",
3
+ "version": "0.1.0",
4
+ "description": "Worker-safe G-code parse core producing ToolpathIR (DD-003).",
5
+ "keywords": [
6
+ "gcode",
7
+ "3d-printing",
8
+ "toolpath",
9
+ "cnc",
10
+ "chestnutlabs",
11
+ "parser",
12
+ "web-worker",
13
+ "streaming",
14
+ "marlin",
15
+ "klipper"
16
+ ],
17
+ "author": "Chestnut Labs",
18
+ "homepage": "https://github.com/ChestnutLabs/gcode-preview#readme",
19
+ "bugs": "https://github.com/ChestnutLabs/gcode-preview/issues",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/ChestnutLabs/gcode-preview.git",
23
+ "directory": "packages/gcode-parser"
24
+ },
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.js",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
49
+ "test": "vitest run"
50
+ },
51
+ "dependencies": {
52
+ "@chestnutlabs/toolpath-core": "0.1.0",
53
+ "@chestnutlabs/gcode-dialects": "0.1.0",
54
+ "@chestnutlabs/gcode-containers": "0.1.0"
55
+ }
56
+ }