@chestnutlabs/gcode-parser 0.3.0 → 0.4.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.
@@ -28,6 +28,9 @@ export interface SegmentRecord {
28
28
  tool: number;
29
29
  layer: number;
30
30
  srcByte: number;
31
+ /** Current value of each requested opt-in modal channel (DD-012 D3), keyed by channel id.
32
+ * Missing/undefined entries store NaN — an honest "no value here", never a fabricated 0. */
33
+ modal?: Record<string, number>;
31
34
  }
32
35
  export interface FinalChannels {
33
36
  count: number;
@@ -45,6 +48,9 @@ export interface FinalChannels {
45
48
  feature: Uint8Array;
46
49
  object: Uint32Array;
47
50
  srcByte: Uint32Array;
51
+ /** Opt-in modal channels (DD-012 D3), present only when requested at construction. Each is a
52
+ * Float32 column of length `count`; an unset value is NaN, never a fabricated 0. */
53
+ modal?: Record<string, Float32Array>;
48
54
  }
49
55
  export declare class SegmentWriter {
50
56
  private readonly maxBufferBytes;
@@ -68,7 +74,13 @@ export declare class SegmentWriter {
68
74
  private accountedBytes;
69
75
  /** Extra live bytes the caller wants included in the budget (line buffer, warnings). */
70
76
  private externalBytes;
71
- constructor(maxBufferBytes: number);
77
+ /** Opt-in modal channel ids (DD-012 D3), stable order; empty for a default parse. */
78
+ private readonly modalNames;
79
+ /** Growable Float32 columns, parallel to `modalNames`. */
80
+ private modalArrays;
81
+ /** Bytes per segment across the core set plus any requested modal columns. */
82
+ private readonly bytesPerSegment;
83
+ constructor(maxBufferBytes: number, modalChannels?: readonly string[]);
72
84
  get count(): number;
73
85
  get accounted(): number;
74
86
  /** Report additional live working bytes (e.g., the line-drain buffer) into the budget. */
@@ -82,6 +94,9 @@ export declare class SegmentWriter {
82
94
  setLayerRange(start: number, end: number, layerIndex: number): void;
83
95
  /** Zero the whole layer channel (non-planar clearing semantics). */
84
96
  clearLayers(): void;
97
+ /** Build the optional `modal` output record (DD-012 D3), or `{}` when no channels were requested
98
+ * so a default parse yields no `modal` key at all. `view` slices/subarrays each column to count. */
99
+ private modalOutput;
85
100
  /**
86
101
  * Copy a segment range into fresh right-sized arrays (progressive-preview
87
102
  * snapshots, DD-004 §5.4 / issue #60). Budget-checked: the copies coexist with
@@ -1 +1 @@
1
- {"version":3,"file":"growable.d.ts","sourceRoot":"","sources":["../src/growable.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,qBAAa,mBAAoB,SAAQ,KAAK;aAE1B,cAAc,EAAE,MAAM;aACtB,cAAc,EAAE,MAAM;aACtB,cAAc,EAAE,MAAM;gBAFtB,cAAc,EAAE,MAAM,EACtB,cAAc,EAAE,MAAM,EACtB,cAAc,EAAE,MAAM;CAOzC;AAuBD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,CAAC,EAAE,MAAM,CAAC;IACV,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,YAAY,CAAC;IACjB,EAAE,EAAE,YAAY,CAAC;IACjB,EAAE,EAAE,YAAY,CAAC;IACjB,EAAE,EAAE,YAAY,CAAC;IACjB,EAAE,EAAE,YAAY,CAAC;IACjB,EAAE,EAAE,YAAY,CAAC;IACjB,CAAC,EAAE,YAAY,CAAC;IAChB,QAAQ,EAAE,YAAY,CAAC;IACvB,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,EAAE,WAAW,CAAC;IAClB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,WAAW,CAAC;IACpB,OAAO,EAAE,WAAW,CAAC;CACtB;AAID,qBAAa,aAAa;IAwBZ,OAAO,CAAC,QAAQ,CAAC,cAAc;IAvB3C,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,MAAM,CAAK;IAEnB,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,CAAC,CAAgB;IACzB,OAAO,CAAC,QAAQ,CAAgB;IAChC,OAAO,CAAC,IAAI,CAAc;IAC1B,OAAO,CAAC,IAAI,CAAe;IAC3B,OAAO,CAAC,KAAK,CAAe;IAC5B,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,OAAO,CAAe;IAE9B,wEAAwE;IACxE,OAAO,CAAC,cAAc,CAAK;IAC3B,wFAAwF;IACxF,OAAO,CAAC,aAAa,CAAK;gBAEG,cAAc,EAAE,MAAM;IAInD,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,0FAA0F;IAC1F,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAIrC,mFAAmF;IACnF,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,QAAQ;IAwDhB,yFAAyF;IACzF,IAAI,CAAC,CAAC,EAAE,aAAa,GAAG,IAAI;IAsB5B,4FAA4F;IAC5F,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAInE,oEAAoE;IACpE,WAAW,IAAI,IAAI;IAInB;;;;;;OAMG;IACH,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,aAAa;IAsBxD,qFAAqF;IACrF,QAAQ,IAAI,aAAa;CAsB1B"}
1
+ {"version":3,"file":"growable.d.ts","sourceRoot":"","sources":["../src/growable.ts"],"names":[],"mappings":"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"}
package/dist/growable.js CHANGED
@@ -37,7 +37,8 @@ const CHANNELS = [
37
37
  { name: 'object', bytesPerElement: 4 },
38
38
  { name: 'srcByte', bytesPerElement: 4 }
39
39
  ];
40
- const BYTES_PER_SEGMENT = CHANNELS.reduce((a, c) => a + c.bytesPerElement, 0); // 40 B core set
40
+ const CORE_BYTES_PER_SEGMENT = CHANNELS.reduce((a, c) => a + c.bytesPerElement, 0); // 40 B core set
41
+ const MODAL_BYTES_PER_ELEMENT = 4; // each opt-in modal channel is one Float32 column
41
42
  const INITIAL_CAPACITY = 4096;
42
43
  export class SegmentWriter {
43
44
  maxBufferBytes;
@@ -61,8 +62,16 @@ export class SegmentWriter {
61
62
  accountedBytes = 0;
62
63
  /** Extra live bytes the caller wants included in the budget (line buffer, warnings). */
63
64
  externalBytes = 0;
64
- constructor(maxBufferBytes) {
65
+ /** Opt-in modal channel ids (DD-012 D3), stable order; empty for a default parse. */
66
+ modalNames;
67
+ /** Growable Float32 columns, parallel to `modalNames`. */
68
+ modalArrays = [];
69
+ /** Bytes per segment across the core set plus any requested modal columns. */
70
+ bytesPerSegment;
71
+ constructor(maxBufferBytes, modalChannels = []) {
65
72
  this.maxBufferBytes = maxBufferBytes;
73
+ this.modalNames = modalChannels;
74
+ this.bytesPerSegment = CORE_BYTES_PER_SEGMENT + MODAL_BYTES_PER_ELEMENT * modalChannels.length;
66
75
  this.allocate(INITIAL_CAPACITY);
67
76
  }
68
77
  get count() {
@@ -83,7 +92,7 @@ export class SegmentWriter {
83
92
  }
84
93
  allocate(newCapacity) {
85
94
  // During a grow/compact, old and new capacity coexist until copies complete.
86
- const newBytes = newCapacity * BYTES_PER_SEGMENT;
95
+ const newBytes = newCapacity * this.bytesPerSegment;
87
96
  this.ensureBudget(newBytes);
88
97
  const nx0 = new Float32Array(newCapacity);
89
98
  const ny0 = new Float32Array(newCapacity);
@@ -99,6 +108,13 @@ export class SegmentWriter {
99
108
  const nfeature = new Uint8Array(newCapacity);
100
109
  const nobject = new Uint32Array(newCapacity);
101
110
  const nsrc = new Uint32Array(newCapacity);
111
+ // Opt-in modal columns grow in lockstep with the core set.
112
+ const nmodal = this.modalNames.map((_, k) => {
113
+ const a = new Float32Array(newCapacity);
114
+ if (this.capacity > 0)
115
+ a.set(this.modalArrays[k].subarray(0, this._count));
116
+ return a;
117
+ });
102
118
  if (this.capacity > 0) {
103
119
  nx0.set(this.x0.subarray(0, this._count));
104
120
  ny0.set(this.y0.subarray(0, this._count));
@@ -115,7 +131,8 @@ export class SegmentWriter {
115
131
  nobject.set(this.object.subarray(0, this._count));
116
132
  nsrc.set(this.srcByte.subarray(0, this._count));
117
133
  }
118
- const oldBytes = this.capacity * BYTES_PER_SEGMENT;
134
+ const oldBytes = this.capacity * this.bytesPerSegment;
135
+ this.modalArrays = nmodal;
119
136
  this.x0 = nx0;
120
137
  this.y0 = ny0;
121
138
  this.z0 = nz0;
@@ -153,6 +170,10 @@ export class SegmentWriter {
153
170
  this.feature[i] = 0;
154
171
  this.object[i] = 0;
155
172
  this.srcByte[i] = s.srcByte;
173
+ for (let k = 0; k < this.modalNames.length; k++) {
174
+ const v = s.modal?.[this.modalNames[k]];
175
+ this.modalArrays[k][i] = v === undefined ? NaN : v;
176
+ }
156
177
  this._count++;
157
178
  }
158
179
  /** Overwrite the layer channel for a segment range (used for post-hoc layer resolution). */
@@ -163,6 +184,16 @@ export class SegmentWriter {
163
184
  clearLayers() {
164
185
  this.layer.fill(0, 0, this._count);
165
186
  }
187
+ /** Build the optional `modal` output record (DD-012 D3), or `{}` when no channels were requested
188
+ * so a default parse yields no `modal` key at all. `view` slices/subarrays each column to count. */
189
+ modalOutput(view) {
190
+ if (this.modalNames.length === 0)
191
+ return {};
192
+ const modal = {};
193
+ for (let k = 0; k < this.modalNames.length; k++)
194
+ modal[this.modalNames[k]] = view(this.modalArrays[k]);
195
+ return { modal };
196
+ }
166
197
  /**
167
198
  * Copy a segment range into fresh right-sized arrays (progressive-preview
168
199
  * snapshots, DD-004 §5.4 / issue #60). Budget-checked: the copies coexist with
@@ -172,9 +203,10 @@ export class SegmentWriter {
172
203
  */
173
204
  snapshotRange(start, end) {
174
205
  const count = Math.max(0, end - start);
175
- this.ensureBudget(count * BYTES_PER_SEGMENT);
206
+ this.ensureBudget(count * this.bytesPerSegment);
176
207
  return {
177
208
  count,
209
+ ...this.modalOutput((a) => a.slice(start, end)),
178
210
  x0: this.x0.slice(start, end),
179
211
  y0: this.y0.slice(start, end),
180
212
  z0: this.z0.slice(start, end),
@@ -198,6 +230,7 @@ export class SegmentWriter {
198
230
  }
199
231
  return {
200
232
  count: this._count,
233
+ ...this.modalOutput((a) => a.subarray(0, this._count)),
201
234
  x0: this.x0.subarray(0, this._count),
202
235
  y0: this.y0.subarray(0, this._count),
203
236
  z0: this.z0.subarray(0, this._count),
package/dist/parse.d.ts CHANGED
@@ -63,6 +63,14 @@ export interface ParseOptions {
63
63
  * that has confidently classified the firmware. The byte-exact engine never sniffs firmware itself.
64
64
  */
65
65
  extruderFollowsPositioning?: boolean;
66
+ /**
67
+ * Opt-in modal channels to capture per segment (DD-012 D3, #189). Each requested id adds one
68
+ * Float32 column to the IR (`segments.modal[id]`); a default parse requests none and pays nothing,
69
+ * so FDM output is unchanged. Supported id: `'toolPower'` — the modal spindle/laser `S` value while
70
+ * a tool is engaged (`M3`/`M4`), NaN when the tool is off. Presentation (Watts vs RPM) is a dialect
71
+ * label, not a separate channel (DD-012 D4). Fan/temp/accel etc. (#180) reuse the same mechanism.
72
+ */
73
+ modalChannels?: readonly string[];
66
74
  /**
67
75
  * DD-005 §4.3 read-only hooks: observe comments/commands during the parse.
68
76
  * Inert when unset (one branch per line); they cannot alter lexing, dispatch,
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"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"}
package/dist/parse.js CHANGED
@@ -38,29 +38,61 @@ export const DEFAULT_LIMITS = {
38
38
  };
39
39
  const LAYER_TOLERANCE = 0.05;
40
40
  const UNRESOLVED_LAYER = 0xffffffff;
41
+ /** Opt-in modal channels this engine can capture (DD-012 D3, #189). Requested via
42
+ * `ParseOptions.modalChannels`; unknown ids are ignored with a warning. Extend as later phases
43
+ * (spindle/laser already covered by `toolPower`; #180's fan/temp/accel land here next). */
44
+ const SUPPORTED_MODAL_CHANNELS = new Set(['toolPower']);
41
45
  const SPLIT_LETTERS = /([a-zA-Z])/g;
42
- /** Port of the inherited lexer: trim, comment split on ';', letter/value pairs. */
46
+ /**
47
+ * Lex a line into its G/M/T command words plus the axis/param words. Real CNC G-code routinely puts
48
+ * several commands on one line and prefixes lines with `N` line numbers; both are handled here.
49
+ * `N`-words (line numbers) are dropped, and non-command letters become params. FDM lines have a single
50
+ * command word, so `codes = [thatCommand]` and behavior is byte-identical to the inherited lexer.
51
+ */
43
52
  function lexLine(line) {
44
- const input = line.trim();
45
- const cmd = input.split(';')[0];
46
- const parts = cmd
53
+ const body = line.trim().split(';')[0];
54
+ const parts = body
47
55
  .split(SPLIT_LETTERS)
48
56
  .slice(1)
49
57
  .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):
58
+ const codes = [];
52
59
  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]);
60
+ let sawCommand = false;
61
+ for (let i = 0; i + 1 < parts.length; i += 2) {
62
+ const key = parts[i].toLowerCase();
63
+ const cc = key.charCodeAt(0);
64
+ if (cc < 97 || cc > 122)
65
+ continue; // not a letter
66
+ const value = parts[i + 1];
67
+ // A real G-code word is a letter followed by a NUMBER. This guard rejects letters embedded in
68
+ // extended-command words (Klipper `EXCLUDE_OBJECT … POLYGON=…`, `CENTER=`, `NAME=`) where a letter
69
+ // is followed by another letter or `=…` — the inherited lexer skipped these by only reading the
70
+ // first word; scanning every letter must not resurrect them as commands/params.
71
+ const num = value.trim() === '' ? NaN : Number(value);
72
+ if (key === 'g' || key === 'm') {
73
+ if (Number.isFinite(num)) {
74
+ codes.push(`${key}${num}`); // e.g. 'g1', 'm3' — leading zeros normalized
75
+ sawCommand = true;
76
+ }
77
+ }
78
+ else if (key === 't' && !sawCommand) {
79
+ // `T` is a tool-select command only as the LEADING command word (`T1 M6`). After an M-code it is
80
+ // that code's parameter, NOT a tool change — e.g. Marlin `M486 T<count>`, `M104 T<tool> S<temp>`.
81
+ if (Number.isFinite(num)) {
82
+ codes.push(`t${num}`);
83
+ sawCommand = true;
84
+ }
85
+ }
86
+ else if (key === 'n') {
87
+ // Line number — not a command, not a motion word. Ignored (fixes Fanuc/Mach/TinyG files).
88
+ }
89
+ else {
90
+ const p = parseFloat(value);
91
+ if (Number.isFinite(p))
92
+ params[key] = p;
61
93
  }
62
94
  }
63
- return { gcode, params };
95
+ return { codes, params };
64
96
  }
65
97
  /** Internal: build an engine over decoded text (streaming passes '' and feeds lines itself). */
66
98
  export function createEngine(input, opts) {
@@ -92,7 +124,18 @@ export function createEngine(input, opts) {
92
124
  warnings.set(code, { code, message, severity: 'warn', srcByte, count: 1 });
93
125
  }
94
126
  };
95
- const writer = new SegmentWriter(limits.maxBufferBytes);
127
+ // Opt-in modal channels (DD-012 D3): dedupe, keep supported, warn (once) on any unknown id.
128
+ const requestedModal = [];
129
+ for (const id of opts.modalChannels ?? []) {
130
+ if (!SUPPORTED_MODAL_CHANNELS.has(id)) {
131
+ warn('modal-channel-unsupported', `Ignoring unsupported modal channel '${id}'.`);
132
+ }
133
+ else if (!requestedModal.includes(id)) {
134
+ requestedModal.push(id);
135
+ }
136
+ }
137
+ const wantToolPower = requestedModal.includes('toolPower');
138
+ const writer = new SegmentWriter(limits.maxBufferBytes, requestedModal);
96
139
  // Machine state (port of State.initial + Interpreter fields).
97
140
  let sx = 0;
98
141
  let sy = 0;
@@ -126,6 +169,23 @@ export function createEngine(input, opts) {
126
169
  let g92z = 0;
127
170
  let coordSystemSeen = false; // any G53/G54–G59/G92-XYZ/G10 → coordinateSystem 'known'
128
171
  let g53OneShot = false; // next move ignores the work offset (machine coordinates)
172
+ // Tool-state modal (DD-012 D2/D4, #189). Spindle/laser on (M3/M4) engages the tool; M5 disengages.
173
+ // A FEED move (G1/G2/G3) with no extrusion E while the tool is engaged is a Cut (productive); a
174
+ // rapid (G0) stays Travel even with the tool engaged (refined #189). FDM slices never issue M3/M4,
175
+ // so toolEngaged stays false and their classification is byte-identical.
176
+ let toolEngaged = false;
177
+ let toolStateSeen = false; // any M3/M4/M5 observed → cutMoves capability 'known'
178
+ let modalS = NaN; // current modal spindle/laser S value (power / RPM); NaN until first seen (#189)
179
+ let modalMotion = null; // last G0–G3 mode for modal-motion lines (#189)
180
+ // Canned drilling cycle state (DD-012 phase 2, #189). Z/R/Q/initial-plane are captured when the
181
+ // cycle is defined and retained across modal (bare X/Y) repeats until G80 or a G0–G3 cancels it.
182
+ let cannedCycle = null;
183
+ let cannedZ = 0; // final drill depth (absolute)
184
+ let cannedR = 0; // retract / reference plane (absolute Z)
185
+ let cannedQ = 0; // peck increment (G83)
186
+ let cannedInitialZ = 0; // Z when the cycle activated — the G98 retract plane
187
+ let cannedRetractInitial = true; // G98 (retract to initial Z) vs G99 (retract to R); modal, default G98
188
+ let cannedCyclesSeen = false; // any G81/G82/G83 expanded → cannedCycles capability 'known'
129
189
  // Per-axis position certainty (DD-010 D4 amendment, #158). A G31 probe endpoint is reached at
130
190
  // RUNTIME (workpiece contact), not the commanded value, so it marks the probed axes uncertain. A
131
191
  // following G92 then RESYNCS the logical frame at the datum (rather than datum-shifting a stale
@@ -214,7 +274,10 @@ export function createEngine(input, opts) {
214
274
  kind,
215
275
  tool: path?.tool ?? tool,
216
276
  layer: UNRESOLVED_LAYER,
217
- srcByte: currentSrcByte
277
+ srcByte: currentSrcByte,
278
+ // toolPower is the modal S while a tool is engaged; NaN when off (M5 / never engaged) — an
279
+ // honest "no cutting power here", not a fabricated 0. Only built when requested (DD-012 D3).
280
+ modal: wantToolPower ? { toolPower: toolEngaged ? modalS : NaN } : undefined
218
281
  });
219
282
  }
220
283
  catch (err) {
@@ -308,7 +371,10 @@ export function createEngine(input, opts) {
308
371
  prevY = sy;
309
372
  prevZ = sz;
310
373
  };
311
- const g0 = (p) => {
374
+ // Map a resolved path type to its MoveKind bit (DD-012 D2). `cut` only ever arises on a feed move
375
+ // when the tool is engaged and E is absent, so FDM (never `cut`) is byte-identical.
376
+ const kindForPath = (t) => t === 'extrusion' ? MoveKind.Extrude : t === 'cut' ? MoveKind.Cut : MoveKind.Travel;
377
+ const g0 = (p, rapid) => {
312
378
  const { x, y, z, e, f } = p;
313
379
  // Classify on the true per-move delta, not the raw E word (DD-010 D1). For the M83/relative
314
380
  // corpus the delta equals the word, so output is byte-identical to the pre-E10 engine.
@@ -339,7 +405,10 @@ export function createEngine(input, opts) {
339
405
  stats.points++;
340
406
  if (f !== undefined)
341
407
  modalFeed = f;
342
- const pathType = eDelta > 0 ? 'extrusion' : 'travel';
408
+ // A rapid (G0) is a non-cutting positioning traverse — Travel even while the tool is engaged
409
+ // (a router keeps its spindle on during rapids; a GRBL-laser gates the beam off during G0). Only
410
+ // a FEED move (G1/G2/G3) with the tool engaged and no extrusion is a Cut (DD-012 D2, refined #189).
411
+ const pathType = eDelta > 0 ? 'extrusion' : toolEngaged && !rapid ? 'cut' : 'travel';
343
412
  if (path === null || path.type !== pathType) {
344
413
  breakPath(pathType);
345
414
  }
@@ -358,7 +427,7 @@ export function createEngine(input, opts) {
358
427
  certainZ = true;
359
428
  }
360
429
  g53OneShot = false; // one-shot machine-coordinate bypass consumed by this move
361
- return emitSegment(sx, sy, sz, eDelta, pathType === 'extrusion' ? MoveKind.Extrude : MoveKind.Travel);
430
+ return emitSegment(sx, sy, sz, eDelta, kindForPath(pathType));
362
431
  };
363
432
  const g2 = (p, cw) => {
364
433
  const { e, f } = p;
@@ -367,7 +436,7 @@ export function createEngine(input, opts) {
367
436
  modalFeed = f;
368
437
  // E is delta-based (DD-010 D1) so M82 arcs classify correctly and `lastE` stays consistent with g0.
369
438
  const eDelta = resolveEDelta(e);
370
- const pathType = eDelta ? 'extrusion' : 'travel';
439
+ const pathType = eDelta ? 'extrusion' : toolEngaged ? 'cut' : 'travel';
371
440
  if (path === null || path.type !== pathType) {
372
441
  breakPath(pathType);
373
442
  }
@@ -427,7 +496,7 @@ export function createEngine(input, opts) {
427
496
  // Through-axis linear ramp — preserves the inherited step (byte-identical for planar XY arcs,
428
497
  // where the through axis is unchanged so the ramp is flat).
429
498
  const cStep = (sc - tc) / totalSegments;
430
- const kind = (pathType === 'extrusion' ? MoveKind.Extrude : MoveKind.Travel) | MoveKind.ArcSegment;
499
+ const kind = kindForPath(pathType) | MoveKind.ArcSegment;
431
500
  const eachE = e !== undefined ? eDelta / Math.max(1, Math.ceil(totalSegments)) : 0;
432
501
  // Map an in-plane point (pa,pb) + through-axis pc back to (x,y,z) for the active plane.
433
502
  const emitArc = (pa, pb, pcv) => {
@@ -449,6 +518,46 @@ export function createEngine(input, opts) {
449
518
  sz = tz;
450
519
  return emitArc(ta, tb, tc);
451
520
  };
521
+ // Canned drilling cycle (DD-012 phase 2, #189): expand G81/G82/G83 into explicit sub-moves so the
522
+ // holes are real geometry (today they emit nothing). Rapids are Travel; the plunge(s) are Cut. Z/R/Q
523
+ // and the initial plane are captured when the cycle is defined and retained across modal repeats.
524
+ const runCannedCycle = (p) => {
525
+ // 1. Rapid to the hole XY at the current Z.
526
+ sx = nextAxis(sx, p.x, offX());
527
+ sy = nextAxis(sy, p.y, offY());
528
+ g53OneShot = false;
529
+ if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel))
530
+ return false;
531
+ // 2. Rapid Z down to the R (reference) plane.
532
+ sz = cannedR;
533
+ if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel))
534
+ return false;
535
+ // 3. Feed to depth — one plunge (G81/G82) or a peck loop (G83). Productive → Cut.
536
+ if (cannedCycle === 'g83' && cannedQ > 0 && cannedR > cannedZ) {
537
+ let depth = cannedR;
538
+ while (depth > cannedZ) {
539
+ depth = Math.max(cannedZ, depth - cannedQ);
540
+ sz = depth;
541
+ if (!emitSegment(sx, sy, sz, 0, MoveKind.Cut))
542
+ return false; // feed down one peck
543
+ if (depth > cannedZ) {
544
+ sz = cannedR;
545
+ if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel))
546
+ return false; // rapid retract to R between pecks
547
+ }
548
+ }
549
+ }
550
+ else {
551
+ sz = cannedZ;
552
+ if (!emitSegment(sx, sy, sz, 0, MoveKind.Cut))
553
+ return false; // single plunge
554
+ }
555
+ // 4. Retract (rapid) to the initial plane (G98) or the R plane (G99).
556
+ sz = cannedRetractInitial ? cannedInitialZ : cannedR;
557
+ if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel))
558
+ return false;
559
+ return true;
560
+ };
452
561
  const onComment = opts.onComment;
453
562
  const onCommand = opts.onCommand;
454
563
  const processLine = (rawLine, offset) => {
@@ -465,188 +574,266 @@ export function createEngine(input, opts) {
465
574
  onComment(rawLine.slice(ci + 1), offset);
466
575
  }
467
576
  const cmd = lexLine(rawLine);
468
- if (cmd.gcode === '')
469
- return;
470
- stats.commands++;
471
- if (onCommand !== undefined) {
472
- onCommand({ gcode: cmd.gcode, params: cmd.params, rawLine, srcByte: offset, segIndex: writer.count });
577
+ // Skip only genuinely empty lines. A line with no lexable words but real content (a dialect
578
+ // extended command like `EXCLUDE_OBJECT_START NAME=cube`, a macro) must NOT be dropped — the
579
+ // observer below still forwards its raw line. The body split runs only when there are no words.
580
+ if (cmd.codes.length === 0 && Object.keys(cmd.params).length === 0 && rawLine.split(';')[0].trim() === '') {
581
+ return; // blank or comment-only line
473
582
  }
583
+ stats.commands++;
584
+ // Modal S/F latch from params on ANY line — including bare `S1000` / `F600` lines that carry no
585
+ // command word (common in GRBL-laser output). S is captured for toolPower only when requested.
586
+ if (wantToolPower && cmd.params.s !== undefined)
587
+ modalS = cmd.params.s;
588
+ if (cmd.params.f !== undefined)
589
+ modalFeed = cmd.params.f;
474
590
  let ok = true;
475
- switch (cmd.gcode) {
476
- case 'g0':
477
- case 'g1':
478
- ok = g0(cmd.params);
479
- break;
480
- case 'g2':
481
- ok = g2(cmd.params, true);
482
- break;
483
- case 'g3':
484
- ok = g2(cmd.params, false);
485
- break;
486
- case 'g20':
487
- units = 'in';
488
- unitsSeen = true;
489
- break;
490
- case 'g21':
491
- units = 'mm';
492
- unitsSeen = true;
493
- break;
494
- case 'g28':
495
- sx = 0;
496
- sy = 0;
497
- sz = 0;
498
- break;
499
- case 't0':
500
- case 't1':
501
- case 't2':
502
- case 't3':
503
- case 't4':
504
- case 't5':
505
- case 't6':
506
- case 't7':
507
- tool = Number(cmd.gcode.slice(1));
508
- break;
509
- case 'm600':
510
- // Manual filament swap = color boundary (#147). A marker, not motion — do
511
- // NOT break/finish the current path; record position (current head), the
512
- // next segment index (slot boundary), and the active tool for provenance.
513
- colorChangeEvents.push({ x: sx, y: sy, z: sz, segIndex: writer.count, srcByte: offset, tool });
514
- break;
515
- // Motion-model modal commands (DD-010 E10 phase 1).
516
- case 'g90':
517
- xyzAbsolute = true;
518
- positioningSeen = true;
519
- break;
520
- case 'g91':
521
- xyzAbsolute = false;
522
- positioningSeen = true;
523
- break;
524
- // Arc-plane selection (DD-010 D3, #157, E10 phase 2).
525
- case 'g17':
526
- arcPlane = 'xy';
527
- arcPlaneSeen = true;
528
- break;
529
- case 'g18':
530
- arcPlane = 'xz';
531
- arcPlaneSeen = true;
532
- break;
533
- case 'g19':
534
- arcPlane = 'yz';
535
- arcPlaneSeen = true;
536
- break;
537
- case 'm82':
538
- eModeExplicit = 'absolute';
539
- break;
540
- case 'm83':
541
- eModeExplicit = 'relative';
542
- break;
543
- // Coordinate systems (DD-010 D4, #158, E10 phase 3).
544
- case 'g53':
545
- // One-shot: the following move is in machine coordinates (ignores the work offset).
546
- g53OneShot = true;
547
- coordSystemSeen = true;
548
- break;
549
- case 'g54':
550
- case 'g55':
551
- case 'g56':
552
- case 'g57':
553
- case 'g58':
554
- case 'g59':
555
- activeWcs = Number(cmd.gcode.slice(1)) - 54; // g54→0 … g59→5
556
- coordSystemSeen = true;
557
- break;
558
- case 'g10': {
559
- // Set a work-coordinate offset. L2 P<n>: set the offset directly. L20 P<n>: set it so the
560
- // current position reads the given value in WCS n. P1→G54 … P6→G59 (default: active system).
561
- const l = cmd.params.l;
562
- const idx = cmd.params.p !== undefined ? Math.round(cmd.params.p) - 1 : activeWcs;
563
- if (idx >= 0 && idx < 6 && (l === 2 || l === 20)) {
564
- const w = wcsOffsets[idx];
565
- const setAxis = (word, cur, axis) => {
566
- if (word === undefined)
567
- return;
568
- w[axis] = l === 2 ? word : cur - word; // L20: offset = current logical − desired reading
569
- };
570
- setAxis(cmd.params.x, sx, 'x');
571
- setAxis(cmd.params.y, sy, 'y');
572
- setAxis(cmd.params.z, sz, 'z');
573
- coordSystemSeen = true;
574
- }
575
- break;
591
+ if (cmd.codes.length === 0) {
592
+ // No G/M/T command word. This is either a dialect extended command (EXCLUDE_OBJECT_*, macros —
593
+ // matched on the raw line, DD-005) or a bare motion/param line. Forward the raw line to the
594
+ // observer so dialects still see it, then continue the active modal motion group if the line
595
+ // carries motion axes (the CNC/LinuxCNC modal form; FDM always emits the G word, so this never
596
+ // runs for FDM — byte-identical).
597
+ if (onCommand !== undefined) {
598
+ onCommand({ gcode: '', params: cmd.params, rawLine, srcByte: offset, segIndex: writer.count });
576
599
  }
577
- case 'g31': {
578
- // Probe move (DD-010 D4 amendment, #158): the endpoint is reached at RUNTIME (workpiece
579
- // contact), NOT the commanded value. Do not advance the position or draw a fabricated probe
580
- // move; mark the probed axes runtime-dependent so a following G92 resyncs the logical frame.
581
- let probed = false;
582
- if (cmd.params.x !== undefined) {
583
- certainX = false;
584
- probed = true;
585
- }
586
- if (cmd.params.y !== undefined) {
587
- certainY = false;
588
- probed = true;
589
- }
590
- if (cmd.params.z !== undefined) {
591
- certainZ = false;
592
- probed = true;
593
- }
594
- if (probed) {
595
- warn('probe-position-runtime-dependent', 'G31 probe endpoint is determined at runtime; the probed axis is runtime-dependent until re-established (G92 or an absolute move).', offset);
596
- }
597
- break;
600
+ if (cmd.params.x !== undefined || cmd.params.y !== undefined || cmd.params.z !== undefined) {
601
+ if (cannedCycle !== null)
602
+ ok = runCannedCycle(cmd.params);
603
+ else if (modalMotion === 'g2' || modalMotion === 'g3')
604
+ ok = g2(cmd.params, modalMotion === 'g2');
605
+ else if (modalMotion === 'g0' || modalMotion === 'g1')
606
+ ok = g0(cmd.params, modalMotion === 'g0');
598
607
  }
599
- case 'g92': {
600
- // Datum WITHOUT motion (DD-010 D4 + probe amendment, #158). `G92 E<v>` rebases the absolute-E
601
- // origin (also used in phase 1). For X/Y/Z, when the position is KNOWN it is a datum SHIFT (the
602
- // work offset is set so the current logical position reads <v>, preserving continuity). When the
603
- // axis is runtime-dependent (post-probe) it is a logical RESYNC: the current logical position is
604
- // declared to be <v>, the offset is reset, certainty is restored, and the current path is
605
- // finalized so the next move starts a NEW frame at the datum — no fabricated move is drawn
606
- // across the unknown probe result.
607
- if (cmd.params.e !== undefined)
608
- lastE = cmd.params.e;
609
- let resync = false;
610
- if (cmd.params.x !== undefined) {
611
- if (certainX)
612
- g92x = sx - cmd.params.x - wcsOffsets[activeWcs].x;
613
- else {
614
- sx = cmd.params.x + wcsOffsets[activeWcs].x;
615
- g92x = 0;
616
- certainX = true;
617
- resync = true;
618
- }
619
- coordSystemSeen = true;
608
+ }
609
+ else
610
+ for (const gcode of cmd.codes) {
611
+ if (onCommand !== undefined) {
612
+ onCommand({ gcode, params: cmd.params, rawLine, srcByte: offset, segIndex: writer.count });
620
613
  }
621
- if (cmd.params.y !== undefined) {
622
- if (certainY)
623
- g92y = sy - cmd.params.y - wcsOffsets[activeWcs].y;
624
- else {
625
- sy = cmd.params.y + wcsOffsets[activeWcs].y;
626
- g92y = 0;
627
- certainY = true;
628
- resync = true;
614
+ switch (gcode) {
615
+ case 'g0':
616
+ case 'g1':
617
+ modalMotion = gcode;
618
+ cannedCycle = null; // a G0–G3 motion command cancels the canned-cycle modal group
619
+ ok = g0(cmd.params, gcode === 'g0');
620
+ break;
621
+ case 'g2':
622
+ modalMotion = 'g2';
623
+ cannedCycle = null;
624
+ ok = g2(cmd.params, true);
625
+ break;
626
+ case 'g3':
627
+ modalMotion = 'g3';
628
+ cannedCycle = null;
629
+ ok = g2(cmd.params, false);
630
+ break;
631
+ // Canned drilling cycles (DD-012 phase 2, #189). A fresh G81/G82/G83 captures Z/R/Q + the
632
+ // initial plane and drills the first hole; bare X/Y lines (cannedRepeat) drill more, retaining
633
+ // that state; G80 cancels; G98/G99 set the retract plane.
634
+ case 'g81':
635
+ case 'g82':
636
+ case 'g83':
637
+ cannedCyclesSeen = true;
638
+ cannedCycle = gcode;
639
+ cannedInitialZ = sz;
640
+ if (cmd.params.z !== undefined)
641
+ cannedZ = nextAxis(sz, cmd.params.z, offZ());
642
+ if (cmd.params.r !== undefined)
643
+ cannedR = nextAxis(sz, cmd.params.r, offZ());
644
+ if (cmd.params.q !== undefined)
645
+ cannedQ = Math.abs(cmd.params.q);
646
+ modalMotion = null;
647
+ ok = runCannedCycle(cmd.params);
648
+ break;
649
+ case 'g80':
650
+ cannedCycle = null;
651
+ break;
652
+ case 'g98':
653
+ cannedRetractInitial = true;
654
+ break;
655
+ case 'g99':
656
+ cannedRetractInitial = false;
657
+ break;
658
+ case 'g20':
659
+ units = 'in';
660
+ unitsSeen = true;
661
+ break;
662
+ case 'g21':
663
+ units = 'mm';
664
+ unitsSeen = true;
665
+ break;
666
+ case 'g28':
667
+ sx = 0;
668
+ sy = 0;
669
+ sz = 0;
670
+ break;
671
+ case 't0':
672
+ case 't1':
673
+ case 't2':
674
+ case 't3':
675
+ case 't4':
676
+ case 't5':
677
+ case 't6':
678
+ case 't7':
679
+ tool = Number(gcode.slice(1));
680
+ break;
681
+ case 'm600':
682
+ // Manual filament swap = color boundary (#147). A marker, not motion — do
683
+ // NOT break/finish the current path; record position (current head), the
684
+ // next segment index (slot boundary), and the active tool for provenance.
685
+ colorChangeEvents.push({ x: sx, y: sy, z: sz, segIndex: writer.count, srcByte: offset, tool });
686
+ break;
687
+ // Motion-model modal commands (DD-010 E10 phase 1).
688
+ case 'g90':
689
+ xyzAbsolute = true;
690
+ positioningSeen = true;
691
+ break;
692
+ case 'g91':
693
+ xyzAbsolute = false;
694
+ positioningSeen = true;
695
+ break;
696
+ // Arc-plane selection (DD-010 D3, #157, E10 phase 2).
697
+ case 'g17':
698
+ arcPlane = 'xy';
699
+ arcPlaneSeen = true;
700
+ break;
701
+ case 'g18':
702
+ arcPlane = 'xz';
703
+ arcPlaneSeen = true;
704
+ break;
705
+ case 'g19':
706
+ arcPlane = 'yz';
707
+ arcPlaneSeen = true;
708
+ break;
709
+ case 'm82':
710
+ eModeExplicit = 'absolute';
711
+ break;
712
+ case 'm83':
713
+ eModeExplicit = 'relative';
714
+ break;
715
+ // Tool-state modal (DD-012 D2/D4, #189). M3 (CW) / M4 (CCW) engage the spindle/laser;
716
+ // M5 disengages. Drives Cut-vs-Travel classification for non-extrusion moves. The `S` word
717
+ // (power/RPM) is a modal channel handled in a later phase; here we track only engagement.
718
+ case 'm3':
719
+ case 'm4':
720
+ toolEngaged = true;
721
+ toolStateSeen = true;
722
+ break;
723
+ case 'm5':
724
+ toolEngaged = false;
725
+ toolStateSeen = true;
726
+ break;
727
+ // Coordinate systems (DD-010 D4, #158, E10 phase 3).
728
+ case 'g53':
729
+ // One-shot: the following move is in machine coordinates (ignores the work offset).
730
+ g53OneShot = true;
731
+ coordSystemSeen = true;
732
+ break;
733
+ case 'g54':
734
+ case 'g55':
735
+ case 'g56':
736
+ case 'g57':
737
+ case 'g58':
738
+ case 'g59':
739
+ activeWcs = Number(gcode.slice(1)) - 54; // g54→0 … g59→5
740
+ coordSystemSeen = true;
741
+ break;
742
+ case 'g10': {
743
+ // Set a work-coordinate offset. L2 P<n>: set the offset directly. L20 P<n>: set it so the
744
+ // current position reads the given value in WCS n. P1→G54 … P6→G59 (default: active system).
745
+ const l = cmd.params.l;
746
+ const idx = cmd.params.p !== undefined ? Math.round(cmd.params.p) - 1 : activeWcs;
747
+ if (idx >= 0 && idx < 6 && (l === 2 || l === 20)) {
748
+ const w = wcsOffsets[idx];
749
+ const setAxis = (word, cur, axis) => {
750
+ if (word === undefined)
751
+ return;
752
+ w[axis] = l === 2 ? word : cur - word; // L20: offset = current logical − desired reading
753
+ };
754
+ setAxis(cmd.params.x, sx, 'x');
755
+ setAxis(cmd.params.y, sy, 'y');
756
+ setAxis(cmd.params.z, sz, 'z');
757
+ coordSystemSeen = true;
758
+ }
759
+ break;
629
760
  }
630
- coordSystemSeen = true;
631
- }
632
- if (cmd.params.z !== undefined) {
633
- if (certainZ)
634
- g92z = sz - cmd.params.z - wcsOffsets[activeWcs].z;
635
- else {
636
- sz = cmd.params.z + wcsOffsets[activeWcs].z;
637
- g92z = 0;
638
- certainZ = true;
639
- resync = true;
761
+ case 'g31': {
762
+ // Probe move (DD-010 D4 amendment, #158): the endpoint is reached at RUNTIME (workpiece
763
+ // contact), NOT the commanded value. Do not advance the position or draw a fabricated probe
764
+ // move; mark the probed axes runtime-dependent so a following G92 resyncs the logical frame.
765
+ let probed = false;
766
+ if (cmd.params.x !== undefined) {
767
+ certainX = false;
768
+ probed = true;
769
+ }
770
+ if (cmd.params.y !== undefined) {
771
+ certainY = false;
772
+ probed = true;
773
+ }
774
+ if (cmd.params.z !== undefined) {
775
+ certainZ = false;
776
+ probed = true;
777
+ }
778
+ if (probed) {
779
+ warn('probe-position-runtime-dependent', 'G31 probe endpoint is determined at runtime; the probed axis is runtime-dependent until re-established (G92 or an absolute move).', offset);
780
+ }
781
+ break;
640
782
  }
641
- coordSystemSeen = true;
783
+ case 'g92': {
784
+ // Datum WITHOUT motion (DD-010 D4 + probe amendment, #158). `G92 E<v>` rebases the absolute-E
785
+ // origin (also used in phase 1). For X/Y/Z, when the position is KNOWN it is a datum SHIFT (the
786
+ // work offset is set so the current logical position reads <v>, preserving continuity). When the
787
+ // axis is runtime-dependent (post-probe) it is a logical RESYNC: the current logical position is
788
+ // declared to be <v>, the offset is reset, certainty is restored, and the current path is
789
+ // finalized so the next move starts a NEW frame at the datum — no fabricated move is drawn
790
+ // across the unknown probe result.
791
+ if (cmd.params.e !== undefined)
792
+ lastE = cmd.params.e;
793
+ let resync = false;
794
+ if (cmd.params.x !== undefined) {
795
+ if (certainX)
796
+ g92x = sx - cmd.params.x - wcsOffsets[activeWcs].x;
797
+ else {
798
+ sx = cmd.params.x + wcsOffsets[activeWcs].x;
799
+ g92x = 0;
800
+ certainX = true;
801
+ resync = true;
802
+ }
803
+ coordSystemSeen = true;
804
+ }
805
+ if (cmd.params.y !== undefined) {
806
+ if (certainY)
807
+ g92y = sy - cmd.params.y - wcsOffsets[activeWcs].y;
808
+ else {
809
+ sy = cmd.params.y + wcsOffsets[activeWcs].y;
810
+ g92y = 0;
811
+ certainY = true;
812
+ resync = true;
813
+ }
814
+ coordSystemSeen = true;
815
+ }
816
+ if (cmd.params.z !== undefined) {
817
+ if (certainZ)
818
+ g92z = sz - cmd.params.z - wcsOffsets[activeWcs].z;
819
+ else {
820
+ sz = cmd.params.z + wcsOffsets[activeWcs].z;
821
+ g92z = 0;
822
+ certainZ = true;
823
+ resync = true;
824
+ }
825
+ coordSystemSeen = true;
826
+ }
827
+ if (resync)
828
+ finishPath(); // start a new frame at the datum; no move connects across the probe
829
+ break;
830
+ }
831
+ default:
832
+ warn('unsupported-command', `unsupported command '${gcode}' preserved as metadata`, offset);
642
833
  }
643
- if (resync)
644
- finishPath(); // start a new frame at the datum; no move connects across the probe
645
- break;
834
+ if (!ok)
835
+ break;
646
836
  }
647
- default:
648
- warn('unsupported-command', `unsupported command '${cmd.gcode}' preserved as metadata`, offset);
649
- }
650
837
  if (!ok) {
651
838
  truncatedAtByte = offset;
652
839
  }
@@ -692,6 +879,13 @@ export function createEngine(input, opts) {
692
879
  // per-move G-code signal and stays 'unavailable' (a future geometry-heuristic DD may change it).
693
880
  wipeMoves: 'unavailable',
694
881
  seamMoves: 'unavailable',
882
+ // Non-extrusion productive moves (DD-012 D2, #189). 'known' once a tool-state modal (M3/M4/M5)
883
+ // was seen — i.e. this is a CNC/laser/plotter file whose Cut moves are meaningful; 'unavailable'
884
+ // for FDM (no tool-state), where every move is Extrude/Travel and Cut is never set.
885
+ cutMoves: toolStateSeen ? 'known' : 'unavailable',
886
+ // Canned drilling cycles expanded to geometry (DD-012 phase 2, #189). 'known' once a G81/G82/G83
887
+ // was seen (holes are real segments), else 'unavailable' (no cycles / FDM).
888
+ cannedCycles: cannedCyclesSeen ? 'known' : 'unavailable',
695
889
  // Motion-model modes (DD-010 E10 phase 1). 'known' when the governing command was seen
696
890
  // (M82/M83, or a firmware-known G90/G91 for E); 'inferred' when defaulted (absolute).
697
891
  extrusionMode: eModeExplicit !== null || (extruderFollowsPositioning && positioningSeen) ? 'known' : 'inferred',
@@ -702,6 +896,10 @@ export function createEngine(input, opts) {
702
896
  // 'inferred' (identity WCS / no offset assumed — the FDM-slicer default).
703
897
  coordinateSystem: coordSystemSeen ? 'known' : 'inferred'
704
898
  };
899
+ // Opt-in modal channels (DD-012 D3): only surfaced when requested. 'known' once its controlling
900
+ // signal was seen (toolPower ← a tool-state modal), else 'unavailable' — never a fabricated value.
901
+ if (wantToolPower)
902
+ capabilities.toolPower = toolStateSeen ? 'known' : 'unavailable';
705
903
  if (layersCapability === 'unavailable') {
706
904
  warn('layers-unavailable', 'No planar layer index; all segments assigned to layer 0.');
707
905
  }
@@ -804,6 +1002,8 @@ export function createEngine(input, opts) {
804
1002
  colorChanges: 'unavailable', // color-change boundaries resolve on the final IR
805
1003
  wipeMoves: 'unavailable', // annotation move kinds resolve on the final IR (DD-016)
806
1004
  seamMoves: 'unavailable',
1005
+ cutMoves: 'unavailable', // non-extrusion classification resolves on the final IR (DD-012)
1006
+ cannedCycles: 'unavailable', // canned-cycle expansion resolves on the final IR (DD-012)
807
1007
  extrusionMode: 'inferred', // motion modes resolve fully on the final IR (E10)
808
1008
  positioningMode: 'inferred',
809
1009
  arcPlanes: 'inferred',
package/dist/worker.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * `worker-slim.ts`; custom adapters use a consumer-supplied worker entry
11
11
  * calling `createWorkerHandler(post, { dialects: createDialectRunner([...]) })`.
12
12
  */
13
- import { createDialectRunner, cura, klipper, marlin, orcaBambu, prusaSlicer, repRap } from '@chestnutlabs/gcode-dialects';
13
+ import { createDialectRunner, cura, grblLaser, grblMill, klipper, linuxCnc, marlin, orcaBambu, prusaSlicer, repRap } from '@chestnutlabs/gcode-dialects';
14
14
  import { openGcode3mf, sniffGcode3mf } from '@chestnutlabs/gcode-containers';
15
15
  import { openBgcodeContainer, sniffBgcode } from '@chestnutlabs/gcode-bgcode';
16
16
  import { createWorkerHandler } from './worker-core.js';
@@ -21,7 +21,11 @@ const BUILTIN_ADAPTERS = [
21
21
  cura(),
22
22
  klipper(),
23
23
  marlin(),
24
- repRap()
24
+ repRap(),
25
+ // Non-extrusion controllers (DD-012 phase 3, #189) — all EXPERIMENTAL tier until hardware-validated.
26
+ grblLaser(),
27
+ grblMill(),
28
+ linuxCnc()
25
29
  ];
26
30
  /** Built-in container adapters (DD-005 §4.4): .gcode.3mf and Prusa binary G-code (.bgcode, #188). */
27
31
  const BUILTIN_CONTAINERS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chestnutlabs/gcode-parser",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Worker-safe G-code parse core producing ToolpathIR (DD-003).",
5
5
  "keywords": [
6
6
  "gcode",
@@ -49,9 +49,9 @@
49
49
  "test": "vitest run"
50
50
  },
51
51
  "dependencies": {
52
- "@chestnutlabs/toolpath-core": "0.3.0",
53
- "@chestnutlabs/gcode-dialects": "0.3.0",
54
- "@chestnutlabs/gcode-containers": "0.3.0",
55
- "@chestnutlabs/gcode-bgcode": "0.3.0"
52
+ "@chestnutlabs/toolpath-core": "0.4.0",
53
+ "@chestnutlabs/gcode-dialects": "0.4.0",
54
+ "@chestnutlabs/gcode-containers": "0.4.0",
55
+ "@chestnutlabs/gcode-bgcode": "0.4.0"
56
56
  }
57
57
  }