diff --git a/README.md b/README.md index 0fa7df3f..6751ce70 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,13 @@ over one shared, framework-neutral engine. plates, WebGL context-loss recovery. - **A low-resource Canvas 2D renderer** — an optional `renderer: '2d'` layer view for constrained environments, with adjacent "ghost" layers and its own progress mapping (no WebGL required). -- **Rich toolpath coloring** — by feature type, move speed, object, or per-layer height, plus - toggleable wipe/seam moves; time-based scrub with a print-time estimate; and a source-line ↔ - segment debugger mapping. +- **Rich toolpath coloring** — by feature type, move speed, object, per-layer height, or (for + non-extrusion) tool power and cut-vs-rapid, plus toggleable wipe/seam moves; time-based scrub with a + print-time estimate; and a source-line ↔ segment debugger mapping. +- **Non-extrusion toolpaths — CNC / laser / plotter** *(experimental)* — cut/burn/draw moves classified + as `Cut`, a modal `toolPower` (laser power / spindle RPM) channel, canned drilling-cycle expansion + (`G81`/`G82`/`G83`), and controller recognition (GRBL laser/mill, LinuxCNC) — all honesty-**tiered**: + reported `inferred` until confirmed on real hardware, never fabricated. - **Honest live progress** (for printer telemetry) — a normalized `ProgressObservation` contract mapped onto the toolpath with tiered confidence: a precise cut + marker when the source position is known, an uncertainty band when it is approximated, stale-signal handling, and user scrub diff --git a/docs/README.md b/docs/README.md index 25c14d74..ccd938d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ See also [`../PROJECT_SETUP.md`](../PROJECT_SETUP.md) for the local environment Provenance of inherited vs. Chestnut code, the exact founding baseline, and the branch mapping are tracked in [`UPSTREAM_PROVENANCE.md`](UPSTREAM_PROVENANCE.md). -## Current state (updated 2026-07-27 — v0.3.0 in preparation; E0–E11 complete; renderer/viz backlog (#177–#185) shipped; binary G-code (`.bgcode`, #188/DD-011) code-complete) +## Current state (updated 2026-07-29 — v0.3.0 shipped to npm; v0.4.0 in prep; #188 bgcode closed; #189 CNC/laser phases 1–5 built + real-file lexer/detection; #152 STL export done) | Epic | Status | Gate | |---|---|---| @@ -56,7 +56,8 @@ tracked in [`UPSTREAM_PROVENANCE.md`](UPSTREAM_PROVENANCE.md). | Renderer color/viz backlog (#177–#185) | **Closed** (2026-07-27) | Additive capability-gated modes over already-parsed IR channels (DD-009 pattern, no new DD). **All shipped:** color-by-speed (#177), color-by-object (#178), color-by-layer-height (#179); seam/wipe move visibility (#182 → [DD-016](design/DD-016-annotation-derived-move-kinds.md)); filled build-plate surface (#185); filament/time metadata (#183) with time-based scrub + print-time estimate (#181); source-line ↔ segment debugger surface (#184). Advanced modal-channel color research (#180) recorded as [RR-002](research/RR-002-modal-state-color-channels.md) | | **E11** Documentation, SDK Reference & Published Manual (#197) | **Closed/Accepted** (2026-07-25) | [DD-013](design/DD-013-documentation-sdk-reference-and-published-manual.md) **Accepted** (D1–D7 as recommended). Completed E7/DD-008's docs ambit. **All four phases shipped:** typedoc API reference over the ten packages (#198), GitHub Pages + Chestnut Labs theme (#199), SDK manual — getting-started/guides/concepts (#200), accuracy gate + `eslint-plugin-tsdoc` + version stamp (#201). **Live: [chestnutlabs.github.io/gcode-preview](https://chestnutlabs.github.io/gcode-preview/)** (manual at `/`, API at `/api/`), auto-redeployed on merge/release. No package/IR/renderer change. Deferred: flip the `notDocumented` doc-coverage check to error once the 55 TSDoc-syntax gaps are filled | | **E8** Low-Resource Layer Mode (#9) | **Closed/Accepted** (2026-07-26) | [DD-014](design/DD-014-low-resource-layer-renderer.md) **Accepted** (D1–D5 as recommended; D6 build-now on the standing AnyBridge evidence). **All four §14 phases shipped** — #212 two new lockstep pkgs (`gcode-colors` shared ColorMode subsystem + `gcode-renderer-2d` Canvas 2D `LayerView2D`, no `three`/framework; renderer-three refactored onto the shared colorer) → **12 packages**; #213 adjacent "ghost" layers over a stable model frame; #214 **`renderer: '2d' \| '3d'` prop** through core + all 4 adapters (the 3D renderer is **loaded on demand** so a 2D-only bundle never ships Three.js) + 2D live-progress "cut"; #215 capability-honesty disclosures (non-planar/CNC via `renderer-unsupported`) + **§8 low-resource budget verified on a real device** (redraw ~0.2 ms median, ≤ 3.6 ms @ 6× CPU throttle vs the 16 ms budget; ~0 MB heap growth over 870 renders — no per-layer geometry) ([report](../tools/benchmark/results/e8-2d-lowresource-benchmark-2026-07-26.md)). §15 acceptance criteria met. Roadmap note (DD-008 §4.8): pure-Node GPU-less still rendering is a related deferred capability | -| **Binary G-code (`.bgcode`) decode** (#188) | **Code-complete** — phase 6 (security sign-off) pending | [DD-011](design/DD-011-binary-gcode-decode-adapter.md) **Accepted** (D1–D7; D4 amended — metadata/thumbnails in-scope), preceded by [RR-003](research/RR-003-bgcode-licensing-and-format-audit.md) licensing/format audit. **Phases 0–5 merged** (#231–#239) — new 13th lockstep pkg `@chestnutlabs/gcode-bgcode` decodes Prusa `.bgcode` → plain G-code through the pipeline; all four codecs (None / DEFLATE-zlib / heatshrink w11+w12 / MeatPack) validated against real Prusa files (21 MB XL — 856/856 block-CRC pass; cube golden-equivalence byte-identical); MeatPack (MIT) + heatshrink (ISC) clean-room ports, no AGPL; adversarial fuzz + §7 review + decode benchmark done. **Remaining: maintainer signs off [SECURITY-REVIEW-DD-011-bgcode.md](design/SECURITY-REVIEW-DD-011-bgcode.md) → close #188.** Targets `v0.3.0` | +| **Binary G-code (`.bgcode`) decode** (#188) | **Closed/Accepted** (2026-07-28) | [DD-011](design/DD-011-binary-gcode-decode-adapter.md) **Accepted**, preceded by [RR-003](research/RR-003-bgcode-licensing-and-format-audit.md). New 13th lockstep pkg `@chestnutlabs/gcode-bgcode` decodes Prusa `.bgcode` → plain G-code (None / DEFLATE-zlib / heatshrink w11+w12 / MeatPack; MeatPack MIT + heatshrink ISC clean-room, no AGPL); validated against real Prusa files (21 MB XL 856/856 CRC; cube golden byte-identical); [§7 security review](design/SECURITY-REVIEW-DD-011-bgcode.md) **signed off**. **Shipped in `v0.3.0`.** | +| **Non-extrusion toolpath — CNC / laser / plotter** (#189) | **Phases 1–5 built + merged; documented; experimental tier** | [DD-012](design/DD-012-non-extrusion-toolpath-and-modal-tool-state.md) **Accepted** (D1–D8), preceded by [RR-004](research/RR-004-non-extrusion-toolpath-coverage.md). **Phases 1–5 (#248–#255):** `MoveKind.Cut` + opt-in `ModalChannel`/`toolPower`; modal-motion + canned cycles (`G81`/`G82`/`G83`); GRBL-laser/GRBL-mill/LinuxCNC dialects + **validation tiers** (experimental → claims `inferred`); color-by-power + cut-vs-rapid; [compatibility matrix](compatibility/dialects-and-containers.md#dialects-non-extrusion--cnc--laser--plotter-dd-012-189) + support docs. **Real-file work:** lexer handles multi-command lines / `N`-numbers / bare-`S` (#256 — real LinuxCNC/TinyG now parse); evidence-based detection (#258, 0/6→3/6 real samples); [STL export](../tools/demo) (#152, #257). All FDM-byte-identical. **Remaining:** phase 6 — hardware validation → promote tiers to `validated`; larger gaps (LinuxCNC O-word subroutines/params/expressions). Rides the next lockstep release (`v0.4.0`). | | #152 STL export | Open, gated | Follows the **#118** chrome/showcase product decision, not an architecture DD (DD-009 §3) | Shipped so far: `packages/toolpath-core` (SoA `ToolpathIR`, capability model), `packages/gcode-parser` diff --git a/docs/compatibility/dialects-and-containers.md b/docs/compatibility/dialects-and-containers.md index 115f9010..5fd1a00c 100644 --- a/docs/compatibility/dialects-and-containers.md +++ b/docs/compatibility/dialects-and-containers.md @@ -28,6 +28,28 @@ annotation) · **unsupported** (generic parse only — geometry always works; me | Marlin | **full** (`;FLAVOR:Marlin`; `gcode_flavor` tail) | **full** (`M486 S`/`S-1` → `objects: known`) | composes (proven: Cura+Marlin) | `dialect-cura-style` | 2026-07-23 | | RepRap-style | **full** (`;FLAVOR:RepRap`; `gcode_flavor` tail) | — | detection-only | `dialect-reprap-style` | 2026-07-23 | +## Dialects (non-extrusion — CNC / laser / plotter, DD-012 #189) + +Non-extrusion controllers. Each declares a **validation tier** (DD-012 D6): until a controller is +confirmed on real hardware, its non-extrusion claims are reported **`inferred`** (experimental), never +`known` — the tier *is* the honesty mechanism. Geometry (positions, arcs, drilled holes) always parses +regardless of tier; the tier governs only how much to trust the *semantic* classification. The +underlying capabilities are in [Cross-cutting coverage](#cross-cutting-coverage) below. + +| Controller | Detection | Machine class | Validation tier | Non-extrusion claims | Fixtures | Evidence date | +|---|---|---|---|---|---|---| +| GRBL laser | LightBurn header / `$32=1` laser mode / `M4`+`S`, no extrusion | laser | **experimental** | `cutMoves` · `toolPower` (laser power) · `cannedCycles` — reported **`inferred`** until hardware-validated | synthetic | 2026-07-28 | +| GRBL mill | `Grbl` banner + `M3` spindle, no extrusion | mill | **experimental** | `cutMoves` · `toolPower` (spindle RPM) · `cannedCycles` — **`inferred`** | synthetic | 2026-07-28 | +| LinuxCNC / EMC | `LinuxCNC`/`EMC` header / `%`-program + `M3` | mill | **experimental** | as above — **`inferred`** | synthetic | 2026-07-28 | +| Marlin-laser / Mach / Smoothieware | _reserved_ | laser/mill | _pending_ | generic parse only until added | — | — | +| Pen plotters (servo `M280` / Z-lift) | _reserved_ | plotter | _pending_ | generic parse only until added | — | — | + +Each dialect adds provenance to `metadata.raw` (`cnc.controller`, `cnc.machineClass`, +`cnc.toolPowerLabel`, `cnc.validationTier`) and emits a `cnc-dialect-experimental` disclosure warning. +A one-line `tier: 'validated'` flip per controller promotes its claims to `known` once a real-hardware +run confirms them (DD-012 §8/§15). **DSP lasers (`.rd`) and galvo (`.ezd`) are out of scope** — those +are proprietary binary formats, not G-code. + ## Containers | Container | Discovery | Plates | Machine metadata | Integrity checks | Security review | Fixtures | Evidence date | @@ -42,6 +64,10 @@ annotation) · **unsupported** (generic parse only — geometry always works; me | Multi-tool / AMS / IDEX | core `tool` channel (T commands) + adapter `filament_type/colour` → `ir.tools` material/color | **full** (`dialect-multitool-ams`, 2026-07-23) | | Arc moves (G2/G3) | core parser (golden-gated) | **full** since E2 | | Per-file build plate in the viewer | `metadata.machine` → `setBuildVolume` (DD-005 §4.2) | mechanism shipped (phase 1); data arrives phases 2–3 | +| Non-extrusion move classification (`Cut`) | core parser: a no-`E` move while a tool is engaged (`M3`/`M4`, `M5` off) → `MoveKind.Cut` (DD-012 D2, #189) | **full** mechanism (`cutMoves: 'known'`); a per-dialect experimental tier reports it `inferred` | +| Tool-power channel (`toolPower`) | opt-in `ModalChannel` — the modal `S` while engaged, requested via `ParseOptions.modalChannels` (DD-012 D3) | **full** mechanism; `NaN` when the tool is off, never a fabricated 0; consumed by the `power` color mode | +| Canned drilling cycles | parser expands `G81`/`G82`/`G83` (+ `G80`, `G98`/`G99`, and modal bare-`X`/`Y` repeat) to real geometry (DD-012 D5) | **full** for `G81`/`G82`/`G83` incl. `G83` peck; `G84`–`G89` `unavailable` (disclosed) | +| Modal motion continuation | a coordinate-only line repeats the last `G0`–`G3` motion mode — the CNC/LinuxCNC form (DD-012 phase 2) | **full** since #189; FDM unaffected (slicers always emit the `G` word) | ## Live progress tiers (DD-006, E5) diff --git a/docs/design/DD-012-hardware-validation-log.md b/docs/design/DD-012-hardware-validation-log.md new file mode 100644 index 00000000..8f90d61b --- /dev/null +++ b/docs/design/DD-012-hardware-validation-log.md @@ -0,0 +1,62 @@ +# DD-012 — Hardware validation log (#189) + +**Purpose.** DD-012 D6/D8 make every non-extrusion dialect ship **`experimental`** (claims reported +`inferred`) until its classification is confirmed on a real machine, at which point it is promoted to +**`validated`** (claims report `known`, the `cnc-dialect-experimental` warning drops). This file is the +**acceptance evidence** behind each promotion: one entry per hardware run, recording what was observed +against what the physical machine did. A tier flip in +[`packages/gcode-dialects/src/cnc.ts`](../../packages/gcode-dialects/src/cnc.ts) must cite an entry here. + +**Provenance.** We record the run's *observations* (move counts, power range, verdicts) and the file +**name only** — never the user's design file or its geometry (RR-004 §6 governance). The files are the +maintainer's own jobs on the maintainer's own hardware. + +**What a pass validates.** The `experimental` tier downgrades exactly three capabilities — `cutMoves`, +`toolPower`, `cannedCycles`. A run validates a controller by confirming, against the physical result, +the claims the file actually exercises: machine-class detection, the Cut-vs-rapid split, the `toolPower` +(`S`) channel, and (where present) canned-cycle geometry. The check is performed with the in-repo +**validation harness** (`tools/demo/validate.html`), which lists each claim for ✓/✗ marking and exports +the report pasted below. + +--- + +## Runs + +### grbl-laser — VALIDATED — 2026-07-29 + +- **Controller / dialect:** GRBL / LightBurn → `grbl-laser` (machine class **laser**) +- **Machine:** maintainer's diode laser +- **File:** `laser-diode-test1.gc` (name only; not committed) +- **Tool:** [`tools/demo/validate.html`](../../tools/demo/validate.html) harness export + +| Observed | Value | +| --- | --- | +| Moves | 5822 cut · 339 rapid · 6161 total | +| Tool power (`S`) | 0–1000 over the 5822 cutting moves (full ramp; exercises the `toolPower` channel end to end) | +| Envelope | 356.0 × 566.0 × 0.0 mm (planar) | +| Geometry | fill + offset-fill patterns | + +**Findings (maintainer, against the physical cut):** + +| Claim | Verdict | Note | +| --- | --- | --- | +| Machine class correct | ✓ matches | detected `laser` | +| Cut-vs-rapid split correct | ✓ matches | holds across fill + offset-fill | +| Tool-power ramp correct | ✓ matches | 0–1000 `S` tracked real laser behavior | +| Work envelope matches part | ✓ matches | off-bed by design (placed off-bed in the source software) | +| Overall path shape matches | ✓ matches | — | +| Canned/drilling cycles | n/a | a laser has no `G81`–`G83` | + +**Decision:** all exercised claims ✓ against real hardware → **`grbl-laser` promoted +`experimental` → `validated`** ([`cnc.ts`](../../packages/gcode-dialects/src/cnc.ts)). Scope is +per-controller: this run validates GRBL/LightBurn **laser** classification only. `grbl-mill` and +`linuxcnc` remain `experimental` pending a run on the partner CNC. + +--- + +## Pending + +| Dialect | Tier | Needs | +| --- | --- | --- | +| `grbl-mill` | experimental | a real GRBL-mill / router run (spindle `M3`, Z-plunge, ideally a `G81` drill) | +| `linuxcnc` | experimental | a real LinuxCNC run (canned-cycle geometry + `toolPower` against the machine) | diff --git a/docs/design/DD-012-non-extrusion-toolpath-and-modal-tool-state.md b/docs/design/DD-012-non-extrusion-toolpath-and-modal-tool-state.md new file mode 100644 index 00000000..4551e28b --- /dev/null +++ b/docs/design/DD-012-non-extrusion-toolpath-and-modal-tool-state.md @@ -0,0 +1,306 @@ +# DD-012 — Non-extrusion toolpath (CNC / laser / plotter) & modal tool-state channels + +**Status:** **Accepted** +**Authors/Owners:** Nathaniel Chestnut +**Date:** 2026-07-28 · **Last revised:** 2026-07-28 +**Accepted:** 2026-07-28 — **D1–D8 as recommended.** `MoveKind.Cut` naming (D2); the **single shared `ModalChannel` mechanism** (D3) is owned here and #180/RR-002 consume it, so **DD-015 is retired**. Implementation unblocked per §14. +**Owning Epic:** #189 (non-extrusion toolpath coverage) · **Milestone:** Future +**Supersedes / Superseded by:** none +**Related:** [RR-004](../research/RR-004-non-extrusion-toolpath-coverage.md) (scope decision + audit — the gate this DD answers), [RR-002](../research/RR-002-modal-state-color-channels.md) + #180 (advanced modal color modes — **consumers** of the channel mechanism defined here; candidate DD-015 collapses into this), DD-001 (capability model + `MoveKind`), DD-010 (motion model this extends), DD-005 (dialect/adapter contracts), DD-016 (additive `MoveKind` bits precedent — `Wipe`/`Seam`), DD-014/E8 (2D rendering overlap), DD-003 (parser resource limits). Reserved number: DD-012 (#189). + +--- + +> **Scope decision (RR-004, maintainer 2026-07-28): non-extrusion toolpath is IN SCOPE.** Defensible +> because it is **validated against real hardware** (maintainer laser + partner CNC tables), which +> retires the epic's headline risk. This DD is the *additive* design that makes CNC/laser/plotter +> honestly supported **without changing FDM behavior**. + +--- + +## 1. Problem + +Non-extrusion G-code parses *geometry* correctly but the FDM-shaped IR **mis-classifies the work** +(RR-004 §5.1): with no extrusion `E` to key on, cut/burn/draw moves collapse to `Travel`, tool state +(spindle RPM, laser power, pen up/down) has nowhere to live, and canned drilling cycles (`G81`–`G89`) +produce zero geometry. The foundation is sound — arcs faceted, bounds correct, capability model honest +(`unavailable`, never fabricated). The gap is **semantic**, and it is the same modal-register shape +that #180's advanced color modes need (RR-002 §5.3). This DD closes the semantic gap additively and +defines the one shared mechanism. + +## 2. Scope + +1. **Additive move classification** — a `MoveKind.Cut` bit for tool-engaged productive moves when `E` + is absent. +2. **A `ModalChannel` mechanism** — opt-in per-segment modal registers, owned here, serving CNC/laser + tool-state **and** #180's FDM color channels. +3. **Tool-state channels** — laser power, spindle RPM, pen state. +4. **Canned-cycle expansion** — `G81`–`G89` (+ `G98`/`G99`) drilling/boring → real geometry. +5. **Dialect families** — GRBL / LinuxCNC / Mach (mill), GRBL / Marlin-laser / GRBL-LPC (laser), + servo/Z-lift (plotter), each carrying a **validation tier**. +6. **Rendering & coloring** — productive-vs-rapid rendering; color-by power / spindle / feed. +7. **Validation-tiered honesty** + a **synthetic, redistributable** fixture corpus backed by + real-machine acceptance evidence. + +## 3. Non-goals + +- **Non-G-code formats:** `.rd` (Ruida/Trocen/TopWisdom DSP), `.ezd` (EZCad galvo) — proprietary binary, + a different motion model, **not a gap in this one** (RR-004 §6). +- **G-code editing / CAM** — out (a slicer/editor concern). +- **Machining semantics beyond motion** — tool tables, offsets libraries, feeds-and-speeds advice, + material models. We classify and visualize motion; we do not simulate machining. +- **Changing any FDM behavior.** FDM IR output must remain byte-identical (regression gate, §9). + +## 4. Decisions + +Decision points **D1–D8**, each with options and a recommendation for maintainer acceptance. + +### 4.1 D1 — Placement: no new package (which existing packages change) + +Unlike `.bgcode` (a new package), this is a set of **additive changes to existing packages**: + +- **`toolpath-core`** — `MoveKind.Cut` bit; the `ModalChannel` type + optional SoA side-columns; capability keys. +- **`gcode-parser`** — modal registers + the fallback classifier + canned-cycle expansion (extends the DD-010 interpreter). +- **`gcode-dialects`** — GRBL/LinuxCNC/Mach/laser/plotter detection + tier declarations. +- **`gcode-colors`** — power/spindle/feed colorers (consumers). +- Renderers/adapters — capability-gated toggles only. + +- **Option A (recommended): extend existing packages.** The move model is shared IR; a parallel package would fork the renderer/capability/adapter surface. +- **Option B: a `gcode-cnc` package.** Rejected — the semantics live in the *core* IR + parser + dialects, not a bolt-on. + +### 4.2 D2 — Move classification: the `MoveKind.Cut` bit + +`MoveKind` is a bitflag with the next flag free (`1 << 7`, RR-004 §5.2). Add: + +```ts +Cut: 1 << 7 // tool-engaged productive move (cut / burn / draw) when no extrusion E is present +``` + +**Classifier (parser):** a move is `Cut` when it has **no `E` delta** *and* a **tool-engaged modal +state** holds (spindle on `M3`/`M4` with `S` > 0; laser on; pen down). Rapids (`G0`, or any move with +the tool disengaged / `M5` / pen up) stay `Travel`. Composes with `ArcSegment` exactly as `Wipe`/`Seam` +do (DD-016). **FDM is untouched:** `Cut` is only ever evaluated when `E` is absent — an FDM slice never +sets it, so its IR is byte-identical. + +- **Option A (recommended): one `Cut` bit.** The *productive* move; the domain (laser/mill/pen) is known from the tool-state channel + dialect, so the bit needn't encode it. +- **Option B: per-domain bits** (`Burn`/`Mill`/`Draw`) — rejected: over-specific, burns three flags for one concept. +- **Naming sub-choice:** `Cut` (parallels `Extrude`, documented as "cut/burn/draw") vs `ToolEngaged` (domain-neutral). **Recommend `Cut`** for user familiarity; capture the neutral meaning in docs. + +### 4.3 D3 — The `ModalChannel` mechanism (owned here; shared with #180) — the pivotal decision + +Per RR-002 §8 and RR-004 §5.3, CNC/laser tool state and FDM's fan/temp/accel are the **same pattern**: +a modal register set by a standalone command, stamped per segment, colored by a ramp, capability-gated +when absent. Define **one** abstraction: + +```ts +interface ModalChannel { + id: string; // 'laserPower' | 'spindle' | 'penState' | 'fan' | 'temp' | 'accel' | … + sources: string[]; // opcodes that set it, e.g. ['M3','M4','M5'] (+ 'S' word), ['M106','M107'] + value: 'u8'|'u16'|'f32'|'enum'; + capabilityKey: string; // e.g. 'laserPower' +} +``` + +- **Opt-in** via parse options (`modalChannels: ['laserPower','spindle']`). The parser maintains the + modal register only for requested channels and stamps a SoA side-column of length `segments.count`. + **Default FDM parse requests none and pays nothing** — the decisive argument from RR-002 §5.3 + (~15 MB / 1 M segments if always-on). +- **Capability per channel:** `known` when the controlling opcode appears in the file; `unavailable` + otherwise — never a fabricated `0` (DD-001 rule). +- **Ownership:** **DD-012 owns the mechanism.** #189's channels (`laserPower`, `spindle`, `penState`) + and #180's channels (`fan`, `temp`, `accel`, `jerk`, `pressureAdvance`) are *instances* of it. + +- **Option A (recommended): one shared `ModalChannel` subsystem, owned by DD-012, #180 consumes.** Build it once; the IR stays coherent. +- **Option B: separate mechanisms for tool-state (#189) and color (#180)** — rejected: identical machinery, guaranteed to drift. +- **Storage sub-choice:** dense typed side-column per requested channel (simple; opt-in *is* the memory gate) vs sparse run-length (smaller for step-function channels, more complex). **Recommend dense-when-requested** for v1; revisit sparse if a channel proves pathological. + +### 4.4 D4 — Tool-state sources & machine-class detection + +- **Laser / spindle:** `M3`/`M4` (on, CW/CCW) + `S` (power/RPM), `M5` (off). Same opcodes for both; + the *interpretation* (Watts-ish power vs RPM) is a **presentation label** from the dialect, not a + different channel. +- **Pen:** `M280` (servo angle) or a `Z`-lift threshold — a two-state `penState` channel. +- **Machine class** (`fdm` | `laser` | `mill` | `plotter`): a capability, **`inferred`** at best + (LightBurn/GRBL header comments, `$32` laser-mode, absence of `E` with present `S`). **Do not gate + behavior on machine class** — drive off the tool-state channel + `Cut` bit so a mis-detected class + never produces wrong geometry. + +- **Option A (recommended): tool-state-driven, machine-class as an `inferred` hint.** +- **Option B: hard machine-class switch** — rejected: brittle; a misread header would mis-render. + +### 4.5 D5 — Canned cycles (`G81`–`G89`) + +Expand drilling/boring cycles to real motion: rapid to `X`/`Y`, feed to `Z` depth, retract to the +`G98`/`G99` plane, honoring modal repetition. Currently they yield **zero geometry** (RR-004 §5.1). + +- **Option A (recommended MVP): the common set** — `G81` (drill), `G82` (dwell), `G83` (peck), + `G80` (cancel), with `G98`/`G99` retract planes; per-dialect parameter conventions. Others + (`G84`/`G85`/`G86`/`G89`) disclosed `unavailable` (`canned-cycle-unhandled`) until validated. +- **Option B: full `G81`–`G89` now** — deferred; larger, and tapping/boring cycles vary more by controller. + +### 4.6 D6 — Dialect families & **validation tiers** + +Extend `gcode-dialects` detection; each dialect **declares its validation tier** (RR-004 §8): + +| Tier | Meaning | Confidence | +|---|---|---| +| **Validated** | Verified on real hardware (maintainer laser / partner CNC) | `known` | +| **Experimental** | Synthetic / spec-derived, not machine-verified | `inferred` + experimental disclosure | +| **Detected-only** | Recognized but classification untested | `unavailable` (geometry only) | + +- **Launch Validated:** GRBL-laser (LightBurn export), GRBL milling + LinuxCNC (partner CNC). +- **Launch Experimental:** Marlin-laser, Mach, Smoothieware, servo/Z-pen plotters — promote to + Validated as hardware/fixtures land. +- **Option A (recommended): tiered per-dialect capability**, surfaced on every claim. +- **Option B: flat "CNC supported" flag** — rejected: dishonest across a fragmented ecosystem (the exact failure mode the scope decision guards against). + +### 4.7 D7 — Rendering & coloring + +- **Renderer:** `Cut` moves render as the productive path (as `Extrude` does today); `Travel` as + rapids. No renderer geometry change — it already keys on `MoveKind`. +- **Colorers (`gcode-colors`, consumers of D3):** `color-by-laserPower`, `color-by-spindle`, + `color-by-feed` (and `cut`-vs-`travel`), ramped like the `feedrate` precedent (#177), degrading to + `fallback` on absent channels rather than fabricating. +- **2D (E8) overlap:** the "layer" concept is weak for CNC (`Z` is depth, not a print layer) — disclose + via capability (`layers: 'unavailable'` for such files) rather than inventing layers. + +### 4.8 D8 — Testing, fixtures & real-hardware validation + +- **Synthetic, redistributable fixtures** with expected `kind`/tool-state ground truth: GRBL-laser, + GRBL + LinuxCNC milling (incl. a `G81` drill), servo + Z-pen plotter, an inch-mode (`G20`) variant. + **No private design files committed** (RR-004 §4). +- **FDM regression gate:** the existing FDM corpus IR must stay **byte-identical** (the `Cut` bit + + channels are opt-in/absent for FDM) — a golden-equivalence assertion. +- **Real-machine acceptance:** every **Validated**-tier dialect is backed by a recorded real-hardware + run (the evidence behind its `known`). Experimental tiers ship without it, clearly labeled. + +## 5. Lifecycle + +Parse options request move-model + channels → parser stamps `Cut` and the requested modal columns → +IR carries them capability-tagged → renderer/colorers consume them → dialect supplies the tier + labels. +FDM path unchanged when nothing non-extrusion is requested or present. + +## 6. Errors & failure behavior + +- Unknown/unverified canned cycle → geometry omitted for that cycle + a bounded `canned-cycle-unhandled` + warning (never a silent wrong hole). +- Tool-state opcode with a malformed/absent `S` → channel stays `unavailable` for that span, not `0`. +- Conflicting modal state (e.g. `M3` then `M5` mid-move) resolves at segment boundaries; disclosed. +- All failures are bounded warnings on the capability surface — never fabricated classification. + +## 7. Security & resource limits + +No new input surface beyond G-code text (DD-003 limits apply). Modal columns are **opt-in**, bounding +memory. Canned-cycle expansion is bounded by the existing segment cap (a pathological repeat count +cannot exceed it). No new dependency, filesystem, or network. + +## 8. Performance + +- Default FDM parse: **zero** new cost (nothing requested/stamped). +- Each requested channel: one SoA typed column (`1`/`2`/`4` bytes × `count`, RR-002 §5.3) + O(1) + per-move register update. Budget: requesting the two launch channels stays within the E8 low-resource + envelope; measured in the exit benchmark. + +## 9. Testing + +- FDM **byte-identical** golden-equivalence (regression) — the primary guard. +- Per-fixture classification assertions (`kind` + tool-state) for each launch dialect. +- Canned-cycle geometry assertions (`G81`/`G82`/`G83`, `G98`/`G99`). +- Inch-mode (`G20`) + arc-plane (`G18`/`G19`) regressions on non-extrusion files. +- Real-machine validation runs recorded as acceptance evidence for Validated tiers. + +## 10. Migration + +Additive; no consumer migration. New `MoveKind.Cut`, new opt-in channels, new colorers, new dialects — +all behind capabilities. #180/RR-002 color modes **re-platform onto this DD's `ModalChannel`** (a code +consolidation, not a public break). Existing FDM consumers see no change. + +## 11. Observability / diagnostics + +Every non-extrusion claim carries its **validation tier** on the capability surface; machine-class is an +`inferred` hint; unhandled cycles/opcodes emit bounded warnings. The parser exposes which modal channels +were requested vs populated. + +## 12. Alternatives considered + +- **Stay FDM-only (honest degradation forever)** — rejected by the scope decision; real-hardware + validation makes first-class support defensible. +- **A parallel non-FDM IR / move model** — rejected: additive `Cut` + `ModalChannel` composes with the + existing IR; a fork multiplies the renderer/capability/adapter surface. +- **Separate modal mechanisms for #189 and #180** — rejected: identical machinery; drift-prone. +- **Flat "CNC supported" claim** — rejected: dishonest across a fragmented ecosystem. + +## 13. Risks + +| Risk | Mitigation | +|---|---| +| False confidence about non-extrusion semantics | **Validation tiers** — `known` only when machine-verified; the scope decision's core guard | +| Dialect breadth is open-ended | Tier untested dialects `unavailable`; promote only with hardware/fixtures | +| Canned-cycle variants differ by controller | MVP subset + per-dialect params; unhandled → disclosed, not guessed | +| Accidental FDM regression | Byte-identical golden-equivalence gate on the FDM corpus | +| Channel memory on huge CNC files | Opt-in columns; measured against the E8 envelope | +| Machine-class mis-detection | Behavior driven by tool-state + `Cut`, not by class; class is only an `inferred` hint | + +## 14. Phased delivery + +1. **Move model + `ModalChannel` mechanism** (`toolpath-core` + `gcode-parser`): `MoveKind.Cut`, the + fallback classifier, opt-in modal registers with `laserPower`/`spindle`/`penState`. **Re-platform + #180/RR-002 channels onto it** (the shared-mechanism payoff). +2. **Canned-cycle expansion** (`G81`/`G82`/`G83` + `G80` + `G98`/`G99`), per-dialect. +3. **Dialect families + tiers** (`gcode-dialects`): GRBL-laser + GRBL/LinuxCNC mill **Validated**; + Marlin-laser/Mach/Smoothieware/plotter **Experimental**. +4. **Rendering & coloring** (`gcode-colors` + renderer gates): power/spindle/feed colorers; cut-vs-rapid. +5. **Compatibility matrix + docs + support-policy** — the tiers, per dialect, dated and evidenced. +6. **Exit** — real-hardware validation evidence for Validated tiers; low-resource benchmark; FDM + byte-identical confirmed. + +## 15. Acceptance criteria + +- [x] D1–D8 decided by the maintainer (2026-07-28, as recommended); DD marked Accepted. +- [ ] FDM corpus IR **byte-identical** vs pre-DD (regression gate green). +- [ ] `MoveKind.Cut` + the fallback classifier correctly classify the launch fixtures; validated on real + hardware for the **Validated** dialects. +- [ ] One `ModalChannel` mechanism serves both tool-state (#189) and #180's color channels; #180 + re-platformed onto it. +- [ ] `G81`/`G82`/`G83` (+ `G98`/`G99`) produce correct geometry; unhandled cycles disclosed. +- [ ] Every non-extrusion claim surfaces its **validation tier**; untested dialects are `unavailable`, + never silently wrong. +- [ ] Compatibility matrix + support-policy updated with dated, evidenced tiers. +- [ ] Synthetic redistributable fixtures only; real-machine runs recorded as acceptance evidence. + +## 16. Reference specifications & parity sources + +There is **no single specification for "CNC/laser G-code"** — it is a forked standard whose authorities +are unevenly distributed across the two machine classes. That distribution is not trivia; it decides +**what D5/D6 can derive from a spec versus what only hardware can confirm** (RR-004 §9 records the full +survey). Cited as **behavioral parity targets** and pointers to *where a behavior is defined* — never as +text to copy (ISO 6983 is copyrighted; the LinuxCNC reference is GPL/GFDL; this project is MIT). + +| Domain | Authority | Anchors | +| --- | --- | --- | +| CNC milling (core) | **RS274NGC — NIST IR 6556** (Kramer/Proctor/Messina, 2000; free) | Motion modes, `G81`–`G89` (D5), `G54`–`G59`, `#params`, `[expr]`, O-words | +| CNC milling (formal) | **ISO 6983-1** (paywalled) | Address-word grammar | +| CNC milling (executable) | **LinuxCNC G-code reference** (free; GPL/GFDL) | RS274NGC as-implemented — home of the phase-7 gap list | +| Lasers | **GRBL source + wiki** (`$32`, `M3`/`M4`) — no standard | The GRBL-laser power model (D4) | +| Lasers (emitter) | **LightBurn docs** — observed output only | Post-processor fingerprints (D4/D6) | +| FDM cross-ref | **RepRap G-code wiki** — community, not a standard | E-stack parity | + +**The split that governs the validation tier (D6):** + +- **CNC is spec-anchored.** D5 canned cycles, coordinate systems, and the phase-7 gaps (O-word + execution, `#params`, `[expressions]`, `L`-repeat) are documented in RS274NGC / LinuxCNC — build them + **to the spec**, with hardware as the *check*. A spec-derived-but-unverified controller is still + `Experimental`/`inferred` (D6) until a real run confirms it. +- **Lasers are not spec-anchored.** GRBL-laser and LightBurn are source/observed only, so **the machine + is the source of truth** — this is where the hardware-validation moat (D8) does the real work. +- **Vendor extensions escape every spec.** E.g. the `mach3` plasma sample overloads bare `S` for + torch-height control (not spindle speed); nothing in RS274NGC covers it, so it is `inferred` by + construction. D4's rule — drive behavior off the tool-state channel + `Cut` bit, never off machine + class — is what keeps such cases from producing wrong geometry. + +## Decision log + +| Date | Decision | By | +|---|---|---| +| 2026-07-28 | DD-012 drafted as **Proposed**; decision menu D1–D8 open. Follows RR-004 (scope IN, validated by real hardware) and unifies the modal-channel mechanism with RR-002/#180. | Chestnut Labs | +| 2026-07-28 | **Accepted — D1–D8 as recommended.** D1 no new package; D2 `MoveKind.Cut` bit; **D3 one shared opt-in `ModalChannel` mechanism owned here, #180/RR-002 consume it → DD-015 retired**; D4 tool-state-driven with machine-class an `inferred` hint; D5 canned-cycle MVP (`G81`/`G82`/`G83` + `G80` + `G98`/`G99`); D6 validation-tiered dialects; D7 reuse renderer + ramped colorers; D8 synthetic fixtures + FDM byte-identical gate + real-machine acceptance. | Maintainer | +| 2026-07-29 | **Editorial (no decision change):** added §16 reference specifications & parity sources (RS274NGC/NIST IR 6556, ISO 6983, LinuxCNC, GRBL, LightBurn, RepRap) making the **spec-anchored CNC vs. observed-only laser** split explicit — it grounds D5/phase-7 in RS274NGC and reaffirms why lasers stay hardware-gated under D6/D8. Full survey in RR-004 §9. Provenance: parity targets only, no spec text copied. | Chestnut Labs | diff --git a/docs/design/DD-017-rs274ngc-parametric-programs.md b/docs/design/DD-017-rs274ngc-parametric-programs.md new file mode 100644 index 00000000..72c1a09c --- /dev/null +++ b/docs/design/DD-017-rs274ngc-parametric-programs.md @@ -0,0 +1,204 @@ +# DD-017 — RS274NGC parametric programs: parameters, expressions & O-word flow (#189 phase 7) + +**Status:** **Proposed** +**Authors/Owners:** Nathaniel Chestnut +**Date:** 2026-07-29 · **Last revised:** 2026-07-29 +**Owning Epic:** #189 (non-extrusion toolpath coverage), **phase 7** · **Milestone:** Future +**Supersedes / Superseded by:** none +**Related:** [DD-012](./DD-012-non-extrusion-toolpath-and-modal-tool-state.md) (non-extrusion move model — this extends its parser work; §16 spec anchors), [RR-004](../research/RR-004-non-extrusion-toolpath-coverage.md) §9 (spec landscape), DD-010 (motion interpreter this builds on), DD-003 (parser resource limits — this adds two), DD-001 (capability/confidence model). Reference spec: **RS274NGC / NIST IR 6556** (free), as-implemented by the **LinuxCNC G-code reference** (cited as behavioral parity, no text copied — RR-004 §6). + +--- + +> **Why now.** Real LinuxCNC / CAM output routinely uses the RS274NGC *programming* layer — numbered/named +> **parameters** (`#100 = 5`), **expressions** (`X[#1 + 2*sin[30]]`), and **O-word** subroutines/loops/ +> conditionals. The parser lexes these tokens away today (`Number('[#1+5]')` → `NaN`, dropped), so a +> parametric program renders **partial or empty geometry**. This is the largest remaining #189 gap and, +> unlike the laser semantics, it is **fully spec-derivable** — build to RS274NGC, no hardware needed to +> get it right (hardware only re-confirms). But it turns the parser into a small **interpreter** (symbol +> table + expression evaluator + control flow that re-executes and skips lines), which is +> architecture-sensitive — hence this DD before any code. + +## 1. Problem + +The engine is a **forward-only line processor**: `Engine.processLine(rawLine, offset)`, called once per +line in source order (`parse.ts:252`). RS274NGC programs violate every assumption of that model: + +- **Parameters** are mutable state (`#n = [expr]`) read anywhere later (`X#100`) — a symbol table. +- **Expressions** (`[ ... ]`) can appear as *any* word value and must be **evaluated**, not read. +- **O-word flow** re-orders execution: `while`/`endwhile` and `repeat`/`endrepeat` **re-run** a line + range; `if`/`endif` **skips** one; `sub`/`call`/`return` **jump**. Forward-only cannot express this. + +Today's lexer (`lexLine`) splits on letters and does `Number(value)`, so `#`, `[`, `]`, and `=` are not +tokens it understands — parametric words are silently dropped. The result is honest-but-empty +(`unavailable`/missing geometry), never *wrong*, but it is a real coverage gap for the CNC class. + +## 2. Scope + +A **pragmatic RS274NGC subset**, engaged only when the constructs appear (FDM and simple CNC untouched): + +1. **Parameters** — numbered `#1`–`#5399`, named `#` / `#<_global>`, assignment `#n = `, + reference `#n` as any word value; a **read-only** subset of system parameters. +2. **Expressions** — `[ ]` grouping; the RS274NGC operator + function set; all values are `f64`. +3. **O-word control flow** — `sub`/`endsub`/`call`/`return`, `if`/`elseif`/`else`/`endif`, + `while`/`endwhile`, `do`/`while`, `repeat`/`endrepeat`, `break`/`continue`; numeric or named O-words. +4. **Two new resource limits** — bounded iteration + recursion (DD-003 family) so a `while [1]` or + infinite recursion cannot hang or OOM the worker. +5. **Capability + honesty** — computed geometry is `known` (deterministic); any unhandled construct is + **disclosed** (warning + capability), never guessed. +6. **Fixtures** — MIT-clean synthetic parametric programs with expected resolved geometry (golden-gated). + +## 3. Non-goals + +- **External subroutine files** (`M98 Pnnn` Fanuc-style program calls, `o call` resolving to a + separate `.ngc` file) — needs a filesystem/program library; **out** (no I/O in the parser). In-file + subroutines only. (Fanuc `M98`/`M99` numeric subprograms: a possible later follow-up, noted not built.) +- **Persistent parameters** across program runs (the LinuxCNC `.var` file) — we interpret a single + program in isolation; persistent `#5xxx` read as their documented power-on defaults or `0`/disclosed. +- **G-code that mutates machine config** (tool tables `G10 L1`, offset writes beyond DD-010's `G10 L2/L20`) + — motion only, per DD-012 §3. +- **Changing any FDM or simple-CNC behavior** — byte-identical regression gate holds (D7). +- **Full RS274NGC conformance** — a subset that covers real LinuxCNC/CAM output; gaps disclosed, not faked. + +## 4. Decisions + +Decision points **D1–D8**, each with options and a recommendation for maintainer acceptance. + +### 4.1 D1 — Execution model: where interpretation lives *(the pivotal decision)* + +O-word flow needs random line access; the engine is forward-only. Three ways to reconcile: + +- **Option A (recommended): a program-buffered interpreter layer, engaged on demand.** A cheap first + scan flags whether the program contains any RS274NGC construct (`#`, `[`, or a leading `o`-word). If + **none** (all FDM, and most simple GRBL/laser CNC), the existing forward-only fast path runs + **unchanged and byte-identical**. If **present**, buffer the program's lines (already fully in memory — + `createEngine` decodes the whole `text`), build an **O-word block index** (sub/if/while/repeat spans), + and run a small interpreter with a program counter + parameter table + expression evaluator that + resolves each executed line to a concrete word set and feeds the **same segment-emitting core** + (`g0`/`g2`/dispatch). One motion/classification code path; the interpreter only *feeds* it. +- **Option B: inline control flow in the main loop.** Give the engine a program-counter and line array + always. Rejected: burdens the hot FDM path with interpreter bookkeeping for a feature it never uses; + muddies the byte-exact core. +- **Option C: a separate `gcode-rs274` package / parse mode.** Rejected: the motion semantics are the + same interpreter (DD-010) — a fork duplicates dispatch, classification, limits, goldens (cf. DD-012 D1). + +**Streaming note:** O-word programs cannot be interpreted from a partial stream (loops need the whole +body). Detection ⇒ buffer fully; these files are small (hand/CAM-written, not million-line FDM). The +streaming driver keeps working for non-parametric input; a parametric program detected mid-stream is +interpreted once fully buffered (or disclosed if the stream is truncated). + +### 4.2 D2 — Parameters + +- **Numbered** `#1`–`#5399`: a dense/lazy `Float64` store. Read-before-write ⇒ `0` (RS274NGC default) + **with a disclosure** the first time (an uninitialized read is often a program bug). +- **Named** `#` (local to the current sub scope) and `#<_global>` (leading underscore = global): + a `Map` per scope frame. +- **Assignment** `#n = ` / `# = `: evaluated at execution time, honoring flow (a + `while` body re-assigns each pass). +- **System parameters** (read-only subset): current position (`#5420`…), active coord system, etc. — + **Recommend a small allow-list** we can resolve from IR state; anything else reads `0` + disclosed. + Rejected: full system-param emulation (feeds/speeds, tool geometry) — DD-012 §3 non-goal. + +### 4.3 D3 — Expressions + +Recursive-descent evaluator over the RS274NGC grammar; all values `f64`; booleans are `1`/`0`. + +- **Binary:** `+ - * /`, `MOD`, `**` (power), `AND OR XOR`, comparisons `EQ NE GT GE LT LE`. RS274NGC + precedence (`**` > `* / MOD` > `+ -` > comparisons > logical). +- **Unary functions:** `ABS ACOS ASIN COS SIN TAN EXP FIX FUP LN ROUND SQRT` and the two-arg + `ATAN[y]/[x]`; `EXISTS[#]`. +- **Grouping** `[ ]` (RS274NGC uses brackets, not parens; `( )` stays a **comment**, unchanged). +- Evaluated wherever a numeric word value is expected (`X[…]`, `F[…]`, `#n = […]`, `o if […]`). +- Malformed expression ⇒ that word is dropped **with a disclosure**, parse continues (never throw out). + +### 4.4 D4 — O-word control flow + +Support the constructs real programs use; each O-word keyed by its number/name: + +- `o sub` … `o endsub`; `o call [a1] [a2] …` (args → `#1`…`#30` in the callee frame); + `o return [expr]`. +- `o if [c]` / `o elseif [c]` / `o else` / `o endif`. +- `o while [c]` / `o endwhile`; `o do` / `o while [c]`; `o repeat [count]` / `o endrepeat`. +- `o break` / `o continue`. +- **Recommend a one-pass block indexer** (match each opener to its closer, record line spans) before + execution, so jumps are O(1). Unbalanced O-words ⇒ disclosed, that block skipped. + +### 4.5 D5 — Resource limits & security *(required — this is now a bounded interpreter)* + +RS274NGC is effectively Turing-complete; `o while [1]` or unbounded recursion must not hang/OOM the +worker. Extend `ParseLimits` (DD-003): + +- **`maxProgramIterations`** — total loop-body executions across the program (recommend `1_000_000`); + exceeding ⇒ stop with a `StopReason` (like `maxSegments`), partial IR returned, disclosed. +- **`maxCallDepth`** — subroutine recursion depth (recommend `50`); exceeding ⇒ disclosed, call skipped. +- Existing `maxSegments`/`maxBufferBytes` still bound emitted geometry. Parameter store bounded by the + `#1`–`#5399` range + a cap on named params. **No I/O, no `eval`** — a pure arithmetic evaluator over a + fixed grammar (no user-defined functions), so no code-execution surface. Ties to DD-003's adversarial + posture: a hostile parametric program can waste bounded CPU, nothing more. + +### 4.6 D6 — Capability & honesty + +- New capability key **`parametricProgram`** (name TBD): `known` when the program used params/expr/O-words + **and we executed them within limits**; `approximated`/disclosed when a limit was hit or a construct was + skipped; `unavailable` for non-parametric files. Resolved coordinates themselves remain `known` (they + are deterministic functions of the program) — the interpreter computes, it does not infer. +- Every skipped/unhandled construct emits a specific warning code (e.g. `rs274-unsupported-oword`, + `rs274-iteration-limit`, `rs274-uninitialized-param`) so the harness/UX can surface exactly what was lost. + +### 4.7 D7 — Byte-exactness, goldens & provenance + +- **FDM + simple-CNC byte-identical:** the fast-path gate (D1) means any file with no `#`/`[`/`o`-word + takes the untouched path; the golden-equivalence + native-golden gates prove it. +- **New parametric fixtures** (synthetic, MIT-clean) with **expected resolved geometry** as goldens: + a param+expression part, a `while` loop (bolt-circle), an `if`/`else`, a `sub`/`call` with args, a + `repeat`. Assertions on emitted segment positions/kinds. +- **`srcByte`** for a computed segment maps to the **executing source line** (the loop body line, not the + `while` header) so source-mapping stays meaningful; document the convention. +- Provenance: semantics implemented from RS274NGC/LinuxCNC **behavior**, expressed in our own code — + **no spec text copied** (ISO/ LinuxCNC-GFDL constraint, RR-004 §6). + +### 4.8 D8 — Delivery shape + +Additive to `@chestnutlabs/gcode-parser` only (no new package, no IR shape change — resolved words feed +the existing SoA). No renderer/adapter/color changes (they consume the same segments). One `minor` bump. + +## 5. Phased delivery (proposed, on acceptance) + +1. **Expression evaluator + parameters** (no flow yet): lex `#`/`[`/`]`/`=`, the evaluator, the param + store, the fast-path detection gate. A straight-line parametric program (assignments + `X[expr]`) + renders correctly. Golden: param+expression part. *FDM byte-identical proven here.* +2. **O-word block indexer + conditionals + loops** (`if`/`while`/`do`/`repeat`) + the two limits. + Golden: bolt-circle `while`, an `if`/`else`. +3. **Subroutines** (`sub`/`call`/`return`, arg binding, `maxCallDepth`, named O-words). Golden: `sub` with args. +4. **Capability + disclosure surface + docs** (`parametricProgram`, warning codes; the compatibility + matrix + a manual page; harness "skipped commands" already surfaces the warnings). Real LinuxCNC + file re-checked through the validation harness as confirmation (not a gate — this is spec-derived). + +## 6. Alternatives considered + +- **Stay honest-but-empty** — rejected: it's the single biggest CNC coverage gap and is fully + spec-derivable; leaving it forfeits real LinuxCNC/CAM files. +- **Ship without loop/recursion limits** — rejected: an unbounded interpreter in a worker is a DoS hole. +- **A dependency (an existing RS274NGC JS interpreter)** — rejected: license/provenance risk, dependency + weight, and our evaluator only needs a fixed grammar; DD-003 limits must be first-class, not bolted on. + +## 7. Open questions (for acceptance) + +1. **Capability name** — `parametricProgram` vs `programControlFlow` vs splitting params/expr from flow. +2. **Iteration/recursion limit defaults** — `1_000_000` / `50` proposed; tune to the worker envelope (E8). +3. **System-parameter allow-list** — which `#5xxx` are worth resolving from IR state vs `0`+disclosed. +4. **Scope of `repeat`/`do-while`** — include all three loop forms in phase 2, or `while` first? + +## 8. Acceptance criteria + +- [ ] D1–D8 decided by the maintainer; DD marked Accepted. +- [ ] FDM + non-parametric CNC corpus **byte-identical** (fast-path gate proven). +- [ ] Parameters, expressions, and the O-word subset produce correct geometry on the parametric fixtures. +- [ ] `maxProgramIterations` + `maxCallDepth` enforced; a `while [1]` fixture stops bounded + disclosed. +- [ ] Every unsupported/limited construct surfaces a specific warning; `parametricProgram` capability honest. +- [ ] Synthetic redistributable fixtures only; a real LinuxCNC file re-checked via the harness. + +## Decision log + +| Date | Decision | By | +|---|---|---| +| 2026-07-29 | DD-017 drafted as **Proposed** (D1–D8 open). Phase 7 of #189; the spec-derivable RS274NGC programming layer (params/expressions/O-words) flagged during real-file validation. Follows DD-012 §16 (spec-anchored CNC). | Chestnut Labs | diff --git a/docs/design/README.md b/docs/design/README.md index 3f43c3b1..868740f3 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -24,11 +24,13 @@ boundary. Architecture-sensitive work is **blocked until its DD is Accepted** (g | [DD-009](DD-009-toolpath-annotations-and-renderer-options.md) | Toolpath Annotations & Renderer Options | E9 | Future | **Accepted** 2026-07-23 (D1–D7 as recommended) (#160) | | [DD-010](DD-010-motion-model-correctness.md) | Motion-Model Correctness (extruder/positioning mode, arc planes, coordinate systems) | E10 | Future | **Accepted** 2026-07-24 (revised D1–D6) (#191) | | [DD-011](DD-011-binary-gcode-decode-adapter.md) | Binary G-code (`.bgcode`) Decode Adapter | E-bgcode (#188) | Future | **Accepted** 2026-07-27 (D1–D7 as recommended; D4 amended — metadata/thumbnails in-scope) — new `@chestnutlabs/gcode-bgcode` container adapter; heatshrink ISC-port + MeatPack clean-room (AGPL-clean, per [RR-003](../research/RR-003-bgcode-licensing-and-format-audit.md)); gated by §7.3 review + golden-equivalence | +| [DD-012](DD-012-non-extrusion-toolpath-and-modal-tool-state.md) | Non-extrusion toolpath (CNC/laser/plotter) & modal tool-state channels | #189 | Future | **Accepted** 2026-07-28 (D1–D8 as recommended) — additive `MoveKind.Cut` + one opt-in `ModalChannel` mechanism (owns tool-state **and** #180/RR-002 color channels → DD-015 retired), canned-cycle (`G81–89`) expansion, validation-tiered dialects; per [RR-004](../research/RR-004-non-extrusion-toolpath-coverage.md) | | [DD-013](DD-013-documentation-sdk-reference-and-published-manual.md) | Documentation, SDK Reference & Published Manual | E11 | Future | **Accepted** 2026-07-25 (D1–D7 as recommended) (#197) | | [DD-014](DD-014-low-resource-layer-renderer.md) | Low-Resource Layer Mode (2D/adjacent-layer renderer over IR) | E8 (#9) | Future | **Accepted** 2026-07-26 (D1–D5 as recommended; D6 build-now on the AnyBridge evidence artifact) | | [DD-016](DD-016-annotation-derived-move-kinds.md) | Annotation-Derived Move Kinds (Wipe & Seam) | E9 (#182) | Future | **Accepted** 2026-07-27 (D1–D6 as recommended; D2 = narrow additive DD-005 sink amendment so a slicer-comment signal can set `Wipe`; seam a non-goal) | +| [DD-017](DD-017-rs274ngc-parametric-programs.md) | RS274NGC Parametric Programs (parameters, expressions & O-word flow) | #189 phase 7 | Future | **Proposed** 2026-07-29 (D1–D8 open) — spec-derivable RS274NGC programming layer: on-demand interpreter engaged only on `#`/`[`/O-words (FDM byte-identical), bounded iteration/recursion limits; per [RR-004](../research/RR-004-non-extrusion-toolpath-coverage.md) §9 | -> **Reserved:** DD-012 (CNC/laser/plotter non-extrusion toolpaths, epic #189) is reserved by the -> DD-010 sibling triage and not yet drafted. DD-015 is the candidate number for the modal-state -> color-channels DD ([RR-002](../research/RR-002-modal-state-color-channels.md), #180), not yet drafted. -> (DD-011, Binary G-code decode for #188, is now drafted — above.) +> **Reserved:** DD-015 was the candidate number for a standalone modal-state color-channels DD +> ([RR-002](../research/RR-002-modal-state-color-channels.md), #180). It is now **likely unnecessary**: +> the shared `ModalChannel` mechanism is **owned by DD-012** (above), which #180's color channels +> consume — see DD-012 §4.3. (DD-012 non-extrusion toolpath, #189, is now drafted — above.) diff --git a/docs/manual/concept-dialects-containers.md b/docs/manual/concept-dialects-containers.md index 953e2d52..56e642da 100644 --- a/docs/manual/concept-dialects-containers.md +++ b/docs/manual/concept-dialects-containers.md @@ -45,3 +45,18 @@ dialect provides them, so a preview can reflect per-object and per-tool structur Which position-affecting G/M-codes are honored — and the remaining gaps — is tracked in the [G-code motion coverage matrix](https://github.com/ChestnutLabs/gcode-preview/blob/dev/docs/compatibility/gcode-motion-coverage.md). The modeling itself is described in [Live progress & motion model](concept-progress-motion.md). + +## Non-extrusion — CNC / laser / plotter (experimental) + +Beyond FDM, the stack understands **non-extrusion** toolpaths. Where there is no extrusion `E` to key +on, a move made while a tool is engaged (spindle/laser on via `M3`/`M4`) is classified `MoveKind.Cut` +rather than `Travel`; the modal `S` value rides an opt-in **`toolPower`** channel +(`parseOptions.modalChannels: ['toolPower']`), colorable with the `power` mode; and canned drilling +cycles (`G81`/`G82`/`G83`, incl. `G83` peck) expand to real geometry. Controllers (GRBL laser/mill, +LinuxCNC) are recognized as dialects. + +This is honesty-**tiered** (DD-012): until a controller is confirmed on real hardware, its +non-extrusion classification is reported **`inferred`** (experimental), never `known`, with a +disclosure warning. Geometry always parses regardless. See the +[compatibility matrix](https://github.com/ChestnutLabs/gcode-preview/blob/dev/docs/compatibility/dialects-and-containers.md) +for the per-controller tier. diff --git a/docs/reference/support-policy.md b/docs/reference/support-policy.md index 3d871039..1f2949d4 100644 --- a/docs/reference/support-policy.md +++ b/docs/reference/support-policy.md @@ -57,6 +57,19 @@ See the living references (each with its own evidence): - Live-progress signal contract: [`docs/reference/progress-signal-contract.md`](progress-signal-contract.md) - Progress consumer notes: [`docs/reference/progress-consumer-notes.md`](progress-consumer-notes.md) +### Non-extrusion (CNC / laser / plotter) — **experimental** (DD-012, #189) + +Beyond FDM, the stack parses and classifies non-extrusion toolpaths — CNC milling, diode/CO₂ lasers, +and pen plotters. **Geometry is fully supported** (positions, arcs, and expanded `G81`/`G82`/`G83` +drilling cycles always render). The **semantic** layer — cut-vs-rapid classification (`MoveKind.Cut`), +the `toolPower` channel, and controller recognition (GRBL laser/mill, LinuxCNC) — currently ships at +the **experimental** validation tier: these claims are reported **`inferred`**, not `known`, with a +`cnc-dialect-experimental` disclosure, until confirmed on real hardware. A controller is promoted to +**validated** (`known`) per DD-012 §8/§15 once a real-machine run confirms its classification. DSP +lasers (`.rd`) and galvo (`.ezd`) are out of scope (proprietary binary, not G-code). See the +[compatibility matrix](../compatibility/dialects-and-containers.md#dialects-non-extrusion--cnc--laser--plotter-dd-012-189) +for the per-controller tier. + ## How this document changes The window definitions are policy (DD-008-gated); the **dated pins** are audit data, refreshed at diff --git a/docs/research/README.md b/docs/research/README.md index e3854999..70256983 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -18,3 +18,4 @@ alternatives. | [RR-001](RR-001-upstream-baseline-and-architecture-audit.md) | Upstream Baseline and Architecture Audit | E0 | **Complete — ready for E0 acceptance.** Baseline = `develop` @ `15375e56` (maintainer-confirmed). Benchmarks (§5.5) + reference comparison (§5.4) done; inherited red suite fixed (#23). | | [RR-002](RR-002-modal-state-color-channels.md) | Advanced color modes via modal-state channels (fan/temp/accel/jerk/PA/flow) | E9 | **Complete (#180).** Flow + volumetric-flow are derivable free (ship as `gcode-colors` colorers); accel/fan/temp/jerk/PA need an **opt-in** modal register (~15 MB/1 M segments if always-on → gated). Recommends one capability-gated "modal state channels" subsystem, shared with the CNC/laser tool-state DD (#189). | | [RR-003](RR-003-bgcode-licensing-and-format-audit.md) | Binary G-code (`.bgcode`) licensing & format audit | E-bgcode (#188) | **Complete (#188 phase 0).** Format v1 pinned (block/compression/encoding IDs, CRC32). Licensing: `libbgcode` + MeatPack are **AGPL** (never copy/WASM-bundle) → clean-room MeatPack; heatshrink is **ISC** → port with attribution; DEFLATE/CRC32 reuse ours. Recommends new `@chestnutlabs/gcode-bgcode` container adapter → **DD-011**, gated by a §7.3 security review + the plain-vs-`.bgcode` golden-equivalence killer test. | +| [RR-004](RR-004-non-extrusion-toolpath-coverage.md) | Non-extrusion toolpath coverage (CNC mill / laser / pen plotter) | #189 | **Complete.** Scope decision **RESOLVED — IN SCOPE** (maintainer 2026-07-28; de-risked by real-hardware validation: maintainer laser + partner CNC). Current IR mis-classifies non-extrusion work (cut/burn/draw → `Travel`, tool state dropped) but geometry is sound. Recommends **DD-012**: additive `MoveKind.Cut = 1<<7` + an **opt-in** `ModalChannel` mechanism (laser power / spindle / pen) that **also serves #180/RR-002** (one mechanism), canned-cycle (`G81–G89`) expansion, validation-**tiered** dialect honesty (`known` only when machine-verified), synthetic redistributable fixtures. | diff --git a/docs/research/RR-004-non-extrusion-toolpath-coverage.md b/docs/research/RR-004-non-extrusion-toolpath-coverage.md new file mode 100644 index 00000000..2d509a40 --- /dev/null +++ b/docs/research/RR-004-non-extrusion-toolpath-coverage.md @@ -0,0 +1,161 @@ +# RR-004 — Non-extrusion toolpath coverage (CNC mill / laser / pen plotter) + +**Status:** Complete +**Author(s):** Nathaniel Chestnut +**Date:** 2026-07-28 +**Owning Epic:** #189 (CNC / laser / plotter, non-extrusion toolpath coverage) · **Informs:** the reserved **DD-012** (non-extrusion move model + modal tool-state channels); **reconciles** RR-002 / #180 (advanced modal color modes) — one modal-channel mechanism, owned here + +--- + +## 1. Question & the decision it informs + +Non-extrusion G-code — diode/CO₂ **lasers** (LightBurn/GRBL, Marlin-laser), **CNC milling** (GRBL, LinuxCNC, Mach), and **pen plotters** (servo / Z-lift) — parses *geometry* correctly today but the FDM-shaped IR **mis-classifies the actual work**: with no extrusion `E` to key on, cut/burn/draw moves collapse into `Travel`, and tool state (spindle RPM, laser power, pen up/down) has nowhere to live. Two questions: + +1. **Is non-extrusion toolpath in scope** — is this "a cross-vendor toolpath stack" or "FDM-excellent with honest degradation elsewhere"? +2. **If in scope, what *additive* IR / parser / dialect changes make it honestly supported without changing FDM behavior**, and how do we avoid over-claiming across a fragmented controller ecosystem? + +**The scope decision is RESOLVED (maintainer, 2026-07-28): non-extrusion toolpath is IN SCOPE.** The deciding factor is validation capability, not appetite: the maintainer operates **real lasers**, and a partner shop operates **CNC tables**, so classification can be **validated against real machine behavior** rather than inferred from spec. That directly retires this epic's headline risk — *"false confidence about non-extrusion semantics."* This RR records that decision, audits current behavior, defines the honesty posture that keeps the claim safe, and recommends the shape of **DD-012**. + +## 2. Candidates / versions / commits tested + +- Repository: `ChestnutLabs/gcode-preview` @ `dev` (post-`v0.3.0`; this RR authored at `dev` HEAD). +- IR surface: `@chestnutlabs/toolpath-core` — `MoveKind` bitflag (`packages/toolpath-core/src/ir.ts:33`), `Confidence` (`ir.ts:14`), `ToolpathSegments` SoA (`ir.ts:96–120`). +- Dialect/annotation contract: `@chestnutlabs/gcode-dialects` — `AnnotationSink.addMoveKind` (allow-listed additive kinds, `sink.ts:27`), the E10 motion model (`extrusionMode`/`positioningMode`/`arcPlanes`/`coordinateSystem`, DD-010). +- Modal-channel precedent: RR-002 (advanced color modes), which already recommends *"the modal-channels DD and the CNC/laser DD (#189) be the same DD, or #189's DD own the mechanism."* +- No third-party code incorporated. LightBurn / GRBL / LinuxCNC / Mach referenced only as behavioral parity targets (observed machine output), no code or documentation text copied. + +## 3. Environment & reproducible procedure + +- Windows 11, repo working copy; IR-surface facts (§5.2–5.3) are static reads of committed source, host-independent. +- Current-behavior audit (§5.1) reproduces by parsing non-extrusion samples through the batteries worker and tallying `segments.kind` bitflags + populated channels — the same probe scripts recorded in the #189 research notes (real LightBurn/GRBL laser export + synthetic GRBL/LinuxCNC milling + Z-pen plotter). +- Free bit check: `MoveKind` occupies bits `0..6`; the next flag is `1 << 7` (see §5.2). + +## 4. Fixture / corpus manifest + +Tracked redistributable corpus today: `test-data/gcodes/` — `3DBenchy`, `calicat`, `easel`, `plant-sign`, `screw`, `vase` (FDM) and **`mach3.gcode`** (the single CNC-style file, an intentional documented divergence in the E10 identity-WCS corpus). **No committed laser / mill / plotter fixtures exist yet** — the audit evidence came from real + synthetic samples held as research notes, not tracked corpus. + +**Governance:** user-provided design files (laser projects, CAM output tied to a real job) are **not** committed. The fixture plan (§8) is to derive **small, synthetic, redistributable** fixtures — minimal GRBL-laser / GRBL-mill / LinuxCNC / Z-pen files with *expected* move-kind and tool-state ground truth — validated for classification against the real machines but not themselves containing private design data. + +## 5. Measurements & observable results + +### 5.1 Current mis-classification (audit, directly observed in prior #189 probes) + +| Sample | Productive moves classified as | Tool state | Notable loss | +|---|---|---|---| +| Laser (LightBurn/GRBL) | **100 % `Travel`**, 0 `Extrude` | dropped (`M4`/`S` power) | — | +| Milling (GRBL/LinuxCNC) | 17 `Travel` + 96 `ArcSegment\|Travel`, **0 `Extrude`** | dropped (`M3 S`) | **`G81` drill cycle → 0 geometry** | +| Plotter (Z-pen) | **100 % `Travel`** | dropped (pen up/down) | — | + +**The foundation is sound:** arcs are faceted (E2/E10 arc-plane work), bounds are correct, and the capability model is *honest* — it reports `unavailable`, never a fabricated cut/extrude. The gap is semantic (what the move *is*), not geometric (where it goes). + +### 5.2 IR headroom (directly observed) + +`MoveKind` is a bitflag with the next flag free: + +``` +None:0 Extrude:1<<0 Travel:1<<1 Retract:1<<2 Unretract:1<<3 +Wipe:1<<4 ArcSegment:1<<5 Seam:1<<6 → next free: 1<<7 +``` + +So a `Cut` / `ToolEngaged` classification is **one additive bit** (`1 << 7`) that composes with `ArcSegment` exactly as `Wipe`/`Seam` already do (DD-016 precedent). No breaking change; FDM segments never set it. + +### 5.3 The tool-state gap is the *same shape* as RR-002's modal channels (directly observed) + +CNC/laser tool state is a **modal register set by a standalone command, stamped per segment, colored by a ramp, capability-gated when absent** — bit-for-bit the pattern RR-002 defined for fan/temp/accel/jerk/pressure-advance: + +| Domain | Modal source | Value | Capability when absent | +|---|---|---|---| +| Laser power | `M3`/`M4` + `S` (and `M5` off) | 0–max (`S`) | `unavailable` | +| Spindle RPM | `M3`/`M4` + `S` (and `M5` off) | rev/min (`S`) | `unavailable` | +| Pen state | pen up/down (`M280` servo / `Z` lift) | up / down | `unavailable` | +| Fan / temp / accel … (#180) | `M106`/`M104`/`M204` … | scalar | `unavailable` | + +One `ModalChannel` abstraction (id, source opcode(s), value type, capability key) serves **both** #189 tool-state and #180 color channels with no FDM-specific assumptions. + +## 6. License / provenance concerns + +None incorporated. Controller behaviors are observed parity references, not copied code/text. The `Cut`-bit classifier and canned-cycle geometry are standard G-code semantics, not novel or encumbered. `.rd` (Ruida/Trocen/TopWisdom DSP) and `.ezd` (EZCad galvo) are proprietary **non-G-code** binary formats — **explicitly out of scope** (they are a different motion model, not a gap in this one). Fixture corpus stays synthetic/redistributable (§4). + +## 7. Limitations & unknowns + +- **Dialect breadth is real and open-ended.** GRBL alone has many forks; LinuxCNC/Mach add more. The mitigation is the honesty tier (§8), not exhaustive coverage — an untested dialect is `unavailable`, never silently wrong. +- **Ground truth needs the hardware.** Classification correctness for a given controller is only `known` once validated on the real machine; until then it is `inferred`. The maintainer's laser + partner CNC cover the launch dialects; others start `inferred`/experimental. +- **Canned cycles vary by controller** (`G81`–`G89` parameter conventions, `G98`/`G99` retract planes differ). Expansion must be per-dialect and disclosed where a variant is unverified. +- **Pen plotters** split between servo (`M280`) and Z-lift conventions; both need a fixture. Inch-mode (`G20`) laser/CNC files are common and must be in the corpus. +- **Arc/`I`,`J` vs `R` and plane state** already handled by E10, but non-FDM files exercise `G18`/`G19` more — worth an explicit regression. + +## 8. Recommendation & rejected alternatives + +### Recommendation — scope IN; deliver additively as **DD-012** + +1. **Additive move classification.** Add `MoveKind.Cut = 1 << 7` (§5.2). When the interpreter sees motion with **no `E` delta** while a **tool-engaged modal state** holds (spindle/laser on via `M3`/`M4`, pen down), classify the move `Cut` (composing with `ArcSegment`); rapids (`G0`, or motion with tool disengaged) stay `Travel`. FDM output is **byte-identical** — the bit is only ever set when `E` is absent and a tool-state channel is present. +2. **DD-012 owns the `ModalChannel` mechanism** (per RR-002 §8). An **opt-in**, per-channel modal register stamped onto a sparse/dense side array, requested via parse options so the **default FDM parse pays nothing and is unchanged**. Launch channels: **laser power** and **spindle RPM** (`S` under `M3`/`M4`/`M5`) and **pen state**. #180's fan/temp/accel/jerk/PA become **the same mechanism's FDM consumers** — build it once. (DD-015, the candidate #180 DD, collapses into DD-012 or becomes a thin consumer DD.) +3. **Canned-cycle expansion.** `G81`–`G89` (with `G98`/`G99` retract planes) expand to real geometry, per-dialect, disclosed where a variant is unverified. +4. **New dialect families**, each **honesty-tiered** (below): GRBL / Marlin-laser / GRBL-LPC (lasers); GRBL / LinuxCNC / Mach (milling); servo + Z-lift (plotters). +5. **Coloring** by laser power / spindle load / feed-vs-rapid — a `gcode-colors` consumer of the modal channel and the `Cut` bit (2D overlaps E8). + +### The honesty posture that keeps the claim safe (validation tiers) + +The capability model already carries `known | inferred | approximated | unavailable`. Apply it **per dialect/feature by validation level** — this is how "more fragile than FDM" is *disclosed*, not hidden: + +| Validation level | Confidence | +|---|---| +| Verified on real hardware (maintainer laser / partner CNC) | **`known`** | +| Synthetic / spec-derived, not yet machine-verified | **`inferred`** + an **experimental** disclosure | +| Untested dialect | **`unavailable`** — parses geometry, refuses to classify | + +Every non-extrusion claim ships with its tier surfaced (the same engine that already says `inferred` for `bed_shape` machine geometry). Non-extrusion is honestly labeled more fragile than FDM; users see exactly what is verified. + +### Fixture / validation plan (the real-hardware moat) + +Capture from the real machines, then commit **synthetic, redistributable** distillations with expected `kind`/tool-state ground truth: GRBL-laser (LightBurn export), GRBL + LinuxCNC milling (incl. a `G81` drill), Z-pen + servo plotter, an inch-mode (`G20`) variant. Golden-style assertions on classification; **no private design files committed** (§4). Real-machine runs are the acceptance evidence behind each `known` tier. + +### Rejected alternatives + +- **Stay FDM-only (honest degradation forever)** — rejected by the scope decision; the validation capability (real hardware) makes first-class support defensible where it would otherwise be reckless. +- **A bespoke non-FDM move model / parallel IR** — rejected: an additive `Cut` bit + modal channel composes with the existing IR; a second model would fork the renderer/capability/adapter surface. +- **Separate modal mechanisms for #189 (tool-state) and #180 (color channels)** — rejected: identical machinery (§5.3); two implementations would drift the IR. One mechanism, owned by DD-012. +- **Always-claim broad controller support** — rejected: dishonest across a fragmented ecosystem; the validation tier gates each claim instead. + +### Suggested follow-up (on DD-012 acceptance) + +- `feat(core): MoveKind.Cut + opt-in ModalChannel registers (laser power, spindle, pen)` — the shared mechanism. +- `feat(parser): canned-cycle (G81–G89) geometry expansion, per-dialect`. +- `feat(dialects): GRBL-laser + GRBL/LinuxCNC mill + pen-plotter families, validation-tiered`. +- `feat(colors): color-by-power / feed-vs-rapid` (consumer). +- Reconcile #180 / RR-002 as consumers of the DD-012 modal mechanism. + +## 9. Reference specifications & parity sources + +There is **no single specification for "CNC/laser G-code."** It is a forked standard: a small +standardized core that every controller extends incompatibly — which is why *dialect* is the correct +framing and why the honesty tier (§8), not a spec conformance claim, is the safety mechanism. The +authorities are **very unevenly distributed** across the two machine classes, and that distribution +directly determines what is spec-derivable versus hardware-only: + +| Domain | Authority | Status | What it anchors | +| --- | --- | --- | --- | +| **CNC milling (core)** | **RS274NGC** — *NIST IR 6556, "The NIST RS274NGC Interpreter — Version 3"* (Kramer, Proctor, Messina, 2000) | Free NIST technical report | Motion modes, `G81`–`G89` canned cycles, coordinate systems (`G54`–`G59`), parameters `#`, expressions `[]`, O-word flow — the interpreter LinuxCNC implements | +| **CNC milling (formal)** | **ISO 6983-1** (NC program format & address words) | Paywalled (ISO); thinner than IR 6556 | The formal address-word grammar | +| **CNC milling (open, executable)** | **LinuxCNC G-code reference** | Free, online; **GPL/GFDL text** | RS274NGC as-implemented — the exact home of our gap list (O-words, `#params`, `[expr]`, `L`-repeat) | +| **Lasers** | **GRBL source + wiki** (`$32` laser mode, `M3` constant vs `M4` dynamic power) | Source/wiki only — **no standard** | The GRBL-laser power model | +| **Lasers (emitter)** | **LightBurn documentation** | No public spec | Observed post-processor output only | +| **Firmware / FDM cross-ref** | **RepRap G-code wiki** (Marlin/RRF/Klipper/Smoothie) | Community-maintained; not a standard | FDM parity (already the E-stack's reference) | + +**Spec-derived vs. observed — the split that governs the tier (§8):** + +- **CNC is spec-anchored.** The milling motion model, canned cycles, coordinate systems, parameters, + and expressions are all defined in RS274NGC (IR 6556) and the LinuxCNC reference. Phase-7 gaps + (O-word execution, `#params`, `[expressions]`, canned-cycle `L`-repeat) are documented features we + have not yet implemented — **build them against the spec; hardware is the *check*, not the source.** +- **Lasers are not spec-anchored.** GRBL-laser is defined only by its source/wiki and LightBurn only by + observed output. There is no authority to conform to, so **the machine *is* the source of truth** — + this is where the hardware-validation moat (§8) does the real work. +- **Vendor extensions escape every spec.** Observed directly: the `mach3` plasma sample uses bare + `S`-words for torch-height control (DTHC), where `S` is **not** spindle speed — nothing in RS274NGC + covers this. Such cases are `inferred`/experimental by construction until a real machine confirms them. + +**Provenance discipline (ties to §6):** these are cited as **behavioral parity targets** and as +pointers for *where a behavior is defined* — **not** as text to copy. ISO 6983 is copyrighted and the +LinuxCNC reference is GPL/GFDL, whereas this project is MIT; we implement *to the documented behavior* +and express it in our own code and words. No specification prose is incorporated. diff --git a/package-lock.json b/package-lock.json index 4cbfef55..8d984506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7385,11 +7385,11 @@ }, "packages/gcode-bgcode": { "name": "@chestnutlabs/gcode-bgcode", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-containers": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-containers": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7397,10 +7397,10 @@ }, "packages/gcode-colors": { "name": "@chestnutlabs/gcode-colors", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7408,10 +7408,10 @@ }, "packages/gcode-containers": { "name": "@chestnutlabs/gcode-containers", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7419,10 +7419,10 @@ }, "packages/gcode-dialects": { "name": "@chestnutlabs/gcode-dialects", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7430,13 +7430,13 @@ }, "packages/gcode-parser": { "name": "@chestnutlabs/gcode-parser", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-bgcode": "0.3.0", - "@chestnutlabs/gcode-containers": "0.3.0", - "@chestnutlabs/gcode-dialects": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-bgcode": "0.4.0", + "@chestnutlabs/gcode-containers": "0.4.0", + "@chestnutlabs/gcode-dialects": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7444,13 +7444,13 @@ }, "packages/gcode-preview-core": { "name": "@chestnutlabs/gcode-preview-core", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-renderer-2d": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-renderer-2d": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7458,13 +7458,13 @@ }, "packages/gcode-preview-element": { "name": "@chestnutlabs/gcode-preview-element", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7472,13 +7472,13 @@ }, "packages/gcode-preview-react": { "name": "@chestnutlabs/gcode-preview-react", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "devDependencies": { "@types/react": "^18.3.0", @@ -7494,13 +7494,13 @@ }, "packages/gcode-preview-svelte": { "name": "@chestnutlabs/gcode-preview-svelte", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "devDependencies": { "svelte": "^4.2.0" @@ -7514,13 +7514,13 @@ }, "packages/gcode-preview-vue": { "name": "@chestnutlabs/gcode-preview-vue", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "devDependencies": { "vue": "^3.4.0" @@ -7534,11 +7534,11 @@ }, "packages/gcode-renderer-2d": { "name": "@chestnutlabs/gcode-renderer-2d", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-colors": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-colors": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "engines": { "node": ">=22" @@ -7546,11 +7546,11 @@ }, "packages/gcode-renderer-three": { "name": "@chestnutlabs/gcode-renderer-three", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-colors": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-colors": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "devDependencies": { "@types/three": "0.178.0" @@ -7564,7 +7564,7 @@ }, "packages/toolpath-core": { "name": "@chestnutlabs/toolpath-core", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "engines": { "node": ">=22" diff --git a/packages/gcode-bgcode/CHANGELOG.md b/packages/gcode-bgcode/CHANGELOG.md index c6070e88..98247a5d 100644 --- a/packages/gcode-bgcode/CHANGELOG.md +++ b/packages/gcode-bgcode/CHANGELOG.md @@ -1,5 +1,13 @@ # @chestnutlabs/gcode-bgcode +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09)]: + - @chestnutlabs/toolpath-core@0.4.0 + - @chestnutlabs/gcode-containers@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-bgcode/package.json b/packages/gcode-bgcode/package.json index 0eea326e..124e0995 100644 --- a/packages/gcode-bgcode/package.json +++ b/packages/gcode-bgcode/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-bgcode", - "version": "0.3.0", + "version": "0.4.0", "description": "Binary G-code (.bgcode) decode adapter for the Chestnut Labs G-code toolpath stack (DD-011): a license-clean, in-memory block walker that decodes Prusa .bgcode to plain G-code for the existing parser/dialect/renderer pipeline. Decode-only.", "keywords": [ "gcode", @@ -47,7 +47,7 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0", - "@chestnutlabs/gcode-containers": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0", + "@chestnutlabs/gcode-containers": "0.4.0" } } diff --git a/packages/gcode-colors/CHANGELOG.md b/packages/gcode-colors/CHANGELOG.md index ebc28a41..8199bd0d 100644 --- a/packages/gcode-colors/CHANGELOG.md +++ b/packages/gcode-colors/CHANGELOG.md @@ -1,5 +1,28 @@ # @chestnutlabs/gcode-colors +## 0.4.0 + +### Minor Changes + +- [#254](https://github.com/ChestnutLabs/gcode-preview/pull/254) [`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: non-extrusion color modes — color-by-power + cut-vs-rapid (DD-012 phase 4, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Two new `ColorMode`s consuming the [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189) channels (DD-012 D7): + - **`power`** — ramps each segment's modal `toolPower` (laser power / spindle RPM, the `S` value) onto + a color ramp, the CNC/laser counterpart to color-by-speed. Auto-ranged (`toolPowerRange`) or explicit; + `NaN` (tool off) or a file parsed without the `toolPower` channel → fallback, never a fabricated color. + Capability-gated on `toolPower` (the Three renderer's `isColorModeAvailable` gates it). + - **`moveKind`** — cut-vs-rapid: productive moves (`Extrude` or `Cut`) vs rapids (`Travel`) — the + "where the tool is actually working" view. Reads the always-present `kind` channel, so it is always + available. + + Both flow through `createSegmentColorer`, so the Three and Canvas-2D renderers get them for free. FDM + coloring is unchanged. + +### Patch Changes + +- Updated dependencies [[`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09)]: + - @chestnutlabs/toolpath-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-colors/package.json b/packages/gcode-colors/package.json index 81fa014b..37a414d1 100644 --- a/packages/gcode-colors/package.json +++ b/packages/gcode-colors/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-colors", - "version": "0.3.0", + "version": "0.4.0", "description": "Renderer-agnostic per-segment color model for the Chestnut Labs G-code toolpath stack (DD-014 D3): the ColorMode union and honest, capability-gated segment coloring over ToolpathIR channels, shared by the 3D and 2D renderers.", "keywords": [ "gcode", @@ -47,6 +47,6 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0" } } diff --git a/packages/gcode-colors/src/__tests__/colors.test.ts b/packages/gcode-colors/src/__tests__/colors.test.ts index b3ed3426..82a11cc4 100644 --- a/packages/gcode-colors/src/__tests__/colors.test.ts +++ b/packages/gcode-colors/src/__tests__/colors.test.ts @@ -6,7 +6,15 @@ */ import { describe, expect, it } from 'vitest'; import { MoveKind, ToolpathIRBuilder, type ToolpathIR } from '@chestnutlabs/toolpath-core'; -import { createSegmentColorer, segmentColor, feedrateRange, layerHeightRange, rampColor, type RGB } from '../index.js'; +import { + createSegmentColorer, + segmentColor, + feedrateRange, + toolPowerRange, + layerHeightRange, + rampColor, + type RGB +} from '../index.js'; const A: RGB = [1, 0, 0]; const B: RGB = [0, 1, 0]; @@ -17,12 +25,15 @@ interface SegChannels { tool?: number; feature?: number; object?: number; + kind?: number; + toolPower?: number; } /** N segments, each carrying the given optional channels. */ function makeIR(segs: SegChannels[], colorChangeAt: number[] = []): ToolpathIR { const b = new ToolpathIRBuilder({ parserVersion: 'test', units: 'mm', unitsSource: 'known' }); for (let s = 0; s < segs.length; s++) { + const { toolPower: _tp, ...seg } = segs[s]; b.addSegment({ x0: s, y0: 0, @@ -34,13 +45,21 @@ function makeIR(segs: SegChannels[], colorChangeAt: number[] = []): ToolpathIR { kind: MoveKind.Extrude, layer: 0, srcByte: s * 10, - ...segs[s] + ...seg }); } for (const segIndex of colorChangeAt) { b.addColorChange({ x: segIndex, y: 0, z: 0.2, segIndex, srcByte: segIndex * 10, tool: 0 }); } - return b.finalize(); + const ir = b.finalize(); + // The builder has no modal-channel input; attach the opt-in toolPower column directly (as the parser + // does when `modalChannels: ['toolPower']` is requested) so the color-by-power mode can be tested. + if (segs.some((s) => s.toolPower !== undefined)) { + (ir.segments as { modal?: Record }).modal = { + toolPower: Float32Array.from(segs, (s) => s.toolPower ?? NaN) + }; + } + return ir; } describe('single / tool / feature', () => { @@ -175,3 +194,44 @@ describe('rampColor', () => { expect(rampColor([A], 0.5)).toEqual([1, 0, 0]); }); }); + +describe('power / moveKind (#189, DD-012 D7)', () => { + it('power ramps the toolPower channel; NaN (tool off) → fallback', () => { + const ir = makeIR([{ toolPower: 0 }, { toolPower: 128 }, { toolPower: 255 }, { toolPower: NaN }]); + const c = createSegmentColorer(ir, { mode: 'power', ramp: [A, B], range: [0, 255], fallback: F }); + expect(c(0)).toEqual(A); // 0 → ramp start + expect(c(2)).toEqual(B); // 255 → ramp end + expect(c(3)).toEqual(F); // NaN → fallback, never a fabricated power color + }); + + it('power with no toolPower channel captured → every segment is fallback', () => { + const ir = makeIR([{}, {}]); // no modal channel + const c = createSegmentColorer(ir, { mode: 'power', ramp: [A, B], fallback: F }); + expect(c(0)).toEqual(F); + expect(c(1)).toEqual(F); + }); + + it('toolPowerRange ignores NaN; [0,0] when the channel is absent', () => { + expect(toolPowerRange(makeIR([{ toolPower: 50 }, { toolPower: NaN }, { toolPower: 200 }]))).toEqual([50, 200]); + expect(toolPowerRange(makeIR([{}, {}]))).toEqual([0, 0]); + }); + + it('moveKind: productive (Extrude/Cut) vs rapid (Travel), else fallback', () => { + const ir = makeIR([ + { kind: MoveKind.Extrude }, + { kind: MoveKind.Cut }, + { kind: MoveKind.Travel }, + { kind: MoveKind.None } + ]); + const c = createSegmentColorer(ir, { mode: 'moveKind', cut: A, travel: B, fallback: F }); + expect(c(0)).toEqual(A); // Extrude → productive + expect(c(1)).toEqual(A); // Cut → productive + expect(c(2)).toEqual(B); // Travel → rapid + expect(c(3)).toEqual(F); // None → fallback + }); + + it('segmentColor one-off works for the new modes', () => { + const ir = makeIR([{ kind: MoveKind.Cut }]); + expect(segmentColor(ir, { mode: 'moveKind', cut: A, travel: B, fallback: F }, 0)).toEqual(A); + }); +}); diff --git a/packages/gcode-colors/src/colors.ts b/packages/gcode-colors/src/colors.ts index 0b87287b..5acec5e0 100644 --- a/packages/gcode-colors/src/colors.ts +++ b/packages/gcode-colors/src/colors.ts @@ -12,7 +12,7 @@ * that read an optional channel (`feature`, `object`, `feedrate`) must be * capability-gated by the caller before use — the IR is told, not shown nonsense. */ -import type { ToolpathIR } from '@chestnutlabs/toolpath-core'; +import { MoveKind, type ToolpathIR } from '@chestnutlabs/toolpath-core'; export type RGB = [number, number, number]; @@ -32,7 +32,15 @@ export type ColorMode = // onto a ramp — the Orca/Bambu view that reveals variable-layer-height prints. Auto-ranged from the // IR when `range` is omitted. Derived purely from `ir.layers` Z; capability-gated by the caller on // `layers` (a non-planar IR collapses every segment to layer 0 → a single height). - | { mode: 'layerHeight'; ramp: RGB[]; range?: [number, number]; fallback: RGB }; + | { mode: 'layerHeight'; ramp: RGB[]; range?: [number, number]; fallback: RGB } + // Color-by-tool-power (#189, DD-012 D7): map each segment's modal `toolPower` (laser power / spindle + // RPM — the S value) onto a ramp. Requires the parse to have captured the `toolPower` modal channel + // (`ParseOptions.modalChannels`); capability-gated by the caller on `toolPower`. NaN (tool off) or a + // file parsed without the channel → fallback, never a fabricated power color. + | { mode: 'power'; ramp: RGB[]; range?: [number, number]; fallback: RGB } + // Cut-vs-rapid (#189, DD-012 D7): productive moves (Extrude or Cut) vs rapids (Travel) — the CNC/laser + // read of "where the tool is actually working." Capability-gated on `cutMoves` for non-extrusion files. + | { mode: 'moveKind'; cut: RGB; travel: RGB; fallback: RGB }; /** Neutral color used when a mode has no palette / the channel value is unknown. */ export const DEFAULT_FALLBACK: RGB = [0.7, 0.7, 0.7]; @@ -51,6 +59,22 @@ export function feedrateRange(ir: ToolpathIR): [number, number] { return min <= max ? [min, max] : [0, 0]; } +/** Min/max of the IR's captured (non-NaN) tool-power values — the auto-range for color-by-power + * (#189). Returns `[0, 0]` when the `toolPower` modal channel was not captured. */ +export function toolPowerRange(ir: ToolpathIR): [number, number] { + const p = ir.segments.modal?.toolPower; + if (p === undefined) return [0, 0]; + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < p.length; i++) { + const v = p[i]; + if (Number.isNaN(v)) continue; + if (v < min) min = v; + if (v > max) max = v; + } + return min <= max ? [min, max] : [0, 0]; +} + /** * Per-layer height (mm): the Z-delta from the previous layer, indexed by layer. Layer 0's height is * its absolute Z (its thickness from the bed). Negative deltas (non-monotonic Z, e.g. spiral/vase @@ -164,6 +188,29 @@ export function createSegmentColorer(ir: ToolpathIR, mode: ColorMode): SegmentCo return rampColor(mode.ramp, span > 0 ? (heights[li] - lo) / span : 0); }; } + if (mode.mode === 'power') { + const power = seg.modal?.toolPower; + // The channel wasn't captured (parse didn't request toolPower) → everything is fallback. + if (power === undefined) { + const fb = mode.fallback; + return () => fb; + } + const [lo, hi] = mode.range ?? toolPowerRange(ir); + const span = hi - lo; + // NaN (tool off / never engaged) → fallback, never a fabricated power color. + return (i) => { + const v = power[i]; + return Number.isNaN(v) ? mode.fallback : rampColor(mode.ramp, span > 0 ? (v - lo) / span : 0); + }; + } + if (mode.mode === 'moveKind') { + return (i) => { + const k = seg.kind[i]; + if ((k & (MoveKind.Extrude | MoveKind.Cut)) !== 0) return mode.cut; // productive move + if ((k & MoveKind.Travel) !== 0) return mode.travel; // rapid + return mode.fallback; + }; + } // object mode: object 0 = none/unknown → fallback; `only` isolates one object, dimming the rest (#178). return (i) => { const obj = seg.object[i]; diff --git a/packages/gcode-colors/src/index.ts b/packages/gcode-colors/src/index.ts index 4d8fc210..815fec1c 100644 --- a/packages/gcode-colors/src/index.ts +++ b/packages/gcode-colors/src/index.ts @@ -11,6 +11,7 @@ export type { RGB, ColorMode, SegmentColorer } from './colors.js'; export { DEFAULT_FALLBACK, feedrateRange, + toolPowerRange, layerHeights, layerHeightRange, rampColor, diff --git a/packages/gcode-containers/CHANGELOG.md b/packages/gcode-containers/CHANGELOG.md index eee8e6c7..c619c592 100644 --- a/packages/gcode-containers/CHANGELOG.md +++ b/packages/gcode-containers/CHANGELOG.md @@ -1,5 +1,12 @@ # @chestnutlabs/gcode-containers +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09)]: + - @chestnutlabs/toolpath-core@0.4.0 + ## 0.3.0 ### Patch Changes diff --git a/packages/gcode-containers/package.json b/packages/gcode-containers/package.json index 6fa15324..b5cf4409 100644 --- a/packages/gcode-containers/package.json +++ b/packages/gcode-containers/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-containers", - "version": "0.3.0", + "version": "0.4.0", "description": "Safe, bounded, in-memory container extraction for sliced G-code (.gcode.3mf) — DD-005 §4.4/§7. Never writes files, never fetches, zero dependencies.", "keywords": [ "gcode", @@ -42,6 +42,6 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0" } } diff --git a/packages/gcode-dialects/CHANGELOG.md b/packages/gcode-dialects/CHANGELOG.md index f5d5d18f..cacaa069 100644 --- a/packages/gcode-dialects/CHANGELOG.md +++ b/packages/gcode-dialects/CHANGELOG.md @@ -1,5 +1,64 @@ # @chestnutlabs/gcode-dialects +## 0.4.0 + +### Minor Changes + +- [#258](https://github.com/ChestnutLabs/gcode-preview/pull/258) [`3f06e5b`](https://github.com/ChestnutLabs/gcode-preview/commit/3f06e5b7b6926daaad4290b29c577a380c9e10df) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: evidence-based non-extrusion detection — recognize header-less real CNC/laser files ([#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + The phase-3 CNC/laser detectors were tuned on synthetic fixtures and matched **none** of the real + public samples (LaserGRBL, LinuxCNC, GRBL CAM output) — real controller output usually has **no + generator header** and writes commands **mid-line** and **concatenated** (`s3400 m3`, `g1z-.1`). + + Detection is now **evidence-scored**, not banner-only: + - A shared `scoreEvidence` extractor strips comments (so `(M3)` / `; LinuxCNC` in a comment can't + create a false marker) and matches commands as words anywhere on a line. + - **GRBL laser** — LightBurn / `$32=1`, **or** the header-less form: a tool-on command (`M3`/`M4`) with + `S` power and **no Z-plunge** (lasers are planar). + - **GRBL / generic mill** — `M3` spindle that **plunges into negative Z** with no extrusion (a milling + fingerprint that separates it from a planar laser); banner optional. + - **LinuxCNC** — explicit header, **or** RS274NGC **O-word** subroutines/flow (`o100 sub`). + - Extrusion detection tightened to `E` on a **motion line**, so LinuxCNC `M67 E0 Q…` analog laser power + is no longer mistaken for FDM. + + Real-file result: **0/6 → 3/6 detected, each with the correct machine class** (laser→laser, mill→mill, + LinuxCNC→linuxcnc); the misses are honest — files with a commented-out or absent spindle carry no + tool-state to infer. Still **experimental tier** (claims reported `inferred`) — detection working on + real files is one input; semantic ground truth (does our `Cut` = actual cutting, `S` scale) still wants + a real machine or a trusted reference. + +- [#253](https://github.com/ChestnutLabs/gcode-preview/pull/253) [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: non-extrusion dialect families + validation tiers (DD-012 phase 3, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Adds controller detection and the **validation-tier honesty mechanism** for CNC/laser toolpaths: + - New dialects (`@chestnutlabs/gcode-dialects`): **GRBL laser** (LightBurn / `$32` laser mode / `M4`+`S`), + **GRBL mill** and **LinuxCNC** (`M3` spindle, `%`/banner envelopes). Registered in the batteries worker. + - Each dialect adds provenance (`cnc.controller`, `cnc.machineClass`, `cnc.toolPowerLabel`) and a + **validation tier** (`cnc.validationTier`). Per DD-012 D6: an **experimental** dialect reports its + non-extrusion claims (`cutMoves` / `toolPower` / `cannedCycles`) as **`inferred`** (never `known`), + with a `cnc-dialect-experimental` disclosure — and only for claims the file actually made (an unused + feature is never fabricated). + - **All launch dialects ship `experimental`** (synthetic fixtures only). A single `tier: 'validated'` + flip per controller promotes its claims to `known` once confirmed on real hardware (DD-012 §8/§15). + + Geometry is untouched (dialects only annotate/label). FDM detection is unaffected — CNC dialects do + not match FDM output. + +- [#262](https://github.com/ChestnutLabs/gcode-preview/pull/262) [`3e244ae`](https://github.com/ChestnutLabs/gcode-preview/commit/3e244aee86463f2a8c030b3793d3bb2dd462e3a9) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(dialects): promote grbl-laser experimental → validated on hardware evidence ([#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + First hardware-validation pass (DD-012 D8): a real GRBL/LightBurn diode-laser run — 6161 moves incl. + fill + offset-fill, full 0–1000 `S` power ramp — confirmed machine-class detection, the Cut-vs-rapid + split, and the `toolPower` channel against the physical cut (all claims ✓). `grbl-laser` is flipped + `experimental → validated`: for laser files its `cutMoves`/`toolPower` claims now report **`known`** + instead of `inferred`, and the `cnc-dialect-experimental` warning is no longer emitted. + + Scope is per-controller: `grbl-mill` and `linuxcnc` remain `experimental` (claims stay `inferred`) + until a run on real CNC hardware. Evidence recorded in `docs/design/DD-012-hardware-validation-log.md`. + +### Patch Changes + +- Updated dependencies [[`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09)]: + - @chestnutlabs/toolpath-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-dialects/package.json b/packages/gcode-dialects/package.json index 19c3df00..1955fda4 100644 --- a/packages/gcode-dialects/package.json +++ b/packages/gcode-dialects/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-dialects", - "version": "0.3.0", + "version": "0.4.0", "description": "Slicer/firmware dialect adapters for ToolpathIR annotation (DD-005). Adapters annotate metadata and optional channels — they can never alter geometry.", "keywords": [ "gcode", @@ -45,6 +45,6 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0" } } diff --git a/packages/gcode-dialects/src/__tests__/cnc.test.ts b/packages/gcode-dialects/src/__tests__/cnc.test.ts new file mode 100644 index 00000000..71851393 --- /dev/null +++ b/packages/gcode-dialects/src/__tests__/cnc.test.ts @@ -0,0 +1,127 @@ +/** + * Non-extrusion dialect families (DD-012 phase 3, #189): GRBL-laser / GRBL-mill / LinuxCNC. + * Detection + the validation-tier honesty mechanism. GRBL-laser is hardware-VALIDATED (2026-07-29, + * DD-012 §16 log) → its claims report `known`; GRBL-mill and LinuxCNC remain EXPERIMENTAL → their + * claims are downgraded to `inferred` until a real CNC run. The flip is per-controller (evidence in + * docs/design/DD-012-hardware-validation-log.md). Fixtures are synthetic (MIT-clean fingerprints, + * not copied third-party files). + */ +import { describe, expect, it } from 'vitest'; +import { parseGcodeToIR, type ParseOptions } from '../../../gcode-parser/src/parse'; +import { createDialectRunner } from '../registry'; +import { grblLaser, grblMill, linuxCnc } from '../cnc'; + +/** Parse text through the CNC dialect set exactly like the worker does. */ +function cncParse(text: string, opts: ParseOptions = {}) { + const runner = createDialectRunner([grblLaser(), grblMill(), linuxCnc()]); + const run = runner.createRun({ + selection: 'auto', + headText: text.slice(0, 64 * 1024), + tailText: text.slice(-16 * 1024) + }); + const result = parseGcodeToIR(text, { ...opts, onComment: run?.onComment, onCommand: run?.onCommand }); + const out = run ? run.finalize(result.ir) : { metadata: {} as Record }; + return { ir: result.ir, metadata: out.metadata, detected: run !== null }; +} + +const LASER = + ['; LightBurn 1.4', 'G21', 'G90', 'M4 S0', 'G0 X0 Y0', 'G1 X10 Y0 S255 F600', 'G1 X10 Y10 S255', 'M5'].join('\n') + + '\n'; + +const LINUXCNC = + [ + '%', + '(LinuxCNC milling program)', + 'G21 G90 G94', + 'M3 S12000', + 'G0 X0 Y0 Z5', + 'G1 Z-2 F100', + 'G1 X20 Y0', + 'G81 X30 Y0 Z-5 R2', + 'G80', + 'M5' + ].join('\n') + '\n'; + +const GRBLMILL = + ['Grbl 1.1', 'G21 G90', 'M3 S10000', 'G0 X0 Y0 Z5', 'G1 Z-1 F100', 'G1 X10 Y0', 'M5'].join('\n') + '\n'; + +describe('DD-012 phase 3 — non-extrusion dialects (#189)', () => { + it('detects GRBL laser (LightBurn), hardware-VALIDATED → claims known, no experimental warning', () => { + const { ir, metadata } = cncParse(LASER, { modalChannels: ['toolPower'] }); + expect(ir.header.dialects.some((d) => d.id === 'grbl-laser')).toBe(true); + const raw = metadata.raw as Record; + expect(raw['cnc.machineClass']).toBe('laser'); + expect(raw['cnc.validationTier']).toBe('validated'); + expect(raw['cnc.toolPowerLabel']).toMatch(/laser/i); + // Validated (2026-07-29 real GRBL/LightBurn run, DD-012 D8): no downgrade — the claims the file + // actually makes stay `known`, and the experimental disclosure is not emitted. + expect(ir.header.capabilities.cutMoves).toBe('known'); + expect(ir.header.capabilities.toolPower).toBe('known'); + expect(ir.header.warnings.some((w) => w.code === 'cnc-dialect-experimental')).toBe(false); + }); + + it('detects LinuxCNC mill and downgrades the canned-cycle claim to inferred', () => { + const { ir, metadata } = cncParse(LINUXCNC); + expect(ir.header.dialects.some((d) => d.id === 'linuxcnc')).toBe(true); + expect((metadata.raw as Record)['cnc.machineClass']).toBe('mill'); + expect(ir.header.capabilities.cannedCycles).toBe('inferred'); // was 'known' from the parser + expect(ir.header.capabilities.cutMoves).toBe('inferred'); + }); + + it('detects GRBL mill (Grbl banner + M3 spindle) — still EXPERIMENTAL (claims inferred)', () => { + const { ir, metadata } = cncParse(GRBLMILL); + const raw = metadata.raw as Record; + expect(ir.header.dialects.some((d) => d.id === 'grbl-mill')).toBe(true); + expect(raw['cnc.machineClass']).toBe('mill'); + expect(raw['cnc.toolPowerLabel']).toMatch(/RPM/i); + // The laser flip is per-controller: mill/linuxcnc are NOT yet hardware-validated, so they still + // downgrade to `inferred` with the experimental disclosure. + expect(raw['cnc.validationTier']).toBe('experimental'); + expect(ir.header.capabilities.cutMoves).toBe('inferred'); + expect(ir.header.warnings.some((w) => w.code === 'cnc-dialect-experimental')).toBe(true); + }); + + it('does not misdetect an FDM file as CNC', () => { + const { detected } = cncParse('M82\nG1 X0 Y0 Z0.2 E0\nG1 X10 E5\nG1 X20 E10\n'); + expect(detected).toBe(false); + }); + + it('a validated dialect still never fabricates a claim for an unused feature', () => { + // grbl-laser is validated, but the tier only governs how a *made* claim is reported — it never + // invents one. A laser file that never engages the tool has no cut moves to claim. + const { ir } = cncParse('; LightBurn\nG90\nG0 X0 Y0\nG1 X10 Y0\n'); // laser header but tool never engaged + expect(ir.header.capabilities.cutMoves).toBe('unavailable'); // no M3/M4 → stays unavailable, not fabricated + }); +}); + +// Evidence-based detection distilled from real public samples (LaserGRBL, LinuxCNC, GRBL CAM output). +// Fixtures are MIT-clean re-creations of the real *fingerprints*, not copied third-party files. +describe('DD-012 phase 3 — evidence-based detection of header-less real files (#189)', () => { + it('no-header GRBL laser: M3 + S power, planar (the raw LaserGRBL form)', () => { + const { ir, metadata } = cncParse('M3 S0\nG1 X100 F1200 S1000\nS0\nM5\n'); + expect(ir.header.dialects.some((d) => d.id === 'grbl-laser')).toBe(true); + expect((metadata.raw as Record)['cnc.machineClass']).toBe('laser'); + }); + + it('mill from mid-line spindle + Z-plunge, no header, concatenated words (g1z-.1, s3400 m3)', () => { + const { ir, metadata } = cncParse('g20 g64\ns3400 m3\ng0z1\ng1z-.1f24\ng1x10y0\nm5\n'); + expect(ir.header.dialects.some((d) => d.id === 'grbl-mill')).toBe(true); + expect((metadata.raw as Record)['cnc.machineClass']).toBe('mill'); + }); + + it('LinuxCNC from an O-word subroutine (RS274NGC)', () => { + const { ir } = cncParse('o100 sub\ng1 x10 f100\no100 endsub\no100 call\n'); + expect(ir.header.dialects.some((d) => d.id === 'linuxcnc')).toBe(true); + }); + + it('LinuxCNC M67 analog laser power is NOT mistaken for FDM extrusion', () => { + const { detected } = cncParse('M3\nG1 X10 F600 S255\nM67 E0 Q128\nG1 X20\nM5\n'); + expect(detected).toBe(true); // recognized as non-extrusion, not rejected as FDM by the bare `E` + }); + + it('a commented-out spindle (`(M3)`) is not treated as tool-on', () => { + // TinyG posts sometimes note `(M3)` in a comment with no active spindle — no tool-state to infer. + const { detected } = cncParse('N1 T1M6\nN2 (M3)\nN3 G1 X10 Y0 F100\nN4 X20\n'); + expect(detected).toBe(false); // honest: no real spindle/laser command → not a confident CNC call + }); +}); diff --git a/packages/gcode-dialects/src/cnc.ts b/packages/gcode-dialects/src/cnc.ts new file mode 100644 index 00000000..adbb310f --- /dev/null +++ b/packages/gcode-dialects/src/cnc.ts @@ -0,0 +1,195 @@ +/** + * Non-extrusion dialect families (DD-012 phase 3, #189): CNC mill / laser / plotter controllers. + * + * These adapters DETECT the controller and declare a **validation tier** (DD-012 D6). They do not + * touch geometry — the parser already classifies `Cut`/`Travel`, the `toolPower` channel, and + * canned-cycle geometry. A dialect adds provenance (controller, machine class, tool-power label) and, + * critically, the honesty tier: + * + * - **validated** — confirmed on real hardware → non-extrusion claims stay `known`. + * - **experimental** — recognized but only spec/synthetic-tested → claims downgraded to `inferred` + * with a disclosure warning. + * + * Every launch dialect ships **experimental** (synthetic fixtures only). Flip `tier` to `'validated'` + * for a given controller once its classification is confirmed on real hardware (DD-012 §8/§15) — the + * one-line change marked at each dialect below. + */ +import type { Confidence, ToolpathIR } from '@chestnutlabs/toolpath-core'; +import type { AnnotationSink, DetectInput, DialectAdapter } from './contracts.js'; + +export type ValidationTier = 'validated' | 'experimental'; +export type MachineClass = 'laser' | 'mill' | 'plotter'; + +/** Non-extrusion capability claims whose confidence the validation tier governs (DD-012 D6). */ +const NON_EXTRUSION_CAPS = ['cutMoves', 'toolPower', 'cannedCycles'] as const; + +/** + * Evidence markers scored from the head text (DD-012 phase 3, #189). Real controller output usually + * carries NO generator header (a raw LaserGRBL/CAM job is just motion + M-codes), so process detection + * scores behavioral markers, not just banners. Extrusion evidence short-circuits everything — an FDM + * file is never a CNC/laser candidate. Comment bodies are stripped first so `(M3)` in a comment or a + * `; LinuxCNC` note does not create a false marker (body-vs-metadata separation, per the corpus brief). + */ +interface Evidence { + extrusion: boolean; // E on a motion line, or M82/M83 — an FDM signal + m3: boolean; // spindle/laser on, constant (or generic tool-on) + m4: boolean; // spindle CCW / dynamic-power laser + power: boolean; // an S value on a motion line or a bare S line + plunge: boolean; // a move to negative Z — cutting into material (a mill signal, not a laser one) + lightBurn: boolean; + laserMode: boolean; // GRBL `$32=1` + linuxCnc: boolean; // explicit LinuxCNC/EMC header + grblBanner: boolean; // a `Grbl` startup banner + oWord: boolean; // RS274NGC O-word subroutine/flow (LinuxCNC family) +} + +function scoreEvidence(head: string): Evidence { + // Strip line comments (';…' and LinuxCNC/RS274 '(…)') so words inside them never score as markers. + const code = head + .split('\n') + .map((l) => l.split(';')[0].replace(/\([^)]*\)/g, '')) + .join('\n'); + const has = (re: RegExp): boolean => re.test(code); + // G-code words run together without spaces (`G1M3`, `g1z-.1`), so a LEADING word-boundary fails. + // Match the command by its number + a trailing NON-digit lookahead: `M0*3(?!\d)` matches `M3`/`M03` + // (incl. mid-line `s3400 m3` and concatenated `G1M3`) but not `M30`/`M300`. + return { + // Extrusion = an `E` value on a G0–G3 MOTION line — NOT bare `E` (LinuxCNC laser uses `M67 E0 Q…` + // for analog power, which must not be mistaken for FDM extrusion). + extrusion: has(/G0*[0-3](?!\d)[^\n;]*E-?[.\d]/i) || has(/M8[23](?!\d)/i), + m3: has(/M0*3(?!\d)/i), + m4: has(/M0*4(?!\d)/i), + power: has(/S\d/i), + plunge: has(/Z\s*-\s*[.\d]/i), // negative Z incl. leading-dot decimals (`Z-.1`) + lightBurn: /LightBurn/i.test(head), // header-only marker; keep the raw head (comments allowed) + laserMode: /\$32\s*=\s*1/.test(code), + linuxCnc: /\b(?:LinuxCNC|EMC2?)\b/i.test(head), + grblBanner: /\bGrbl\s*[0-9]/i.test(head), + oWord: has(/[oO][\s<]*\w*\s*(?:sub|call|if|while|do|repeat|return|endsub)\b/im) + }; +} + +interface CncSpec { + id: string; + displayName: string; + machineClass: MachineClass; + /** Presentation label for the `toolPower` (S) channel — "power" vs "RPM" is a label, not a channel (D4). */ + toolPowerLabel: string; + tier: ValidationTier; + detect: (input: DetectInput) => { evidence: string; confidence: Confidence } | null; +} + +function makeCncDialect(spec: CncSpec): DialectAdapter { + return { + id: spec.id, + displayName: spec.displayName, + kind: 'firmware', + detect(input) { + const hit = spec.detect(input); + return hit === null + ? null + : { dialectId: spec.id, kind: 'firmware', confidence: hit.confidence, evidence: hit.evidence }; + }, + finalize(ir: ToolpathIR, sink: AnnotationSink) { + sink.setRaw('cnc.controller', spec.id); + sink.setRaw('cnc.machineClass', spec.machineClass); + sink.setRaw('cnc.validationTier', spec.tier); + sink.setRaw('cnc.toolPowerLabel', spec.toolPowerLabel); + // Validation tier (DD-012 D6): until hardware-validated, non-extrusion claims are `inferred`, + // never `known`. Only downgrade claims the file actually made (present as 'known') — never + // fabricate a claim for a feature the file did not use. + if (spec.tier === 'experimental') { + let downgraded = false; + for (const cap of NON_EXTRUSION_CAPS) { + if (ir.header.capabilities[cap] === 'known') { + sink.upgradeCapability(cap, 'inferred'); + downgraded = true; + } + } + if (downgraded) { + sink.warn( + 'cnc-dialect-experimental', + `${spec.displayName} support is EXPERIMENTAL (spec-derived, not yet hardware-validated); ` + + `non-extrusion classification is reported as 'inferred'.` + ); + } + } + } + }; +} + +/** GRBL diode/CO₂ laser (LightBurn and GRBL-laser post-processors). */ +export function grblLaser(): DialectAdapter { + return makeCncDialect({ + id: 'grbl-laser', + displayName: 'GRBL laser (LightBurn)', + machineClass: 'laser', + toolPowerLabel: 'laser power (S)', + // Hardware-validated 2026-07-29 (DD-012 D8; see docs/design/DD-012-hardware-validation-log.md): + // a real GRBL/LightBurn laser run — 6161 moves incl. fill + offset-fill, full 0–1000 S power ramp — + // confirmed machine-class detection, Cut-vs-rapid classification, and the toolPower channel against + // the physical cut. Claims report `known`, not `inferred`. (Mill/LinuxCNC remain experimental.) + tier: 'validated', + detect(input) { + const e = scoreEvidence(input.headText); + if (e.extrusion || e.linuxCnc) return null; + if (e.lightBurn) return { evidence: 'LightBurn header', confidence: 'known' }; + if (e.laserMode) return { evidence: 'GRBL $32=1 (laser mode)', confidence: 'inferred' }; + // No-header GRBL laser (the common LaserGRBL case): a tool-on command with power, and NO Z + // plunging — lasers are planar; a mill would cut down into Z. Works whether the file uses M3 + // (constant power) or M4 (dynamic). + if ((e.m3 || e.m4) && e.power && !e.plunge) { + return { evidence: `${e.m4 ? 'M4 dynamic' : 'M3'} + S power, planar (no Z plunge)`, confidence: 'inferred' }; + } + return null; + } + }); +} + +/** GRBL CNC milling (constant-power spindle). */ +export function grblMill(): DialectAdapter { + return makeCncDialect({ + id: 'grbl-mill', + displayName: 'GRBL mill', + machineClass: 'mill', + toolPowerLabel: 'spindle RPM (S)', + tier: 'experimental', // → 'validated' after a real GRBL-mill run (DD-012 §8) + detect(input) { + const e = scoreEvidence(input.headText); + if (e.extrusion || e.lightBurn || e.laserMode || e.linuxCnc) return null; + // A spindle (M3) that plunges into the material (negative Z) with no extrusion — a milling + // fingerprint that distinguishes it from a planar laser. GRBL/family-level, so a banner is a + // stronger signal but not required (most CAM output has no banner). + if (e.m3 && e.plunge) { + const ev = e.grblBanner ? 'Grbl banner + M3 + Z-plunge' : 'M3 spindle + Z-plunge, no extrusion'; + return { evidence: ev, confidence: 'inferred' }; + } + return null; + } + }); +} + +/** LinuxCNC / EMC milling. */ +export function linuxCnc(): DialectAdapter { + return makeCncDialect({ + id: 'linuxcnc', + displayName: 'LinuxCNC mill', + machineClass: 'mill', + toolPowerLabel: 'spindle RPM (S)', + tier: 'experimental', // → 'validated' after a real LinuxCNC run (DD-012 §8) + detect(input) { + const e = scoreEvidence(input.headText); + if (e.extrusion) return null; + if (e.linuxCnc) return { evidence: 'LinuxCNC/EMC header', confidence: 'known' }; + // RS274NGC O-word subroutines / flow control (`O100 sub`, `o call`) are the LinuxCNC + // family's distinguishing dialect feature — GRBL/TinyG do not have them. + if (e.oWord) return { evidence: 'RS274NGC O-word subroutine/flow', confidence: 'inferred' }; + // A `%` program envelope with a spindle and no extrusion (also common in Fanuc-style output; + // reported family-level, not as a specific controller). + if (/^\s*%/m.test(input.headText) && e.m3) { + return { evidence: '%-program envelope + M3 spindle, no extrusion', confidence: 'inferred' }; + } + return null; + } + }); +} diff --git a/packages/gcode-dialects/src/index.ts b/packages/gcode-dialects/src/index.ts index 02448294..51bee731 100644 --- a/packages/gcode-dialects/src/index.ts +++ b/packages/gcode-dialects/src/index.ts @@ -15,6 +15,8 @@ export { prusaSlicer } from './prusaslicer.js'; export { orcaBambu } from './orca-bambu.js'; export { cura } from './cura.js'; export { klipper, marlin, repRap } from './firmware.js'; +export { grblLaser, grblMill, linuxCnc } from './cnc.js'; +export type { ValidationTier, MachineClass } from './cnc.js'; export { segAtOrAfterByte, applyMarkerRanges, diff --git a/packages/gcode-parser/CHANGELOG.md b/packages/gcode-parser/CHANGELOG.md index a2df3f5a..0880b56a 100644 --- a/packages/gcode-parser/CHANGELOG.md +++ b/packages/gcode-parser/CHANGELOG.md @@ -1,5 +1,131 @@ # @chestnutlabs/gcode-parser +## 0.4.0 + +### Minor Changes + +- [#252](https://github.com/ChestnutLabs/gcode-preview/pull/252) [`b2053be`](https://github.com/ChestnutLabs/gcode-preview/commit/b2053be4b8e71250bc6077f60ef996fe601b6f3e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: canned drilling cycle expansion — G81/G82/G83 (DD-012 phase 2, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + CNC canned drilling cycles previously produced **zero geometry** — holes vanished. They now expand to + explicit sub-moves so the drilling is real, classified toolpath: + - **G81/G82** (drill / drill-with-dwell): rapid to the hole XY, rapid down to the R plane, **feed to + depth (`Cut`)**, rapid retract. + - **G83** (peck): the plunge is a peck loop — feed down by `Q`, rapid-retract to R between pecks, until + reaching depth; each down-feed is a `Cut`. + - **G98/G99** set the retract plane (initial Z vs R); **G80** cancels; a `G0`–`G3` motion also cancels. + - **Modal repeat**: with a cycle active, a bare `X`/`Y` line drills another hole (retaining Z/R/Q and + the initial plane) — the common CNC hole-pattern form. + - Rapids are `Travel`, plunges are `Cut`; new capability **`cannedCycles`** (`known` once a cycle is + seen, else `unavailable`). + + FDM output is unchanged (no canned cycles in FDM); the native-golden corpus gains only the additive + `cannedCycles` capability, with no geometry change. + +- [#253](https://github.com/ChestnutLabs/gcode-preview/pull/253) [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: non-extrusion dialect families + validation tiers (DD-012 phase 3, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Adds controller detection and the **validation-tier honesty mechanism** for CNC/laser toolpaths: + - New dialects (`@chestnutlabs/gcode-dialects`): **GRBL laser** (LightBurn / `$32` laser mode / `M4`+`S`), + **GRBL mill** and **LinuxCNC** (`M3` spindle, `%`/banner envelopes). Registered in the batteries worker. + - Each dialect adds provenance (`cnc.controller`, `cnc.machineClass`, `cnc.toolPowerLabel`) and a + **validation tier** (`cnc.validationTier`). Per DD-012 D6: an **experimental** dialect reports its + non-extrusion claims (`cutMoves` / `toolPower` / `cannedCycles`) as **`inferred`** (never `known`), + with a `cnc-dialect-experimental` disclosure — and only for claims the file actually made (an unused + feature is never fabricated). + - **All launch dialects ship `experimental`** (synthetic fixtures only). A single `tier: 'validated'` + flip per controller promotes its claims to `known` once confirmed on real hardware (DD-012 §8/§15). + + Geometry is untouched (dialects only annotate/label). FDM detection is unaffected — CNC dialects do + not match FDM output. + +- [#248](https://github.com/ChestnutLabs/gcode-preview/pull/248) [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: non-extrusion `Cut` move classification + tool-state modal (DD-012 phase 1, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Non-extrusion toolpaths (CNC / laser / plotter) no longer collapse their productive moves into + `Travel`. The parser now tracks a tool-engaged modal state — `M3`/`M4` (spindle/laser on, incl. the + `M03`/`M04` leading-zero form) engage it, `M5` disengages — and classifies a move with **no extrusion + `E`** while the tool is engaged as the new **`MoveKind.Cut`** bit (a CNC/laser/plotter counterpart to + `Extrude`, composing with `ArcSegment` like the other kinds). + - New IR move kind `MoveKind.Cut = 1 << 7` (`@chestnutlabs/toolpath-core`). + - New capability **`cutMoves`**: `known` once a tool-state modal is seen (a CNC/laser/plotter file), + `unavailable` for FDM. + - **FDM is byte-identical**: FDM slices never issue `M3`/`M4`, so `Cut` is never set and every move + stays `Extrude`/`Travel` exactly as before (verified against the native-golden corpus; the CNC + fixtures `demo-easel`/`demo-mach3` are documented intentional adapter-divergences). + + Modal tool-state _value_ channels (laser power / spindle RPM via `S`), canned-cycle expansion, and + dialect families follow in later DD-012 phases. + +- [#251](https://github.com/ChestnutLabs/gcode-preview/pull/251) [`11f317d`](https://github.com/ChestnutLabs/gcode-preview/commit/11f317de2d6cb963d2a7fb0c894c89d3d5adc86d) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: modal motion continuation — bare coordinate lines repeat the last G0–G3 (DD-012 phase 2, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + CNC/LinuxCNC-style G-code frequently omits the `G` word on repeated moves (`G1 X0 Y0` then bare + `X10 Y0` / `X20 Y0`). The parser previously **dropped** those lines entirely — a three-move path + produced a single segment. It now tracks the active `G0`–`G3` motion mode and treats a line whose + leading word is a coordinate axis (`X`/`Y`/`Z`, with no `G`/`M`/`T` command) as a continuation of + that mode, so the full toolpath is emitted and classified/colored consistently (incl. inline `S` + for `toolPower`). + + FDM output is **byte-identical** — slicers always emit the `G` word, so the continuation path never + triggers (the native-golden corpus is unchanged). This unblocks canned-cycle repeat (next phase). + +- [#250](https://github.com/ChestnutLabs/gcode-preview/pull/250) [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: opt-in modal tool-power channel (DD-012 phase 1 — the `ModalChannel` mechanism, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Adds the shared, opt-in **`ModalChannel`** mechanism DD-012 D3 is built around, and its first channel: + **`toolPower`** — the modal spindle/laser `S` value while a tool is engaged. + - `ParseOptions.modalChannels?: readonly string[]` — request per-segment modal channels by id. + Supported id: `'toolPower'`. Unknown ids are ignored with a `modal-channel-unsupported` warning. + - `ToolpathSegments.modal?: Readonly>` — one Float32 column per requested + channel, present **only** when requested. An unset value is `NaN` (an honest "no value here"), never + a fabricated `0`. `toolPower` is the modal `S` (set on `M3`/`M4` and inline on GRBL-laser motion + lines) while engaged, `NaN` when the tool is off (`M5`). + - New capability **`toolPower`**: surfaced only when the channel is requested — `known` once a + tool-state modal is seen, else `unavailable`. + - **Default parse pays nothing**: no `modalChannels` ⇒ no `modal` on the IR, no extra columns, FDM + output unchanged. The budget-aware SoA writer (DD-003) grows the opt-in columns in lockstep and + accounts their bytes. + + Presentation (Watts vs RPM) is a dialect label, not a separate channel (DD-012 D4); [#180](https://github.com/ChestnutLabs/gcode-preview/issues/180)'s + fan/temp/accel color channels reuse this same mechanism in a later phase. + +- [#256](https://github.com/ChestnutLabs/gcode-preview/pull/256) [`b84bea9`](https://github.com/ChestnutLabs/gcode-preview/commit/b84bea959b7aae24d148e6bcc488a9ed254a54f0) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: lexer handles multi-command lines, N-word line numbers, and bare S/F ([#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Real CNC/laser G-code (GRBL, LinuxCNC, TinyG, Mach3, Fanuc) is written very differently from FDM + slicer output, and the inherited first-word lexer silently dropped most of it. The lexer now: + - **Reads every G/M/T command word on a line**, not just the first — `G20 G17 G90`, `G91 G81 …`, + `S3400 M3` now all apply. This was the biggest gap: `M3` spindle-on and `G81` canned cycles were + being dropped as params, so mills showed no `Cut` moves and drilled holes vanished. + - **Strips `N`-word line numbers** (`N10 G1 X…`) — Fanuc/Mach/TinyG number every line, which + previously reduced whole files to zero geometry. + - **Latches bare `S` / `F` lines** (standalone `S1000` / `F600`) into modal power/feed — common in + GRBL-laser output. + - Guards against **letters embedded in extended-command words** (`EXCLUDE_OBJECT … POLYGON=…`): a + command/param is only taken when a real number follows the letter, so `T` in `M486 T` / + `M104 T` stays a parameter (not a tool select), and the `G` in `POLYGON` never becomes a move. + + Validated against real public sample files: a LinuxCNC arc-spiral went from 16 → 5,506 parsed + segments, a TinyG program from 0 → 344. **FDM output is byte-identical** — slicers emit one clean + command per line, so the multi-command path never runs for them (the real-G-code native-golden corpus + is unchanged except for fewer spurious `unsupported-command` warnings; `demo-mach3` and one adversarial + binary fixture are documented intentional divergences). + +### Patch Changes + +- [#260](https://github.com/ChestnutLabs/gcode-preview/pull/260) [`879b60a`](https://github.com/ChestnutLabs/gcode-preview/commit/879b60ae0fca87ca8187791603a1bc7f54e61c4c) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - fix: G0 rapids classify as Travel, not Cut, even while the tool is engaged ([#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + The non-extrusion `Cut`/`Travel` classifier keyed only on tool-state (`M3`/`M4` latched), so on a + router — where the spindle stays on across rapids — every `G0` reposition was counted as a cutting + move. DD-012 D2 §4.2 already specifies that rapids stay `Travel`; this brings the implementation in + line: only a **feed** move (`G1`/`G2`/`G3`) with the tool engaged and no `E` delta is `Cut`; a `G0` + rapid is `Travel` regardless of tool state (a GRBL-laser also gates the beam off during `G0`). + + Surfaced by the CNC/laser validation harness on real files — e.g. the `easel` router fixture went + from 742 cut / 0 rapids to a correct 737 cut / 5 rapids (its 5 `G0` moves). Geometry is unchanged + (only the `kind` column shifts); FDM output is byte-identical since `Cut` is never evaluated there. + +- Updated dependencies [[`3f06e5b`](https://github.com/ChestnutLabs/gcode-preview/commit/3f06e5b7b6926daaad4290b29c577a380c9e10df), [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09), [`3e244ae`](https://github.com/ChestnutLabs/gcode-preview/commit/3e244aee86463f2a8c030b3793d3bb2dd462e3a9)]: + - @chestnutlabs/gcode-dialects@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + - @chestnutlabs/gcode-bgcode@0.4.0 + - @chestnutlabs/gcode-containers@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-parser/package.json b/packages/gcode-parser/package.json index f053c7c0..453c24ef 100644 --- a/packages/gcode-parser/package.json +++ b/packages/gcode-parser/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-parser", - "version": "0.3.0", + "version": "0.4.0", "description": "Worker-safe G-code parse core producing ToolpathIR (DD-003).", "keywords": [ "gcode", @@ -49,9 +49,9 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/toolpath-core": "0.3.0", - "@chestnutlabs/gcode-dialects": "0.3.0", - "@chestnutlabs/gcode-containers": "0.3.0", - "@chestnutlabs/gcode-bgcode": "0.3.0" + "@chestnutlabs/toolpath-core": "0.4.0", + "@chestnutlabs/gcode-dialects": "0.4.0", + "@chestnutlabs/gcode-containers": "0.4.0", + "@chestnutlabs/gcode-bgcode": "0.4.0" } } diff --git a/packages/gcode-parser/src/__tests__/golden-equivalence.test.ts b/packages/gcode-parser/src/__tests__/golden-equivalence.test.ts index 3751294c..6f044c60 100644 --- a/packages/gcode-parser/src/__tests__/golden-equivalence.test.ts +++ b/packages/gcode-parser/src/__tests__/golden-equivalence.test.ts @@ -42,8 +42,19 @@ const equivalenceFixtures = manifest.fixtures.filter((f) => f.sizeTier !== 'adve * the logical frame; the native core honors that (position stays in the authored logical range and no * fabricated move is drawn across the probe), where the inherited engine ignored both. These fixtures * are excluded from strict adapter-position equivalence and pinned instead by their native goldens. + * + * DD-012 (#189): demo-easel is a CNC file (`M3 S12000` … `M5`). The native core now classifies its + * spindle-engaged no-E moves as `MoveKind.Cut` — a documented semantic correction — where the + * inherited adapter (which knew only Extrude|Travel) called them Travel. Positions are unchanged; only + * the masked-kind digest diverges, so it too is excluded here and pinned by its native golden. */ -const INTENTIONAL_MOTION_CORRECTION = new Set(['demo-mach3']); +// container-adv-traversal-names is an ADVERSARIAL BINARY container (a malicious ZIP) — never real +// G-code. The #189 multi-command lexer (which reads every G/M word on a line, not just the first) +// finds a few coincidental `G` byte patterns in the ZIP bytes, so a raw-text parse of it yields +// a handful of bounded segments where the inherited first-word lexer yielded zero. Bounded and +// harmless (the real path decodes the container first); excluded from strict adapter-equivalence and +// pinned by its native golden, like the CNC motion corrections above. +const INTENTIONAL_MOTION_CORRECTION = new Set(['demo-mach3', 'demo-easel', 'container-adv-traversal-names']); function fnv1a(view: ArrayBufferView): string { const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); diff --git a/packages/gcode-parser/src/__tests__/lexer-multicommand.test.ts b/packages/gcode-parser/src/__tests__/lexer-multicommand.test.ts new file mode 100644 index 00000000..0d8459bc --- /dev/null +++ b/packages/gcode-parser/src/__tests__/lexer-multicommand.test.ts @@ -0,0 +1,59 @@ +/* + * Multi-command lines, N-word line numbers, and bare S/F words (#189 lexer). Real CNC/laser G-code + * (GRBL, LinuxCNC, TinyG, Mach3, Fanuc) routinely puts several commands on one line, prefixes lines + * with N line numbers, and sets power/feed on bare `S`/`F` lines — none of which the FDM-shaped + * first-word lexer handled. FDM output is unchanged (slicers emit one clean command per line). + */ +import { describe, expect, it } from 'vitest'; +import { MoveKind } from '@chestnutlabs/toolpath-core'; +import { parseGcodeToIR } from '../parse.js'; + +const cut = (k: number): boolean => (k & MoveKind.Cut) !== 0; + +describe('#189 lexer — multi-command / line numbers / bare words', () => { + it('multiple G commands on one line all apply (G21 G90 G1 …)', () => { + const { ir } = parseGcodeToIR('G21 G90 G1 X10 Y0 F100\n', {}); + expect(ir.segments.count).toBe(1); + expect(ir.segments.x1[0]).toBeCloseTo(10); + }); + + it('a spindle command sharing a line (S3400 M3) is not lost → tool engaged, Cut', () => { + const { ir } = parseGcodeToIR('S3400 M3\nG1 X10 Y0 F100\nG1 X10 Y10\n', { modalChannels: ['toolPower'] }); + expect(ir.header.capabilities.cutMoves).toBe('known'); + expect(cut(ir.segments.kind[0])).toBe(true); + expect(ir.segments.modal!.toolPower[0]).toBe(3400); // S captured from the shared line + }); + + it('N-word line numbers are stripped (Fanuc/Mach/TinyG)', () => { + const { ir } = parseGcodeToIR('N10 G1 X10 Y0 F100\nN20 X20 Y0\nN30 X30\n', {}); + expect(ir.segments.count).toBe(3); // all three moves parse; N is ignored, modal motion continues + expect(ir.segments.x1[2]).toBeCloseTo(30); + }); + + it('a bare S line updates the modal tool power', () => { + const { ir } = parseGcodeToIR('M3 S0\nS1000\nG1 X10 Y0 F100\n', { modalChannels: ['toolPower'] }); + expect(ir.segments.modal!.toolPower[0]).toBe(1000); // the cut takes the bare-S power, not M3 S0 + }); + + it('a bare F line sets the modal feed for the next move', () => { + const { ir } = parseGcodeToIR('G1 X0 Y0\nF250\nX10 Y0\n', {}); + expect(ir.segments.feedrate[ir.segments.count - 1]).toBeCloseTo(250); + }); + + it('M486 T does NOT change the tool (T is the M486 param, not a tool select)', () => { + const { ir } = parseGcodeToIR('M486 T2\nG1 X0 Y0 E0\nG1 X10 E1\n', {}); + for (let i = 0; i < ir.segments.count; i++) expect(ir.segments.tool[i]).toBe(0); + }); + + it('a LEADING T is a tool select (T1 M6)', () => { + const { ir } = parseGcodeToIR('T1 M6\nG1 X0 Y0 E0\nG1 X10 E1\n', {}); + expect(ir.segments.tool[ir.segments.count - 1]).toBe(1); + }); + + it('extended-command words with embedded letters emit no spurious motion', () => { + // The `G` in POLYGON / `T` in OBJECT must not become commands (no digit follows them). + const gc = 'G0 X0 Y0\nEXCLUDE_OBJECT_DEFINE NAME=c POLYGON=[[0,0],[9,9]]\nG1 X10 Y0\n'; + const { ir } = parseGcodeToIR(gc, {}); + expect(ir.segments.count).toBe(2); // only the G0 + G1; the EXCLUDE line produces no segment + }); +}); diff --git a/packages/gcode-parser/src/__tests__/non-extrusion.test.ts b/packages/gcode-parser/src/__tests__/non-extrusion.test.ts new file mode 100644 index 00000000..cf4f00c9 --- /dev/null +++ b/packages/gcode-parser/src/__tests__/non-extrusion.test.ts @@ -0,0 +1,203 @@ +/* + * Non-extrusion toolpath classification — DD-012 phase 1, #189. A FEED move (G1/G2/G3) with no + * extrusion E while a tool-state modal (M3/M4 spindle/laser on) holds is a Cut (productive); a rapid + * (G0) stays Travel even while engaged (refined #189); M5 disengages. FDM files (no M3/M4) are + * byte-identical — Cut is never set and cutMoves is 'unavailable'. + */ +import { describe, expect, it } from 'vitest'; +import { MoveKind } from '@chestnutlabs/toolpath-core'; +import { parseGcodeToIR } from '../parse.js'; + +const cut = (kind: number): boolean => (kind & MoveKind.Cut) !== 0; +const travel = (kind: number): boolean => (kind & MoveKind.Travel) !== 0; +const extrude = (kind: number): boolean => (kind & MoveKind.Extrude) !== 0; +const arc = (kind: number): boolean => (kind & MoveKind.ArcSegment) !== 0; + +describe('DD-012 phase 1 — non-extrusion Cut classification (#189)', () => { + it('laser: no-E moves under M4 are Cut, not Travel; cutMoves is known', () => { + const gc = ['G21', 'G90', 'M4 S255', 'G1 X10 Y0 F600', 'G1 X10 Y10'].join('\n') + '\n'; + const { ir, stats } = parseGcodeToIR(gc, {}); + expect(ir.header.capabilities.cutMoves).toBe('known'); + expect(cut(ir.segments.kind[0])).toBe(true); + expect(cut(ir.segments.kind[1])).toBe(true); + expect(travel(ir.segments.kind[0])).toBe(false); + expect(extrude(ir.segments.kind[0])).toBe(false); + // A cut move carries no extrusion — the FDM extrusion tally must not count it. + expect(stats.extrusionDistance).toBe(0); + }); + + it('rapid (G0) is Travel even while the tool is engaged; the G1 feed between rapids is Cut', () => { + // A router keeps its spindle on across rapids and a GRBL-laser gates the beam off during G0, so a + // rapid is a non-cutting traverse regardless of tool state (DD-012 D2, refined #189). Only the feed + // move is productive. Mirrors the real GRBL-laser fingerprint (G0 reposition → G1 burn → G0 away). + const gc = ['M4 S255', 'G0 X10 Y0', 'G1 X20 Y0 F600', 'G0 X30 Y0'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, {}); + expect(travel(ir.segments.kind[0])).toBe(true); // G0 reposition — engaged but not cutting + expect(cut(ir.segments.kind[0])).toBe(false); + expect(cut(ir.segments.kind[1])).toBe(true); // G1 feed — the actual burn + expect(travel(ir.segments.kind[2])).toBe(true); // G0 away — engaged but not cutting + // Modal-motion continuation keeps the rapid classification: a bare-coord line after G0 is a rapid. + const modal = parseGcodeToIR(['M3 S1000', 'G0 X0 Y0', 'X10 Y0', 'G1 X20 Y0', 'X30 Y0'].join('\n') + '\n', {}); + expect(travel(modal.ir.segments.kind[1])).toBe(true); // bare X/Y under G0 modal → still rapid + expect(cut(modal.ir.segments.kind[3])).toBe(true); // bare X/Y under G1 modal → cut + }); + + it('M5 disengages the tool: the next no-E move is Travel again', () => { + const gc = ['M4 S255', 'G1 X10 Y0', 'M5', 'G0 X0 Y0'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, {}); + expect(cut(ir.segments.kind[0])).toBe(true); // engaged → Cut + const last = ir.segments.count - 1; + expect(travel(ir.segments.kind[last])).toBe(true); // disengaged → Travel + expect(cut(ir.segments.kind[last])).toBe(false); + }); + + it('spindle arc: an engaged G2 is Cut composed with ArcSegment', () => { + const gc = ['M3 S1000', 'G1 X10 Y0', 'G2 X20 Y0 I5 J0'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, {}); + const arcSeg = [...ir.segments.kind].find((k) => arc(k)); + expect(arcSeg).toBeDefined(); + expect(cut(arcSeg as number)).toBe(true); + expect(arc(arcSeg as number)).toBe(true); + }); + + it('M03/M05 (leading-zero form) are recognized', () => { + const gc = ['M03 S1000', 'G1 X5 Y0', 'M05', 'G0 X0 Y0'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, {}); + expect(cut(ir.segments.kind[0])).toBe(true); + expect(travel(ir.segments.kind[ir.segments.count - 1])).toBe(true); + }); + + it('FDM regression: no tool-state ⇒ no Cut, cutMoves unavailable, moves are Extrude/Travel', () => { + const gc = ['M82', 'G1 X0 Y0 Z0.2 E0', 'G1 X10 E5', 'G1 X20', 'G0 X30'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, {}); + expect(ir.header.capabilities.cutMoves).toBe('unavailable'); + for (let i = 0; i < ir.segments.count; i++) { + expect(cut(ir.segments.kind[i])).toBe(false); + } + expect(extrude(ir.segments.kind[1])).toBe(true); // Δ5 extrude + expect(travel(ir.segments.kind[2])).toBe(true); // E-unchanged → Travel (unchanged behavior) + }); +}); + +describe('DD-012 phase 1 — opt-in modal tool-power channel (ModalChannel, #189)', () => { + it('toolPower: modal S is stamped per segment when requested (incl. inline S)', () => { + const gc = ['M4 S255', 'G1 X10 Y0', 'G1 X10 Y10 S128'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, { modalChannels: ['toolPower'] }); + expect(ir.header.capabilities.toolPower).toBe('known'); + const tp = ir.segments.modal?.toolPower; + expect(tp).toBeDefined(); + expect(tp![0]).toBe(255); // power from M4 S255 + expect(tp![1]).toBe(128); // inline G1 … S128 latches the modal register + }); + + it('toolPower is NaN while the tool is off (after M5)', () => { + const gc = ['M4 S255', 'G1 X10 Y0', 'M5', 'G0 X0 Y0'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, { modalChannels: ['toolPower'] }); + const tp = ir.segments.modal!.toolPower; + expect(tp[0]).toBe(255); + expect(Number.isNaN(tp[ir.segments.count - 1])).toBe(true); // disengaged → no power value + }); + + it('opt-out (default parse): no modal on the IR and no toolPower capability', () => { + const { ir } = parseGcodeToIR(['M4 S255', 'G1 X10 Y0'].join('\n') + '\n', {}); + expect(ir.segments.modal).toBeUndefined(); + expect(ir.header.capabilities.toolPower).toBeUndefined(); + }); + + it('FDM requesting toolPower: capability unavailable, column all NaN (never fabricated 0)', () => { + const gc = ['M82', 'G1 X0 Y0 E0', 'G1 X10 E5', 'G1 X20 E10'].join('\n') + '\n'; + const { ir } = parseGcodeToIR(gc, { modalChannels: ['toolPower'] }); + expect(ir.header.capabilities.toolPower).toBe('unavailable'); + const tp = ir.segments.modal!.toolPower; + for (let i = 0; i < ir.segments.count; i++) expect(Number.isNaN(tp[i])).toBe(true); + }); + + it('unknown modal channel is ignored with a warning', () => { + const { ir, stats } = parseGcodeToIR(['M4 S255', 'G1 X10 Y0'].join('\n') + '\n', { + modalChannels: ['bogus'] + }); + expect(ir.segments.modal).toBeUndefined(); + expect(stats.warningsByCode['modal-channel-unsupported']).toBeGreaterThan(0); + }); +}); + +describe('DD-012 phase 2 — modal motion continuation (#189)', () => { + it('a bare coordinate line repeats the last G1 motion (was dropped entirely)', () => { + const { ir } = parseGcodeToIR('G1 X0 Y0 F600\nX10 Y0\nX20 Y0\n', {}); + expect(ir.segments.count).toBe(3); // was 1 before modal-motion support + expect(ir.segments.x1[2]).toBeCloseTo(20); + }); + + it('bare coordinate line repeats G0 rapids too', () => { + const { ir } = parseGcodeToIR('G0 X0 Y0\nX5 Y0\nX10 Y0\n', {}); + expect(ir.segments.count).toBe(3); + expect(travel(ir.segments.kind[2])).toBe(true); + }); + + it('modal continuation preserves Cut classification when engaged', () => { + const { ir } = parseGcodeToIR('M4 S255\nG1 X0 Y0 F600\nX10 Y0\nX10 Y10\n', {}); + expect(ir.segments.count).toBe(3); + for (let i = 0; i < ir.segments.count; i++) expect(cut(ir.segments.kind[i])).toBe(true); + }); + + it('inline S on a modal-continuation line latches toolPower', () => { + const { ir } = parseGcodeToIR('M4 S255\nG1 X0 Y0\nX10 Y0 S100\n', { modalChannels: ['toolPower'] }); + const tp = ir.segments.modal!.toolPower; + expect(tp[0]).toBe(255); + expect(tp[1]).toBe(100); + }); + + it('inert before any motion mode: a leading coordinate line emits nothing', () => { + const { ir } = parseGcodeToIR('X10 Y0\nG1 X20 Y0\n', {}); + expect(ir.segments.count).toBe(1); // the pre-mode coordinate line is dropped; only the G1 emits + }); +}); + +describe('DD-012 phase 2 — canned drilling cycles (#189)', () => { + const minZ1 = (ir: { segments: { count: number; z1: Float32Array } }): number => { + let m = Infinity; + for (let i = 0; i < ir.segments.count; i++) m = Math.min(m, ir.segments.z1[i]); + return m; + }; + + it('G81 expands to rapid/plunge/retract, with a Cut plunge to depth', () => { + const { ir } = parseGcodeToIR('G0 X0 Y0 Z5\nG81 X10 Y10 Z-5 R2 F100\nG80\n', {}); + expect(ir.header.capabilities.cannedCycles).toBe('known'); + let sawCut = false; + for (let i = 0; i < ir.segments.count; i++) if (cut(ir.segments.kind[i])) sawCut = true; + expect(sawCut).toBe(true); + expect(minZ1(ir)).toBeCloseTo(-5); // the plunge reaches Z-5 + }); + + it('modal repeat: a bare X/Y line drills another hole (4 more sub-moves)', () => { + const one = parseGcodeToIR('G0 X0 Y0 Z5\nG81 X10 Y10 Z-5 R2 F100\nG80\n', {}).ir.segments.count; + const two = parseGcodeToIR('G0 X0 Y0 Z5\nG81 X10 Y10 Z-5 R2 F100\nX20 Y10\nG80\n', {}).ir.segments.count; + expect(two).toBe(one + 4); + }); + + it('retract plane: G98 → initial Z, G99 → R plane', () => { + const g98 = parseGcodeToIR('G0 X0 Y0 Z5\nG98\nG81 X10 Y10 Z-5 R2\nG80\n', {}).ir.segments; + const g99 = parseGcodeToIR('G0 X0 Y0 Z5\nG99\nG81 X10 Y10 Z-5 R2\nG80\n', {}).ir.segments; + expect(g98.z1[g98.count - 1]).toBeCloseTo(5); // retract to the initial plane + expect(g99.z1[g99.count - 1]).toBeCloseTo(2); // retract to the R plane + }); + + it('G83 peck drilling produces multiple Cut plunges reaching depth', () => { + const { ir } = parseGcodeToIR('G0 X0 Y0 Z5\nG83 X0 Y0 Z-6 R2 Q2 F100\nG80\n', {}); + let cutCount = 0; + for (let i = 0; i < ir.segments.count; i++) if (cut(ir.segments.kind[i])) cutCount++; + expect(cutCount).toBeGreaterThan(1); // one Cut per peck + expect(minZ1(ir)).toBeCloseTo(-6); + }); + + it('G80 cancels: a following bare coordinate line drills nothing', () => { + const active = parseGcodeToIR('G0 X0 Y0 Z5\nG81 X10 Y10 Z-5 R2\nX20 Y10\n', {}).ir.segments.count; + const cancelled = parseGcodeToIR('G0 X0 Y0 Z5\nG81 X10 Y10 Z-5 R2\nG80\nX20 Y10\n', {}).ir.segments.count; + expect(active).toBe(cancelled + 4); // the post-G80 line adds no hole + }); + + it('FDM regression: no canned cycles ⇒ cannedCycles unavailable', () => { + const { ir } = parseGcodeToIR('M82\nG1 X0 Y0 E0\nG1 X10 E5\n', {}); + expect(ir.header.capabilities.cannedCycles).toBe('unavailable'); + }); +}); diff --git a/packages/gcode-parser/src/growable.ts b/packages/gcode-parser/src/growable.ts index 567e837b..620fb530 100644 --- a/packages/gcode-parser/src/growable.ts +++ b/packages/gcode-parser/src/growable.ts @@ -42,7 +42,8 @@ const CHANNELS: ChannelSpec[] = [ { name: 'srcByte', bytesPerElement: 4 } ]; -const BYTES_PER_SEGMENT = CHANNELS.reduce((a, c) => a + c.bytesPerElement, 0); // 40 B core set +const CORE_BYTES_PER_SEGMENT = CHANNELS.reduce((a, c) => a + c.bytesPerElement, 0); // 40 B core set +const MODAL_BYTES_PER_ELEMENT = 4; // each opt-in modal channel is one Float32 column export interface SegmentRecord { x0: number; @@ -57,6 +58,9 @@ export interface SegmentRecord { tool: number; layer: number; srcByte: number; + /** Current value of each requested opt-in modal channel (DD-012 D3), keyed by channel id. + * Missing/undefined entries store NaN — an honest "no value here", never a fabricated 0. */ + modal?: Record; } export interface FinalChannels { @@ -75,6 +79,9 @@ export interface FinalChannels { feature: Uint8Array; object: Uint32Array; srcByte: Uint32Array; + /** Opt-in modal channels (DD-012 D3), present only when requested at construction. Each is a + * Float32 column of length `count`; an unset value is NaN, never a fabricated 0. */ + modal?: Record; } const INITIAL_CAPACITY = 4096; @@ -103,7 +110,19 @@ export class SegmentWriter { /** Extra live bytes the caller wants included in the budget (line buffer, warnings). */ private externalBytes = 0; - constructor(private readonly maxBufferBytes: number) { + /** Opt-in modal channel ids (DD-012 D3), stable order; empty for a default parse. */ + private readonly modalNames: readonly string[]; + /** Growable Float32 columns, parallel to `modalNames`. */ + private modalArrays: Float32Array[] = []; + /** Bytes per segment across the core set plus any requested modal columns. */ + private readonly bytesPerSegment: number; + + constructor( + private readonly maxBufferBytes: number, + modalChannels: readonly string[] = [] + ) { + this.modalNames = modalChannels; + this.bytesPerSegment = CORE_BYTES_PER_SEGMENT + MODAL_BYTES_PER_ELEMENT * modalChannels.length; this.allocate(INITIAL_CAPACITY); } @@ -129,7 +148,7 @@ export class SegmentWriter { private allocate(newCapacity: number): void { // During a grow/compact, old and new capacity coexist until copies complete. - const newBytes = newCapacity * BYTES_PER_SEGMENT; + const newBytes = newCapacity * this.bytesPerSegment; this.ensureBudget(newBytes); const nx0 = new Float32Array(newCapacity); @@ -146,6 +165,12 @@ export class SegmentWriter { const nfeature = new Uint8Array(newCapacity); const nobject = new Uint32Array(newCapacity); const nsrc = new Uint32Array(newCapacity); + // Opt-in modal columns grow in lockstep with the core set. + const nmodal = this.modalNames.map((_, k) => { + const a = new Float32Array(newCapacity); + if (this.capacity > 0) a.set(this.modalArrays[k].subarray(0, this._count)); + return a; + }); if (this.capacity > 0) { nx0.set(this.x0.subarray(0, this._count)); @@ -164,7 +189,8 @@ export class SegmentWriter { nsrc.set(this.srcByte.subarray(0, this._count)); } - const oldBytes = this.capacity * BYTES_PER_SEGMENT; + const oldBytes = this.capacity * this.bytesPerSegment; + this.modalArrays = nmodal; this.x0 = nx0; this.y0 = ny0; this.z0 = nz0; @@ -203,6 +229,10 @@ export class SegmentWriter { this.feature[i] = 0; this.object[i] = 0; this.srcByte[i] = s.srcByte; + for (let k = 0; k < this.modalNames.length; k++) { + const v = s.modal?.[this.modalNames[k]]; + this.modalArrays[k][i] = v === undefined ? NaN : v; + } this._count++; } @@ -216,6 +246,15 @@ export class SegmentWriter { this.layer.fill(0, 0, this._count); } + /** Build the optional `modal` output record (DD-012 D3), or `{}` when no channels were requested + * so a default parse yields no `modal` key at all. `view` slices/subarrays each column to count. */ + private modalOutput(view: (a: Float32Array) => Float32Array): { modal?: Record } { + if (this.modalNames.length === 0) return {}; + const modal: Record = {}; + for (let k = 0; k < this.modalNames.length; k++) modal[this.modalNames[k]] = view(this.modalArrays[k]); + return { modal }; + } + /** * Copy a segment range into fresh right-sized arrays (progressive-preview * snapshots, DD-004 §5.4 / issue #60). Budget-checked: the copies coexist with @@ -225,9 +264,10 @@ export class SegmentWriter { */ snapshotRange(start: number, end: number): FinalChannels { const count = Math.max(0, end - start); - this.ensureBudget(count * BYTES_PER_SEGMENT); + this.ensureBudget(count * this.bytesPerSegment); return { count, + ...this.modalOutput((a) => a.slice(start, end)), x0: this.x0.slice(start, end), y0: this.y0.slice(start, end), z0: this.z0.slice(start, end), @@ -252,6 +292,7 @@ export class SegmentWriter { } return { count: this._count, + ...this.modalOutput((a) => a.subarray(0, this._count)), x0: this.x0.subarray(0, this._count), y0: this.y0.subarray(0, this._count), z0: this.z0.subarray(0, this._count), diff --git a/packages/gcode-parser/src/parse.ts b/packages/gcode-parser/src/parse.ts index abc347bb..71fcf493 100644 --- a/packages/gcode-parser/src/parse.ts +++ b/packages/gcode-parser/src/parse.ts @@ -85,6 +85,14 @@ export interface ParseOptions { * that has confidently classified the firmware. The byte-exact engine never sniffs firmware itself. */ extruderFollowsPositioning?: boolean; + /** + * Opt-in modal channels to capture per segment (DD-012 D3, #189). Each requested id adds one + * Float32 column to the IR (`segments.modal[id]`); a default parse requests none and pays nothing, + * so FDM output is unchanged. Supported id: `'toolPower'` — the modal spindle/laser `S` value while + * a tool is engaged (`M3`/`M4`), NaN when the tool is off. Presentation (Watts vs RPM) is a dialect + * label, not a separate channel (DD-012 D4). Fan/temp/accel etc. (#180) reuse the same mechanism. + */ + modalChannels?: readonly string[]; /** * DD-005 §4.3 read-only hooks: observe comments/commands during the parse. * Inert when unset (one branch per line); they cannot alter lexing, dispatch, @@ -157,38 +165,69 @@ export interface AsyncParseResult extends ParseResult { const LAYER_TOLERANCE = 0.05; const UNRESOLVED_LAYER = 0xffffffff; +/** Opt-in modal channels this engine can capture (DD-012 D3, #189). Requested via + * `ParseOptions.modalChannels`; unknown ids are ignored with a warning. Extend as later phases + * (spindle/laser already covered by `toolPower`; #180's fan/temp/accel land here next). */ +const SUPPORTED_MODAL_CHANNELS = new Set(['toolPower']); + interface Cmd { - gcode: string; + /** All G/M/T command words on the line, in source order (real CNC posts multiple per line, e.g. + * `G20 G17 G90`, `G91 G81 …`, `S3400 M3`). FDM slicers emit exactly one → `codes.length === 1`. */ + codes: string[]; params: Record; } const SPLIT_LETTERS = /([a-zA-Z])/g; -/** Port of the inherited lexer: trim, comment split on ';', letter/value pairs. */ +/** + * Lex a line into its G/M/T command words plus the axis/param words. Real CNC G-code routinely puts + * several commands on one line and prefixes lines with `N` line numbers; both are handled here. + * `N`-words (line numbers) are dropped, and non-command letters become params. FDM lines have a single + * command word, so `codes = [thatCommand]` and behavior is byte-identical to the inherited lexer. + */ function lexLine(line: string): Cmd { - const input = line.trim(); - const cmd = input.split(';')[0]; - const parts = cmd + const body = line.trim().split(';')[0]; + const parts = body .split(SPLIT_LETTERS) .slice(1) .map((s) => s.trim()); - const gcode = !parts.length ? '' : `${parts[0]?.toLowerCase()}${Number(parts[1])}`; - // Faithful replication of the inherited parseParams reduce (odd indices are values): + const codes: string[] = []; const params: Record = {}; - const rest = parts.slice(2); - for (let idx = 0; idx < rest.length; idx++) { - if (idx % 2 === 0) continue; - const key = rest[idx - 1].toLowerCase(); - const code = key.charCodeAt(0); - if ((code >= 97 && code <= 122) || (code >= 65 && code <= 90)) { - params[key] = parseFloat(rest[idx]); + let sawCommand = false; + for (let i = 0; i + 1 < parts.length; i += 2) { + const key = parts[i].toLowerCase(); + const cc = key.charCodeAt(0); + if (cc < 97 || cc > 122) continue; // not a letter + const value = parts[i + 1]; + // A real G-code word is a letter followed by a NUMBER. This guard rejects letters embedded in + // extended-command words (Klipper `EXCLUDE_OBJECT … POLYGON=…`, `CENTER=`, `NAME=`) where a letter + // is followed by another letter or `=…` — the inherited lexer skipped these by only reading the + // first word; scanning every letter must not resurrect them as commands/params. + const num = value.trim() === '' ? NaN : Number(value); + if (key === 'g' || key === 'm') { + if (Number.isFinite(num)) { + codes.push(`${key}${num}`); // e.g. 'g1', 'm3' — leading zeros normalized + sawCommand = true; + } + } else if (key === 't' && !sawCommand) { + // `T` is a tool-select command only as the LEADING command word (`T1 M6`). After an M-code it is + // that code's parameter, NOT a tool change — e.g. Marlin `M486 T`, `M104 T S`. + if (Number.isFinite(num)) { + codes.push(`t${num}`); + sawCommand = true; + } + } else if (key === 'n') { + // Line number — not a command, not a motion word. Ignored (fixes Fanuc/Mach/TinyG files). + } else { + const p = parseFloat(value); + if (Number.isFinite(p)) params[key] = p; } } - return { gcode, params }; + return { codes, params }; } interface PathState { - type: 'extrusion' | 'travel'; + type: 'extrusion' | 'cut' | 'travel'; tool: number; segStart: number; startZ: number; @@ -256,7 +295,17 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En } }; - const writer = new SegmentWriter(limits.maxBufferBytes); + // Opt-in modal channels (DD-012 D3): dedupe, keep supported, warn (once) on any unknown id. + const requestedModal: string[] = []; + for (const id of opts.modalChannels ?? []) { + if (!SUPPORTED_MODAL_CHANNELS.has(id)) { + warn('modal-channel-unsupported', `Ignoring unsupported modal channel '${id}'.`); + } else if (!requestedModal.includes(id)) { + requestedModal.push(id); + } + } + const wantToolPower = requestedModal.includes('toolPower'); + const writer = new SegmentWriter(limits.maxBufferBytes, requestedModal); // Machine state (port of State.initial + Interpreter fields). let sx = 0; @@ -295,6 +344,24 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En let coordSystemSeen = false; // any G53/G54–G59/G92-XYZ/G10 → coordinateSystem 'known' let g53OneShot = false; // next move ignores the work offset (machine coordinates) + // Tool-state modal (DD-012 D2/D4, #189). Spindle/laser on (M3/M4) engages the tool; M5 disengages. + // A FEED move (G1/G2/G3) with no extrusion E while the tool is engaged is a Cut (productive); a + // rapid (G0) stays Travel even with the tool engaged (refined #189). FDM slices never issue M3/M4, + // so toolEngaged stays false and their classification is byte-identical. + let toolEngaged = false; + let toolStateSeen = false; // any M3/M4/M5 observed → cutMoves capability 'known' + let modalS = NaN; // current modal spindle/laser S value (power / RPM); NaN until first seen (#189) + let modalMotion: 'g0' | 'g1' | 'g2' | 'g3' | null = null; // last G0–G3 mode for modal-motion lines (#189) + // Canned drilling cycle state (DD-012 phase 2, #189). Z/R/Q/initial-plane are captured when the + // cycle is defined and retained across modal (bare X/Y) repeats until G80 or a G0–G3 cancels it. + let cannedCycle: 'g81' | 'g82' | 'g83' | null = null; + let cannedZ = 0; // final drill depth (absolute) + let cannedR = 0; // retract / reference plane (absolute Z) + let cannedQ = 0; // peck increment (G83) + let cannedInitialZ = 0; // Z when the cycle activated — the G98 retract plane + let cannedRetractInitial = true; // G98 (retract to initial Z) vs G99 (retract to R); modal, default G98 + let cannedCyclesSeen = false; // any G81/G82/G83 expanded → cannedCycles capability 'known' + // Per-axis position certainty (DD-010 D4 amendment, #158). A G31 probe endpoint is reached at // RUNTIME (workpiece contact), not the commanded value, so it marks the probed axes uncertain. A // following G92 then RESYNCS the logical frame at the datum (rather than datum-shifting a stale @@ -406,7 +473,10 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En kind, tool: path?.tool ?? tool, layer: UNRESOLVED_LAYER, - srcByte: currentSrcByte + srcByte: currentSrcByte, + // toolPower is the modal S while a tool is engaged; NaN when off (M5 / never engaged) — an + // honest "no cutting power here", not a fabricated 0. Only built when requested (DD-012 D3). + modal: wantToolPower ? { toolPower: toolEngaged ? modalS : NaN } : undefined }); } catch (err) { if (err instanceof BudgetExceededError) { @@ -481,7 +551,7 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En path = null; }; - const breakPath = (type: 'extrusion' | 'travel'): void => { + const breakPath = (type: 'extrusion' | 'cut' | 'travel'): void => { finishPath(); path = { type, @@ -497,7 +567,12 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En prevZ = sz; }; - const g0 = (p: Record): boolean => { + // Map a resolved path type to its MoveKind bit (DD-012 D2). `cut` only ever arises on a feed move + // when the tool is engaged and E is absent, so FDM (never `cut`) is byte-identical. + const kindForPath = (t: 'extrusion' | 'cut' | 'travel'): number => + t === 'extrusion' ? MoveKind.Extrude : t === 'cut' ? MoveKind.Cut : MoveKind.Travel; + + const g0 = (p: Record, rapid: boolean): boolean => { const { x, y, z, e, f } = p; // Classify on the true per-move delta, not the raw E word (DD-010 D1). For the M83/relative // corpus the delta equals the word, so output is byte-identical to the pre-E10 engine. @@ -521,7 +596,10 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En } stats.points++; if (f !== undefined) modalFeed = f; - const pathType = eDelta > 0 ? 'extrusion' : 'travel'; + // A rapid (G0) is a non-cutting positioning traverse — Travel even while the tool is engaged + // (a router keeps its spindle on during rapids; a GRBL-laser gates the beam off during G0). Only + // a FEED move (G1/G2/G3) with the tool engaged and no extrusion is a Cut (DD-012 D2, refined #189). + const pathType = eDelta > 0 ? 'extrusion' : toolEngaged && !rapid ? 'cut' : 'travel'; if (path === null || path.type !== pathType) { breakPath(pathType); } @@ -536,7 +614,7 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En if (z !== undefined) certainZ = true; } g53OneShot = false; // one-shot machine-coordinate bypass consumed by this move - return emitSegment(sx, sy, sz, eDelta, pathType === 'extrusion' ? MoveKind.Extrude : MoveKind.Travel); + return emitSegment(sx, sy, sz, eDelta, kindForPath(pathType)); }; const g2 = (p: Record, cw: boolean): boolean => { @@ -545,7 +623,7 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En if (f !== undefined) modalFeed = f; // E is delta-based (DD-010 D1) so M82 arcs classify correctly and `lastE` stays consistent with g0. const eDelta = resolveEDelta(e); - const pathType = eDelta ? 'extrusion' : 'travel'; + const pathType = eDelta ? 'extrusion' : toolEngaged ? 'cut' : 'travel'; if (path === null || path.type !== pathType) { breakPath(pathType); } @@ -607,7 +685,7 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En // where the through axis is unchanged so the ramp is flat). const cStep = (sc - tc) / totalSegments; - const kind = (pathType === 'extrusion' ? MoveKind.Extrude : MoveKind.Travel) | MoveKind.ArcSegment; + const kind = kindForPath(pathType) | MoveKind.ArcSegment; const eachE = e !== undefined ? eDelta / Math.max(1, Math.ceil(totalSegments)) : 0; // Map an in-plane point (pa,pb) + through-axis pc back to (x,y,z) for the active plane. @@ -631,6 +709,40 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En return emitArc(ta, tb, tc); }; + // Canned drilling cycle (DD-012 phase 2, #189): expand G81/G82/G83 into explicit sub-moves so the + // holes are real geometry (today they emit nothing). Rapids are Travel; the plunge(s) are Cut. Z/R/Q + // and the initial plane are captured when the cycle is defined and retained across modal repeats. + const runCannedCycle = (p: Record): boolean => { + // 1. Rapid to the hole XY at the current Z. + sx = nextAxis(sx, p.x, offX()); + sy = nextAxis(sy, p.y, offY()); + g53OneShot = false; + if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel)) return false; + // 2. Rapid Z down to the R (reference) plane. + sz = cannedR; + if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel)) return false; + // 3. Feed to depth — one plunge (G81/G82) or a peck loop (G83). Productive → Cut. + if (cannedCycle === 'g83' && cannedQ > 0 && cannedR > cannedZ) { + let depth = cannedR; + while (depth > cannedZ) { + depth = Math.max(cannedZ, depth - cannedQ); + sz = depth; + if (!emitSegment(sx, sy, sz, 0, MoveKind.Cut)) return false; // feed down one peck + if (depth > cannedZ) { + sz = cannedR; + if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel)) return false; // rapid retract to R between pecks + } + } + } else { + sz = cannedZ; + if (!emitSegment(sx, sy, sz, 0, MoveKind.Cut)) return false; // single plunge + } + // 4. Retract (rapid) to the initial plane (G98) or the R plane (G99). + sz = cannedRetractInitial ? cannedInitialZ : cannedR; + if (!emitSegment(sx, sy, sz, 0, MoveKind.Travel)) return false; + return true; + }; + const onComment = opts.onComment; const onCommand = opts.onCommand; @@ -648,185 +760,254 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En if (ci !== -1) onComment(rawLine.slice(ci + 1), offset); } const cmd = lexLine(rawLine); - if (cmd.gcode === '') return; - stats.commands++; - if (onCommand !== undefined) { - onCommand({ gcode: cmd.gcode, params: cmd.params, rawLine, srcByte: offset, segIndex: writer.count }); + // Skip only genuinely empty lines. A line with no lexable words but real content (a dialect + // extended command like `EXCLUDE_OBJECT_START NAME=cube`, a macro) must NOT be dropped — the + // observer below still forwards its raw line. The body split runs only when there are no words. + if (cmd.codes.length === 0 && Object.keys(cmd.params).length === 0 && rawLine.split(';')[0].trim() === '') { + return; // blank or comment-only line } + stats.commands++; + // Modal S/F latch from params on ANY line — including bare `S1000` / `F600` lines that carry no + // command word (common in GRBL-laser output). S is captured for toolPower only when requested. + if (wantToolPower && cmd.params.s !== undefined) modalS = cmd.params.s; + if (cmd.params.f !== undefined) modalFeed = cmd.params.f; let ok = true; - switch (cmd.gcode) { - case 'g0': - case 'g1': - ok = g0(cmd.params); - break; - case 'g2': - ok = g2(cmd.params, true); - break; - case 'g3': - ok = g2(cmd.params, false); - break; - case 'g20': - units = 'in'; - unitsSeen = true; - break; - case 'g21': - units = 'mm'; - unitsSeen = true; - break; - case 'g28': - sx = 0; - sy = 0; - sz = 0; - break; - case 't0': - case 't1': - case 't2': - case 't3': - case 't4': - case 't5': - case 't6': - case 't7': - tool = Number(cmd.gcode.slice(1)); - break; - case 'm600': - // Manual filament swap = color boundary (#147). A marker, not motion — do - // NOT break/finish the current path; record position (current head), the - // next segment index (slot boundary), and the active tool for provenance. - colorChangeEvents.push({ x: sx, y: sy, z: sz, segIndex: writer.count, srcByte: offset, tool }); - break; - // Motion-model modal commands (DD-010 E10 phase 1). - case 'g90': - xyzAbsolute = true; - positioningSeen = true; - break; - case 'g91': - xyzAbsolute = false; - positioningSeen = true; - break; - // Arc-plane selection (DD-010 D3, #157, E10 phase 2). - case 'g17': - arcPlane = 'xy'; - arcPlaneSeen = true; - break; - case 'g18': - arcPlane = 'xz'; - arcPlaneSeen = true; - break; - case 'g19': - arcPlane = 'yz'; - arcPlaneSeen = true; - break; - case 'm82': - eModeExplicit = 'absolute'; - break; - case 'm83': - eModeExplicit = 'relative'; - break; - // Coordinate systems (DD-010 D4, #158, E10 phase 3). - case 'g53': - // One-shot: the following move is in machine coordinates (ignores the work offset). - g53OneShot = true; - coordSystemSeen = true; - break; - case 'g54': - case 'g55': - case 'g56': - case 'g57': - case 'g58': - case 'g59': - activeWcs = Number(cmd.gcode.slice(1)) - 54; // g54→0 … g59→5 - coordSystemSeen = true; - break; - case 'g10': { - // Set a work-coordinate offset. L2 P: set the offset directly. L20 P: set it so the - // current position reads the given value in WCS n. P1→G54 … P6→G59 (default: active system). - const l = cmd.params.l; - const idx = cmd.params.p !== undefined ? Math.round(cmd.params.p) - 1 : activeWcs; - if (idx >= 0 && idx < 6 && (l === 2 || l === 20)) { - const w = wcsOffsets[idx]; - const setAxis = (word: number | undefined, cur: number, axis: 'x' | 'y' | 'z') => { - if (word === undefined) return; - w[axis] = l === 2 ? word : cur - word; // L20: offset = current logical − desired reading - }; - setAxis(cmd.params.x, sx, 'x'); - setAxis(cmd.params.y, sy, 'y'); - setAxis(cmd.params.z, sz, 'z'); - coordSystemSeen = true; - } - break; + if (cmd.codes.length === 0) { + // No G/M/T command word. This is either a dialect extended command (EXCLUDE_OBJECT_*, macros — + // matched on the raw line, DD-005) or a bare motion/param line. Forward the raw line to the + // observer so dialects still see it, then continue the active modal motion group if the line + // carries motion axes (the CNC/LinuxCNC modal form; FDM always emits the G word, so this never + // runs for FDM — byte-identical). + if (onCommand !== undefined) { + onCommand({ gcode: '', params: cmd.params, rawLine, srcByte: offset, segIndex: writer.count }); } - case 'g31': { - // Probe move (DD-010 D4 amendment, #158): the endpoint is reached at RUNTIME (workpiece - // contact), NOT the commanded value. Do not advance the position or draw a fabricated probe - // move; mark the probed axes runtime-dependent so a following G92 resyncs the logical frame. - let probed = false; - if (cmd.params.x !== undefined) { - certainX = false; - probed = true; - } - if (cmd.params.y !== undefined) { - certainY = false; - probed = true; - } - if (cmd.params.z !== undefined) { - certainZ = false; - probed = true; - } - if (probed) { - 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 - ); - } - break; + if (cmd.params.x !== undefined || cmd.params.y !== undefined || cmd.params.z !== undefined) { + if (cannedCycle !== null) ok = runCannedCycle(cmd.params); + else if (modalMotion === 'g2' || modalMotion === 'g3') ok = g2(cmd.params, modalMotion === 'g2'); + else if (modalMotion === 'g0' || modalMotion === 'g1') ok = g0(cmd.params, modalMotion === 'g0'); } - case 'g92': { - // Datum WITHOUT motion (DD-010 D4 + probe amendment, #158). `G92 E` rebases the absolute-E - // origin (also used in phase 1). For X/Y/Z, when the position is KNOWN it is a datum SHIFT (the - // work offset is set so the current logical position reads , preserving continuity). When the - // axis is runtime-dependent (post-probe) it is a logical RESYNC: the current logical position is - // declared to be , the offset is reset, certainty is restored, and the current path is - // finalized so the next move starts a NEW frame at the datum — no fabricated move is drawn - // across the unknown probe result. - if (cmd.params.e !== undefined) lastE = cmd.params.e; - let resync = false; - if (cmd.params.x !== undefined) { - if (certainX) g92x = sx - cmd.params.x - wcsOffsets[activeWcs].x; - else { - sx = cmd.params.x + wcsOffsets[activeWcs].x; - g92x = 0; - certainX = true; - resync = true; - } - coordSystemSeen = true; + } else + for (const gcode of cmd.codes) { + if (onCommand !== undefined) { + onCommand({ gcode, params: cmd.params, rawLine, srcByte: offset, segIndex: writer.count }); } - if (cmd.params.y !== undefined) { - if (certainY) g92y = sy - cmd.params.y - wcsOffsets[activeWcs].y; - else { - sy = cmd.params.y + wcsOffsets[activeWcs].y; - g92y = 0; - certainY = true; - resync = true; + switch (gcode) { + case 'g0': + case 'g1': + modalMotion = gcode as 'g0' | 'g1'; + cannedCycle = null; // a G0–G3 motion command cancels the canned-cycle modal group + ok = g0(cmd.params, gcode === 'g0'); + break; + case 'g2': + modalMotion = 'g2'; + cannedCycle = null; + ok = g2(cmd.params, true); + break; + case 'g3': + modalMotion = 'g3'; + cannedCycle = null; + ok = g2(cmd.params, false); + break; + // Canned drilling cycles (DD-012 phase 2, #189). A fresh G81/G82/G83 captures Z/R/Q + the + // initial plane and drills the first hole; bare X/Y lines (cannedRepeat) drill more, retaining + // that state; G80 cancels; G98/G99 set the retract plane. + case 'g81': + case 'g82': + case 'g83': + cannedCyclesSeen = true; + cannedCycle = gcode as 'g81' | 'g82' | 'g83'; + cannedInitialZ = sz; + if (cmd.params.z !== undefined) cannedZ = nextAxis(sz, cmd.params.z, offZ()); + if (cmd.params.r !== undefined) cannedR = nextAxis(sz, cmd.params.r, offZ()); + if (cmd.params.q !== undefined) cannedQ = Math.abs(cmd.params.q); + modalMotion = null; + ok = runCannedCycle(cmd.params); + break; + case 'g80': + cannedCycle = null; + break; + case 'g98': + cannedRetractInitial = true; + break; + case 'g99': + cannedRetractInitial = false; + break; + case 'g20': + units = 'in'; + unitsSeen = true; + break; + case 'g21': + units = 'mm'; + unitsSeen = true; + break; + case 'g28': + sx = 0; + sy = 0; + sz = 0; + break; + case 't0': + case 't1': + case 't2': + case 't3': + case 't4': + case 't5': + case 't6': + case 't7': + tool = Number(gcode.slice(1)); + break; + case 'm600': + // Manual filament swap = color boundary (#147). A marker, not motion — do + // NOT break/finish the current path; record position (current head), the + // next segment index (slot boundary), and the active tool for provenance. + colorChangeEvents.push({ x: sx, y: sy, z: sz, segIndex: writer.count, srcByte: offset, tool }); + break; + // Motion-model modal commands (DD-010 E10 phase 1). + case 'g90': + xyzAbsolute = true; + positioningSeen = true; + break; + case 'g91': + xyzAbsolute = false; + positioningSeen = true; + break; + // Arc-plane selection (DD-010 D3, #157, E10 phase 2). + case 'g17': + arcPlane = 'xy'; + arcPlaneSeen = true; + break; + case 'g18': + arcPlane = 'xz'; + arcPlaneSeen = true; + break; + case 'g19': + arcPlane = 'yz'; + arcPlaneSeen = true; + break; + case 'm82': + eModeExplicit = 'absolute'; + break; + case 'm83': + eModeExplicit = 'relative'; + break; + // Tool-state modal (DD-012 D2/D4, #189). M3 (CW) / M4 (CCW) engage the spindle/laser; + // M5 disengages. Drives Cut-vs-Travel classification for non-extrusion moves. The `S` word + // (power/RPM) is a modal channel handled in a later phase; here we track only engagement. + case 'm3': + case 'm4': + toolEngaged = true; + toolStateSeen = true; + break; + case 'm5': + toolEngaged = false; + toolStateSeen = true; + break; + // Coordinate systems (DD-010 D4, #158, E10 phase 3). + case 'g53': + // One-shot: the following move is in machine coordinates (ignores the work offset). + g53OneShot = true; + coordSystemSeen = true; + break; + case 'g54': + case 'g55': + case 'g56': + case 'g57': + case 'g58': + case 'g59': + activeWcs = Number(gcode.slice(1)) - 54; // g54→0 … g59→5 + coordSystemSeen = true; + break; + case 'g10': { + // Set a work-coordinate offset. L2 P: set the offset directly. L20 P: set it so the + // current position reads the given value in WCS n. P1→G54 … P6→G59 (default: active system). + const l = cmd.params.l; + const idx = cmd.params.p !== undefined ? Math.round(cmd.params.p) - 1 : activeWcs; + if (idx >= 0 && idx < 6 && (l === 2 || l === 20)) { + const w = wcsOffsets[idx]; + const setAxis = (word: number | undefined, cur: number, axis: 'x' | 'y' | 'z') => { + if (word === undefined) return; + w[axis] = l === 2 ? word : cur - word; // L20: offset = current logical − desired reading + }; + setAxis(cmd.params.x, sx, 'x'); + setAxis(cmd.params.y, sy, 'y'); + setAxis(cmd.params.z, sz, 'z'); + coordSystemSeen = true; + } + break; } - coordSystemSeen = true; - } - if (cmd.params.z !== undefined) { - if (certainZ) g92z = sz - cmd.params.z - wcsOffsets[activeWcs].z; - else { - sz = cmd.params.z + wcsOffsets[activeWcs].z; - g92z = 0; - certainZ = true; - resync = true; + case 'g31': { + // Probe move (DD-010 D4 amendment, #158): the endpoint is reached at RUNTIME (workpiece + // contact), NOT the commanded value. Do not advance the position or draw a fabricated probe + // move; mark the probed axes runtime-dependent so a following G92 resyncs the logical frame. + let probed = false; + if (cmd.params.x !== undefined) { + certainX = false; + probed = true; + } + if (cmd.params.y !== undefined) { + certainY = false; + probed = true; + } + if (cmd.params.z !== undefined) { + certainZ = false; + probed = true; + } + if (probed) { + 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 + ); + } + break; } - coordSystemSeen = true; + case 'g92': { + // Datum WITHOUT motion (DD-010 D4 + probe amendment, #158). `G92 E` rebases the absolute-E + // origin (also used in phase 1). For X/Y/Z, when the position is KNOWN it is a datum SHIFT (the + // work offset is set so the current logical position reads , preserving continuity). When the + // axis is runtime-dependent (post-probe) it is a logical RESYNC: the current logical position is + // declared to be , the offset is reset, certainty is restored, and the current path is + // finalized so the next move starts a NEW frame at the datum — no fabricated move is drawn + // across the unknown probe result. + if (cmd.params.e !== undefined) lastE = cmd.params.e; + let resync = false; + if (cmd.params.x !== undefined) { + if (certainX) g92x = sx - cmd.params.x - wcsOffsets[activeWcs].x; + else { + sx = cmd.params.x + wcsOffsets[activeWcs].x; + g92x = 0; + certainX = true; + resync = true; + } + coordSystemSeen = true; + } + if (cmd.params.y !== undefined) { + if (certainY) g92y = sy - cmd.params.y - wcsOffsets[activeWcs].y; + else { + sy = cmd.params.y + wcsOffsets[activeWcs].y; + g92y = 0; + certainY = true; + resync = true; + } + coordSystemSeen = true; + } + if (cmd.params.z !== undefined) { + if (certainZ) g92z = sz - cmd.params.z - wcsOffsets[activeWcs].z; + else { + sz = cmd.params.z + wcsOffsets[activeWcs].z; + g92z = 0; + certainZ = true; + resync = true; + } + coordSystemSeen = true; + } + if (resync) finishPath(); // start a new frame at the datum; no move connects across the probe + break; + } + default: + warn('unsupported-command', `unsupported command '${gcode}' preserved as metadata`, offset); } - if (resync) finishPath(); // start a new frame at the datum; no move connects across the probe - break; + if (!ok) break; } - default: - warn('unsupported-command', `unsupported command '${cmd.gcode}' preserved as metadata`, offset); - } if (!ok) { truncatedAtByte = offset; } @@ -881,6 +1062,13 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En // per-move G-code signal and stays 'unavailable' (a future geometry-heuristic DD may change it). wipeMoves: 'unavailable', seamMoves: 'unavailable', + // Non-extrusion productive moves (DD-012 D2, #189). 'known' once a tool-state modal (M3/M4/M5) + // was seen — i.e. this is a CNC/laser/plotter file whose Cut moves are meaningful; 'unavailable' + // for FDM (no tool-state), where every move is Extrude/Travel and Cut is never set. + cutMoves: toolStateSeen ? 'known' : 'unavailable', + // Canned drilling cycles expanded to geometry (DD-012 phase 2, #189). 'known' once a G81/G82/G83 + // was seen (holes are real segments), else 'unavailable' (no cycles / FDM). + cannedCycles: cannedCyclesSeen ? 'known' : 'unavailable', // Motion-model modes (DD-010 E10 phase 1). 'known' when the governing command was seen // (M82/M83, or a firmware-known G90/G91 for E); 'inferred' when defaulted (absolute). extrusionMode: eModeExplicit !== null || (extruderFollowsPositioning && positioningSeen) ? 'known' : 'inferred', @@ -891,6 +1079,9 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En // 'inferred' (identity WCS / no offset assumed — the FDM-slicer default). coordinateSystem: coordSystemSeen ? 'known' : 'inferred' }; + // Opt-in modal channels (DD-012 D3): only surfaced when requested. 'known' once its controlling + // signal was seen (toolPower ← a tool-state modal), else 'unavailable' — never a fabricated value. + if (wantToolPower) capabilities.toolPower = toolStateSeen ? 'known' : 'unavailable'; if (layersCapability === 'unavailable') { warn('layers-unavailable', 'No planar layer index; all segments assigned to layer 0.'); @@ -995,6 +1186,8 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En colorChanges: 'unavailable', // color-change boundaries resolve on the final IR wipeMoves: 'unavailable', // annotation move kinds resolve on the final IR (DD-016) seamMoves: 'unavailable', + cutMoves: 'unavailable', // non-extrusion classification resolves on the final IR (DD-012) + cannedCycles: 'unavailable', // canned-cycle expansion resolves on the final IR (DD-012) extrusionMode: 'inferred', // motion modes resolve fully on the final IR (E10) positioningMode: 'inferred', arcPlanes: 'inferred', diff --git a/packages/gcode-parser/src/worker.ts b/packages/gcode-parser/src/worker.ts index 3a6b7c40..632b0798 100644 --- a/packages/gcode-parser/src/worker.ts +++ b/packages/gcode-parser/src/worker.ts @@ -13,7 +13,10 @@ import { createDialectRunner, cura, + grblLaser, + grblMill, klipper, + linuxCnc, marlin, orcaBambu, prusaSlicer, @@ -31,7 +34,11 @@ const BUILTIN_ADAPTERS: Parameters[0] = [ cura(), klipper(), marlin(), - repRap() + repRap(), + // Non-extrusion controllers (DD-012 phase 3, #189) — all EXPERIMENTAL tier until hardware-validated. + grblLaser(), + grblMill(), + linuxCnc() ]; /** Built-in container adapters (DD-005 §4.4): .gcode.3mf and Prusa binary G-code (.bgcode, #188). */ diff --git a/packages/gcode-preview-core/CHANGELOG.md b/packages/gcode-preview-core/CHANGELOG.md index 4bd942fa..46a09aa8 100644 --- a/packages/gcode-preview-core/CHANGELOG.md +++ b/packages/gcode-preview-core/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-core +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`b2053be`](https://github.com/ChestnutLabs/gcode-preview/commit/b2053be4b8e71250bc6077f60ef996fe601b6f3e), [`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86), [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`11f317d`](https://github.com/ChestnutLabs/gcode-preview/commit/11f317de2d6cb963d2a7fb0c894c89d3d5adc86d), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09), [`879b60a`](https://github.com/ChestnutLabs/gcode-preview/commit/879b60ae0fca87ca8187791603a1bc7f54e61c4c), [`b84bea9`](https://github.com/ChestnutLabs/gcode-preview/commit/b84bea959b7aae24d148e6bcc488a9ed254a54f0)]: + - @chestnutlabs/gcode-parser@0.4.0 + - @chestnutlabs/gcode-renderer-three@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + - @chestnutlabs/gcode-renderer-2d@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-preview-core/package.json b/packages/gcode-preview-core/package.json index 4af0892f..80b68ee8 100644 --- a/packages/gcode-preview-core/package.json +++ b/packages/gcode-preview-core/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-preview-core", - "version": "0.3.0", + "version": "0.4.0", "description": "Framework-neutral preview controller for the Chestnut Labs G-code viewer (DD-007 §4.6): the shared engine glue, state model, and TypeScript contracts beneath the Vue/React/Svelte adapters.", "keywords": [ "gcode", @@ -52,9 +52,9 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-renderer-2d": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-renderer-2d": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" } } diff --git a/packages/gcode-preview-element/CHANGELOG.md b/packages/gcode-preview-element/CHANGELOG.md index 31a42209..394ca749 100644 --- a/packages/gcode-preview-element/CHANGELOG.md +++ b/packages/gcode-preview-element/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-element +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`b2053be`](https://github.com/ChestnutLabs/gcode-preview/commit/b2053be4b8e71250bc6077f60ef996fe601b6f3e), [`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86), [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`11f317d`](https://github.com/ChestnutLabs/gcode-preview/commit/11f317de2d6cb963d2a7fb0c894c89d3d5adc86d), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09), [`879b60a`](https://github.com/ChestnutLabs/gcode-preview/commit/879b60ae0fca87ca8187791603a1bc7f54e61c4c), [`b84bea9`](https://github.com/ChestnutLabs/gcode-preview/commit/b84bea959b7aae24d148e6bcc488a9ed254a54f0)]: + - @chestnutlabs/gcode-parser@0.4.0 + - @chestnutlabs/gcode-renderer-three@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + - @chestnutlabs/gcode-preview-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-preview-element/package.json b/packages/gcode-preview-element/package.json index 9f0efda2..f790bd6a 100644 --- a/packages/gcode-preview-element/package.json +++ b/packages/gcode-preview-element/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-preview-element", - "version": "0.3.0", + "version": "0.4.0", "description": "Framework-free Web Component for the Chestnut Labs G-code viewer (DD-007 D1 / DD-009 D5): a custom element bridging @chestnutlabs/gcode-preview-core, no framework peer dependency.", "keywords": [ "gcode", @@ -52,9 +52,9 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" } } diff --git a/packages/gcode-preview-react/CHANGELOG.md b/packages/gcode-preview-react/CHANGELOG.md index d08ac4c0..ea9071d1 100644 --- a/packages/gcode-preview-react/CHANGELOG.md +++ b/packages/gcode-preview-react/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-react +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`b2053be`](https://github.com/ChestnutLabs/gcode-preview/commit/b2053be4b8e71250bc6077f60ef996fe601b6f3e), [`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86), [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`11f317d`](https://github.com/ChestnutLabs/gcode-preview/commit/11f317de2d6cb963d2a7fb0c894c89d3d5adc86d), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09), [`879b60a`](https://github.com/ChestnutLabs/gcode-preview/commit/879b60ae0fca87ca8187791603a1bc7f54e61c4c), [`b84bea9`](https://github.com/ChestnutLabs/gcode-preview/commit/b84bea959b7aae24d148e6bcc488a9ed254a54f0)]: + - @chestnutlabs/gcode-parser@0.4.0 + - @chestnutlabs/gcode-renderer-three@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + - @chestnutlabs/gcode-preview-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-preview-react/package.json b/packages/gcode-preview-react/package.json index 895267f7..b5694e3e 100644 --- a/packages/gcode-preview-react/package.json +++ b/packages/gcode-preview-react/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-preview-react", - "version": "0.3.0", + "version": "0.4.0", "description": "Thin React integration for the Chestnut Labs G-code viewer (DD-007 D1 amendment): useGcodePreview hook + GcodePreview component as a reactivity bridge over @chestnutlabs/gcode-preview-core.", "keywords": [ "gcode", @@ -49,10 +49,10 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" diff --git a/packages/gcode-preview-svelte/CHANGELOG.md b/packages/gcode-preview-svelte/CHANGELOG.md index b9dc9277..da69cef6 100644 --- a/packages/gcode-preview-svelte/CHANGELOG.md +++ b/packages/gcode-preview-svelte/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-svelte +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`b2053be`](https://github.com/ChestnutLabs/gcode-preview/commit/b2053be4b8e71250bc6077f60ef996fe601b6f3e), [`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86), [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`11f317d`](https://github.com/ChestnutLabs/gcode-preview/commit/11f317de2d6cb963d2a7fb0c894c89d3d5adc86d), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09), [`879b60a`](https://github.com/ChestnutLabs/gcode-preview/commit/879b60ae0fca87ca8187791603a1bc7f54e61c4c), [`b84bea9`](https://github.com/ChestnutLabs/gcode-preview/commit/b84bea959b7aae24d148e6bcc488a9ed254a54f0)]: + - @chestnutlabs/gcode-parser@0.4.0 + - @chestnutlabs/gcode-renderer-three@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + - @chestnutlabs/gcode-preview-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-preview-svelte/package.json b/packages/gcode-preview-svelte/package.json index f3777e31..e1b53813 100644 --- a/packages/gcode-preview-svelte/package.json +++ b/packages/gcode-preview-svelte/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-preview-svelte", - "version": "0.3.0", + "version": "0.4.0", "description": "Thin Svelte integration for the Chestnut Labs G-code viewer (DD-007 D1 amendment): createGcodePreview store/action API + GcodePreview component as a reactivity bridge over @chestnutlabs/gcode-preview-core.", "keywords": [ "gcode", @@ -55,10 +55,10 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0" diff --git a/packages/gcode-preview-vue/CHANGELOG.md b/packages/gcode-preview-vue/CHANGELOG.md index d6d28c5b..b5664a3f 100644 --- a/packages/gcode-preview-vue/CHANGELOG.md +++ b/packages/gcode-preview-vue/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-vue +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`b2053be`](https://github.com/ChestnutLabs/gcode-preview/commit/b2053be4b8e71250bc6077f60ef996fe601b6f3e), [`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86), [`13fd5c6`](https://github.com/ChestnutLabs/gcode-preview/commit/13fd5c61d730428a7f7e73c28cf3cc9c48e68c19), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`11f317d`](https://github.com/ChestnutLabs/gcode-preview/commit/11f317de2d6cb963d2a7fb0c894c89d3d5adc86d), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09), [`879b60a`](https://github.com/ChestnutLabs/gcode-preview/commit/879b60ae0fca87ca8187791603a1bc7f54e61c4c), [`b84bea9`](https://github.com/ChestnutLabs/gcode-preview/commit/b84bea959b7aae24d148e6bcc488a9ed254a54f0)]: + - @chestnutlabs/gcode-parser@0.4.0 + - @chestnutlabs/gcode-renderer-three@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + - @chestnutlabs/gcode-preview-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-preview-vue/package.json b/packages/gcode-preview-vue/package.json index a82640a7..c2da5933 100644 --- a/packages/gcode-preview-vue/package.json +++ b/packages/gcode-preview-vue/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-preview-vue", - "version": "0.3.0", + "version": "0.4.0", "description": "Thin Vue 3 integration for the Chestnut Labs G-code viewer (DD-007): useGcodePreview composable + GcodePreview component over the framework-neutral parser/renderer/progress packages.", "keywords": [ "gcode", @@ -50,10 +50,10 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-parser": "0.3.0", - "@chestnutlabs/gcode-renderer-three": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0", - "@chestnutlabs/gcode-preview-core": "0.3.0" + "@chestnutlabs/gcode-parser": "0.4.0", + "@chestnutlabs/gcode-renderer-three": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0", + "@chestnutlabs/gcode-preview-core": "0.4.0" }, "peerDependencies": { "vue": "^3.4.0" diff --git a/packages/gcode-renderer-2d/CHANGELOG.md b/packages/gcode-renderer-2d/CHANGELOG.md index 1d1ce1a1..b947e1f5 100644 --- a/packages/gcode-renderer-2d/CHANGELOG.md +++ b/packages/gcode-renderer-2d/CHANGELOG.md @@ -1,5 +1,13 @@ # @chestnutlabs/gcode-renderer-2d +## 0.4.0 + +### Patch Changes + +- Updated dependencies [[`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09)]: + - @chestnutlabs/gcode-colors@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-renderer-2d/package.json b/packages/gcode-renderer-2d/package.json index 58735516..9aa60b83 100644 --- a/packages/gcode-renderer-2d/package.json +++ b/packages/gcode-renderer-2d/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-renderer-2d", - "version": "0.3.0", + "version": "0.4.0", "description": "Low-resource Canvas 2D layer renderer for the Chestnut Labs G-code viewer (DD-014 / E8): an opt-in current/adjacent-layer 2D view over the existing ToolpathIR for low-GPU/low-memory/WebGL-blocked devices. No three, no framework.", "keywords": [ "gcode", @@ -49,7 +49,7 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-colors": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-colors": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" } } diff --git a/packages/gcode-renderer-three/CHANGELOG.md b/packages/gcode-renderer-three/CHANGELOG.md index c0ad1d5f..10ff8a30 100644 --- a/packages/gcode-renderer-three/CHANGELOG.md +++ b/packages/gcode-renderer-three/CHANGELOG.md @@ -1,5 +1,29 @@ # @chestnutlabs/gcode-renderer-three +## 0.4.0 + +### Minor Changes + +- [#254](https://github.com/ChestnutLabs/gcode-preview/pull/254) [`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: non-extrusion color modes — color-by-power + cut-vs-rapid (DD-012 phase 4, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Two new `ColorMode`s consuming the [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189) channels (DD-012 D7): + - **`power`** — ramps each segment's modal `toolPower` (laser power / spindle RPM, the `S` value) onto + a color ramp, the CNC/laser counterpart to color-by-speed. Auto-ranged (`toolPowerRange`) or explicit; + `NaN` (tool off) or a file parsed without the `toolPower` channel → fallback, never a fabricated color. + Capability-gated on `toolPower` (the Three renderer's `isColorModeAvailable` gates it). + - **`moveKind`** — cut-vs-rapid: productive moves (`Extrude` or `Cut`) vs rapids (`Travel`) — the + "where the tool is actually working" view. Reads the always-present `kind` channel, so it is always + available. + + Both flow through `createSegmentColorer`, so the Three and Canvas-2D renderers get them for free. FDM + coloring is unchanged. + +### Patch Changes + +- Updated dependencies [[`5f59b77`](https://github.com/ChestnutLabs/gcode-preview/commit/5f59b7788bbb14cacfe21aaf3d7134c6ba8dcd86), [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a), [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09)]: + - @chestnutlabs/gcode-colors@0.4.0 + - @chestnutlabs/toolpath-core@0.4.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/gcode-renderer-three/package.json b/packages/gcode-renderer-three/package.json index 4062988f..78ac75ac 100644 --- a/packages/gcode-renderer-three/package.json +++ b/packages/gcode-renderer-three/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-renderer-three", - "version": "0.3.0", + "version": "0.4.0", "description": "Three.js toolpath renderer consuming ToolpathIR (DD-004). Phases 1-2: geometry builders + scene/lifecycle.", "keywords": [ "gcode", @@ -49,8 +49,8 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-colors": "0.3.0", - "@chestnutlabs/toolpath-core": "0.3.0" + "@chestnutlabs/gcode-colors": "0.4.0", + "@chestnutlabs/toolpath-core": "0.4.0" }, "devDependencies": { "@types/three": "0.178.0" diff --git a/packages/gcode-renderer-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index b2c233b5..686f74e4 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -823,6 +823,13 @@ export class ToolpathRenderer { const conf = this.ir?.header.capabilities['layers']; return conf !== undefined && conf !== 'unavailable'; } + if (mode === 'power') { + // Color-by-power needs the `toolPower` modal channel captured (#189) — present only when the parse + // requested it and a tool-state was seen (or a CNC dialect reported it). Absent → not available. + const conf = this.ir?.header.capabilities['toolPower']; + return conf !== undefined && conf !== 'unavailable'; + } + // 'moveKind' (cut-vs-rapid) reads the always-present kind channel → always available, like single/tool. return true; } diff --git a/packages/toolpath-core/CHANGELOG.md b/packages/toolpath-core/CHANGELOG.md index 5ed997b4..4c0cb7be 100644 --- a/packages/toolpath-core/CHANGELOG.md +++ b/packages/toolpath-core/CHANGELOG.md @@ -1,5 +1,45 @@ # @chestnutlabs/toolpath-core +## 0.4.0 + +### Minor Changes + +- [#248](https://github.com/ChestnutLabs/gcode-preview/pull/248) [`1029580`](https://github.com/ChestnutLabs/gcode-preview/commit/10295803839816adaed224c48eba1f74374c0c2a) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: non-extrusion `Cut` move classification + tool-state modal (DD-012 phase 1, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Non-extrusion toolpaths (CNC / laser / plotter) no longer collapse their productive moves into + `Travel`. The parser now tracks a tool-engaged modal state — `M3`/`M4` (spindle/laser on, incl. the + `M03`/`M04` leading-zero form) engage it, `M5` disengages — and classifies a move with **no extrusion + `E`** while the tool is engaged as the new **`MoveKind.Cut`** bit (a CNC/laser/plotter counterpart to + `Extrude`, composing with `ArcSegment` like the other kinds). + - New IR move kind `MoveKind.Cut = 1 << 7` (`@chestnutlabs/toolpath-core`). + - New capability **`cutMoves`**: `known` once a tool-state modal is seen (a CNC/laser/plotter file), + `unavailable` for FDM. + - **FDM is byte-identical**: FDM slices never issue `M3`/`M4`, so `Cut` is never set and every move + stays `Extrude`/`Travel` exactly as before (verified against the native-golden corpus; the CNC + fixtures `demo-easel`/`demo-mach3` are documented intentional adapter-divergences). + + Modal tool-state _value_ channels (laser power / spindle RPM via `S`), canned-cycle expansion, and + dialect families follow in later DD-012 phases. + +- [#250](https://github.com/ChestnutLabs/gcode-preview/pull/250) [`8fec7c3`](https://github.com/ChestnutLabs/gcode-preview/commit/8fec7c3622cd2a6d6d57b43d7866cfea1cb71e09) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat: opt-in modal tool-power channel (DD-012 phase 1 — the `ModalChannel` mechanism, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189)) + + Adds the shared, opt-in **`ModalChannel`** mechanism DD-012 D3 is built around, and its first channel: + **`toolPower`** — the modal spindle/laser `S` value while a tool is engaged. + - `ParseOptions.modalChannels?: readonly string[]` — request per-segment modal channels by id. + Supported id: `'toolPower'`. Unknown ids are ignored with a `modal-channel-unsupported` warning. + - `ToolpathSegments.modal?: Readonly>` — one Float32 column per requested + channel, present **only** when requested. An unset value is `NaN` (an honest "no value here"), never + a fabricated `0`. `toolPower` is the modal `S` (set on `M3`/`M4` and inline on GRBL-laser motion + lines) while engaged, `NaN` when the tool is off (`M5`). + - New capability **`toolPower`**: surfaced only when the channel is requested — `known` once a + tool-state modal is seen, else `unavailable`. + - **Default parse pays nothing**: no `modalChannels` ⇒ no `modal` on the IR, no extra columns, FDM + output unchanged. The budget-aware SoA writer (DD-003) grows the opt-in columns in lockstep and + accounts their bytes. + + Presentation (Watts vs RPM) is a dialect label, not a separate channel (DD-012 D4); [#180](https://github.com/ChestnutLabs/gcode-preview/issues/180)'s + fan/temp/accel color channels reuse this same mechanism in a later phase. + ## 0.3.0 ### Minor Changes diff --git a/packages/toolpath-core/package.json b/packages/toolpath-core/package.json index d6ca78a3..47d2a5c9 100644 --- a/packages/toolpath-core/package.json +++ b/packages/toolpath-core/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/toolpath-core", - "version": "0.3.0", + "version": "0.4.0", "description": "Neutral ToolpathIR and capability model for the Chestnut Labs G-code Preview toolpath stack.", "keywords": [ "gcode", diff --git a/packages/toolpath-core/src/ir.ts b/packages/toolpath-core/src/ir.ts index 2ec0a9b9..dfa61a61 100644 --- a/packages/toolpath-core/src/ir.ts +++ b/packages/toolpath-core/src/ir.ts @@ -38,7 +38,12 @@ export const MoveKind = { Unretract: 1 << 3, Wipe: 1 << 4, ArcSegment: 1 << 5, - Seam: 1 << 6 + Seam: 1 << 6, + /** Tool-engaged productive move (cut / burn / draw) when no extrusion E is present — + * a CNC/laser/plotter counterpart to `Extrude` (DD-012 D2). Set by the parser only when a + * tool-state modal (spindle/laser on) holds and the move has no E delta, so an FDM slice + * never sets it. Composes with `ArcSegment` like the other kinds. */ + Cut: 1 << 7 } as const; export type MoveKindName = keyof typeof MoveKind; @@ -117,6 +122,11 @@ export interface ToolpathSegments { object: Uint32Array; /** Byte offset in the source of the command that produced this segment. */ srcByte: Uint32Array; + /** Opt-in modal channels (DD-012 D3), keyed by channel id — present only when the parse was asked + * to capture them (`ParseOptions.modalChannels`). Each is a Float32 column of length `count`; an + * unset value is `NaN` (an honest "no value here"), never a fabricated 0. E.g. `modal.toolPower` + * is the spindle/laser `S` value while a tool is engaged. FDM parses carry no `modal`. */ + modal?: Readonly>; } export interface ToolpathLayer { diff --git a/test-data/golden-native/container-adv-bad-crc.json b/test-data/golden-native/container-adv-bad-crc.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-adv-bad-crc.json +++ b/test-data/golden-native/container-adv-bad-crc.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-adv-duplicate-plate.json b/test-data/golden-native/container-adv-duplicate-plate.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-adv-duplicate-plate.json +++ b/test-data/golden-native/container-adv-duplicate-plate.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-adv-encrypted.json b/test-data/golden-native/container-adv-encrypted.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-adv-encrypted.json +++ b/test-data/golden-native/container-adv-encrypted.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-adv-header-mismatch.json b/test-data/golden-native/container-adv-header-mismatch.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-adv-header-mismatch.json +++ b/test-data/golden-native/container-adv-header-mismatch.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-adv-size-lie.json b/test-data/golden-native/container-adv-size-lie.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-adv-size-lie.json +++ b/test-data/golden-native/container-adv-size-lie.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-adv-traversal-names.json b/test-data/golden-native/container-adv-traversal-names.json index e5c72665..a3e87e87 100644 --- a/test-data/golden-native/container-adv-traversal-names.json +++ b/test-data/golden-native/container-adv-traversal-names.json @@ -1,8 +1,8 @@ { - "segmentCount": 0, - "layerCount": 0, - "toolCount": 0, - "points": 0, + "segmentCount": 3, + "layerCount": 1, + "toolCount": 1, + "points": 3, "extrusionDistance": 0, "capabilities": { "geometry": "known", @@ -18,27 +18,28 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { - "x0": "811c9dc5", - "y0": "811c9dc5", - "z0": "811c9dc5", - "x1": "811c9dc5", - "y1": "811c9dc5", - "z1": "811c9dc5", - "e": "811c9dc5", - "feedrate": "811c9dc5", - "kind": "811c9dc5", - "tool": "811c9dc5", - "layer": "811c9dc5", - "srcByte": "811c9dc5" + "x0": "4c0deeb5", + "y0": "0666a885", + "z0": "e23c62b5", + "x1": "ca9b1375", + "y1": "f0976b05", + "z1": "e23c62b5", + "e": "e23c62b5", + "feedrate": "a51f5f48", + "kind": "16835645", + "tool": "7f77879d", + "layer": "e23c62b5", + "srcByte": "74134621" } } diff --git a/test-data/golden-native/container-adv-truncated.json b/test-data/golden-native/container-adv-truncated.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-adv-truncated.json +++ b/test-data/golden-native/container-adv-truncated.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-mini-project.json b/test-data/golden-native/container-mini-project.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-mini-project.json +++ b/test-data/golden-native/container-mini-project.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-model-project.json b/test-data/golden-native/container-model-project.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/container-model-project.json +++ b/test-data/golden-native/container-model-project.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/demo-3dbenchy.json b/test-data/golden-native/demo-3dbenchy.json index d856dbfe..06ab997e 100644 --- a/test-data/golden-native/demo-3dbenchy.json +++ b/test-data/golden-native/demo-3dbenchy.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "known", "positioningMode": "known", "arcPlanes": "inferred", diff --git a/test-data/golden-native/demo-calicat.json b/test-data/golden-native/demo-calicat.json index c6da46b4..dc5e3855 100644 --- a/test-data/golden-native/demo-calicat.json +++ b/test-data/golden-native/demo-calicat.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "known", "positioningMode": "known", "arcPlanes": "inferred", diff --git a/test-data/golden-native/demo-easel.json b/test-data/golden-native/demo-easel.json index f368cbb2..511248f4 100644 --- a/test-data/golden-native/demo-easel.json +++ b/test-data/golden-native/demo-easel.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "known", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "known", "arcPlanes": "inferred", @@ -36,7 +38,7 @@ "z1": "922f59d5", "e": "b2ea2aa5", "feedrate": "55cd1315", - "kind": "201c43c5", + "kind": "011a88d7", "tool": "b92addb5", "layer": "b2ea2aa5", "srcByte": "66a09cf5" diff --git a/test-data/golden-native/demo-mach3.json b/test-data/golden-native/demo-mach3.json index 2158d974..0d5b6be7 100644 --- a/test-data/golden-native/demo-mach3.json +++ b/test-data/golden-native/demo-mach3.json @@ -18,8 +18,10 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "known", + "cannedCycles": "unavailable", "extrusionMode": "inferred", - "positioningMode": "inferred", + "positioningMode": "known", "arcPlanes": "inferred", "coordinateSystem": "known" }, @@ -36,8 +38,8 @@ "y1": "f0ad3a03", "z1": "5cde1254", "e": "54b90035", - "feedrate": "3e3b6563", - "kind": "4a13bc65", + "feedrate": "f4a1a463", + "kind": "9319ff9f", "tool": "dc53945d", "layer": "54b90035", "srcByte": "07d9628b" diff --git a/test-data/golden-native/demo-plant-sign.json b/test-data/golden-native/demo-plant-sign.json index 4ae7766c..c01157f1 100644 --- a/test-data/golden-native/demo-plant-sign.json +++ b/test-data/golden-native/demo-plant-sign.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "known", "positioningMode": "known", "arcPlanes": "inferred", diff --git a/test-data/golden-native/demo-screw.json b/test-data/golden-native/demo-screw.json index 8f54536a..82a5444b 100644 --- a/test-data/golden-native/demo-screw.json +++ b/test-data/golden-native/demo-screw.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "known", "positioningMode": "known", "arcPlanes": "inferred", diff --git a/test-data/golden-native/demo-vase.json b/test-data/golden-native/demo-vase.json index 5ba00d6e..a3ec72e1 100644 --- a/test-data/golden-native/demo-vase.json +++ b/test-data/golden-native/demo-vase.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "known", "positioningMode": "known", "arcPlanes": "inferred", diff --git a/test-data/golden-native/dialect-cura-style.json b/test-data/golden-native/dialect-cura-style.json index 1efdc093..c5724ec9 100644 --- a/test-data/golden-native/dialect-cura-style.json +++ b/test-data/golden-native/dialect-cura-style.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/dialect-klipper-prusa.json b/test-data/golden-native/dialect-klipper-prusa.json index d99f98a2..478fc8c1 100644 --- a/test-data/golden-native/dialect-klipper-prusa.json +++ b/test-data/golden-native/dialect-klipper-prusa.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-partially-inferred", - "unsupported-command" + "layers-partially-inferred" ], "bufferDigests": { "x0": "63aaf591", diff --git a/test-data/golden-native/dialect-marlin-m486.json b/test-data/golden-native/dialect-marlin-m486.json index 2264aba8..22898b69 100644 --- a/test-data/golden-native/dialect-marlin-m486.json +++ b/test-data/golden-native/dialect-marlin-m486.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/dialect-multitool-ams.json b/test-data/golden-native/dialect-multitool-ams.json index bd71fa9e..9f140c98 100644 --- a/test-data/golden-native/dialect-multitool-ams.json +++ b/test-data/golden-native/dialect-multitool-ams.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/dialect-orca-bambu-style.json b/test-data/golden-native/dialect-orca-bambu-style.json index 1ebd14c2..a6d14c31 100644 --- a/test-data/golden-native/dialect-orca-bambu-style.json +++ b/test-data/golden-native/dialect-orca-bambu-style.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/dialect-prusa-style.json b/test-data/golden-native/dialect-prusa-style.json index 7389359b..54e392a0 100644 --- a/test-data/golden-native/dialect-prusa-style.json +++ b/test-data/golden-native/dialect-prusa-style.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/dialect-reprap-style.json b/test-data/golden-native/dialect-reprap-style.json index 5e01e875..d6969d5e 100644 --- a/test-data/golden-native/dialect-reprap-style.json +++ b/test-data/golden-native/dialect-reprap-style.json @@ -18,6 +18,8 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/fuzz-deflate-corrupt-stream.json b/test-data/golden-native/fuzz-deflate-corrupt-stream.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/fuzz-deflate-corrupt-stream.json +++ b/test-data/golden-native/fuzz-deflate-corrupt-stream.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/progress-anycubic-percent.json b/test-data/golden-native/progress-anycubic-percent.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/progress-anycubic-percent.json +++ b/test-data/golden-native/progress-anycubic-percent.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/progress-bambu-percent-layer.json b/test-data/golden-native/progress-bambu-percent-layer.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/progress-bambu-percent-layer.json +++ b/test-data/golden-native/progress-bambu-percent-layer.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/progress-byte-exact.json b/test-data/golden-native/progress-byte-exact.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/progress-byte-exact.json +++ b/test-data/golden-native/progress-byte-exact.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/progress-klipper-byte-fraction.json b/test-data/golden-native/progress-klipper-byte-fraction.json index e5c72665..fa260d47 100644 --- a/test-data/golden-native/progress-klipper-byte-fraction.json +++ b/test-data/golden-native/progress-klipper-byte-fraction.json @@ -18,14 +18,15 @@ "colorChanges": "unavailable", "wipeMoves": "unavailable", "seamMoves": "unavailable", + "cutMoves": "unavailable", + "cannedCycles": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable", - "unsupported-command" + "layers-unavailable" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/tools/demo/index.html b/tools/demo/index.html index 3b13880b..b4aea5ae 100644 --- a/tools/demo/index.html +++ b/tools/demo/index.html @@ -227,6 +227,7 @@

+

diff --git a/tools/demo/src/main.js b/tools/demo/src/main.js index 8de01f76..b80f144c 100644 --- a/tools/demo/src/main.js +++ b/tools/demo/src/main.js @@ -7,6 +7,7 @@ import { GcodeParseSession, CancelledError } from '@chestnutlabs/gcode-parser'; import { ToolpathRenderer } from '@chestnutlabs/gcode-renderer-three'; import { createProgressMapper } from '@chestnutlabs/toolpath-core'; +import { downloadToolpathStl } from './stl-export.js'; // Inherited MIT demo corpus (see test-data/manifest.json), served by Vite's publicDir. const CORPUS = [ @@ -94,6 +95,7 @@ const els = { material: $('material'), qualityNote: $('qualityNote'), frame: $('frame'), + exportStl: $('exportStl'), disclosure: $('disclosure'), stats: $('stats'), progressTier: $('progressTier'), @@ -167,7 +169,8 @@ function enableControls(ir) { els.scrub.max = String(renderer.segmentCount); els.scrub.value = String(renderer.segmentCount); els.scrubVal.textContent = 'all'; - for (const el of [els.startLayer, els.endLayer, els.scrub, els.colorMode, els.frame]) el.disabled = false; + for (const el of [els.startLayer, els.endLayer, els.scrub, els.colorMode, els.frame, els.exportStl]) + el.disabled = false; // Capability-honest color modes (§4.6): never offer fabricated feature colors. const featureOpt = els.colorMode.querySelector('option[value="feature"]'); @@ -427,6 +430,16 @@ const applyTheme = () => renderer.setTheme(themeFor(els.theme.value, els.materia els.theme.addEventListener('change', applyTheme); els.material.addEventListener('change', applyTheme); els.frame.addEventListener('click', () => renderer.frame()); +els.exportStl.addEventListener('click', () => { + const ir = renderer.ir; + if (!ir) return; + const { segments, emitted, triangles } = downloadToolpathStl(ir, 'toolpath.stl'); + const note = + emitted < segments + ? `Exported STL: ${triangles.toLocaleString()} triangles from ${emitted.toLocaleString()} of ${segments.toLocaleString()} productive segments (strided to the triangle budget).` + : `Exported STL: ${triangles.toLocaleString()} triangles from ${segments.toLocaleString()} productive segments.`; + setStatus(note); +}); // App-level keyboard shortcuts (master plan §9.5); every control is also plain // tab-reachable, and the sliders take arrow/page keys natively. diff --git a/tools/demo/src/stl-export.js b/tools/demo/src/stl-export.js new file mode 100644 index 00000000..4c9a3850 --- /dev/null +++ b/tools/demo/src/stl-export.js @@ -0,0 +1,141 @@ +/* + * STL export of the rendered toolpath (issue #152) — a SHOWCASE-app feature, deliberately kept in + * tools/demo and NOT in the reusable packages (per the #152 / #118 scoping). Self-contained: it reads + * only the public `ToolpathIR` (via `renderer.ir`), no renderer internals. + * + * Each productive move (extrude or cut, never travels/rapids) becomes a square-section tube; the tubes + * are emitted as a binary STL. Large toolpaths are strided down to a triangle budget so the download + * stays sane (the count that was dropped is returned for an honest note — no silent truncation). + */ +import { MoveKind } from '@chestnutlabs/toolpath-core'; + +const TRIS_PER_SEGMENT = 8; // 4 side quads of the square tube, no end caps + +/** + * Build a binary STL of the toolpath's productive moves. + * @param {import('@chestnutlabs/toolpath-core').ToolpathIR} ir + * @param {{ radius?: number, kindMask?: number, maxTriangles?: number }} [opts] + * @returns {{ buffer: ArrayBuffer, segments: number, emitted: number, triangles: number }} + */ +export function toolpathToStl(ir, opts = {}) { + const seg = ir.segments; + const radius = opts.radius ?? 0.2; // half-width of the square tube, mm + const kindMask = opts.kindMask ?? MoveKind.Extrude | MoveKind.Cut; + const maxTriangles = opts.maxTriangles ?? 2_000_000; + const o = ir.header.originOffset ?? { x: 0, y: 0, z: 0 }; + + // Select productive, non-degenerate segments. + const idx = []; + for (let i = 0; i < seg.count; i++) { + if ((seg.kind[i] & kindMask) === 0) continue; + const dx = seg.x1[i] - seg.x0[i]; + const dy = seg.y1[i] - seg.y0[i]; + const dz = seg.z1[i] - seg.z0[i]; + if (dx * dx + dy * dy + dz * dz < 1e-18) continue; + idx.push(i); + } + + // Stride down to the triangle budget (honest decimation — the caller reports what was dropped). + const stride = Math.max(1, Math.ceil((idx.length * TRIS_PER_SEGMENT) / maxTriangles)); + const chosen = stride === 1 ? idx : idx.filter((_, k) => k % stride === 0); + + const triCount = chosen.length * TRIS_PER_SEGMENT; + const buffer = new ArrayBuffer(84 + triCount * 50); + const dv = new DataView(buffer); + dv.setUint32(80, triCount, true); // 80-byte header left zero; triangle count little-endian + let off = 84; + + const tri = (a, b, c) => { + // Real facet normal (right-hand winding), normalized. + const ux = b[0] - a[0]; + const uy = b[1] - a[1]; + const uz = b[2] - a[2]; + const vx = c[0] - a[0]; + const vy = c[1] - a[1]; + const vz = c[2] - a[2]; + let nx = uy * vz - uz * vy; + let ny = uz * vx - ux * vz; + let nz = ux * vy - uy * vx; + const nl = Math.hypot(nx, ny, nz) || 1; + nx /= nl; + ny /= nl; + nz /= nl; + dv.setFloat32(off, nx, true); + dv.setFloat32(off + 4, ny, true); + dv.setFloat32(off + 8, nz, true); + const pts = [a, b, c]; + for (let p = 0; p < 3; p++) { + dv.setFloat32(off + 12 + p * 12, pts[p][0], true); + dv.setFloat32(off + 16 + p * 12, pts[p][1], true); + dv.setFloat32(off + 20 + p * 12, pts[p][2], true); + } + dv.setUint16(off + 48, 0, true); // attribute byte count + off += 50; + }; + + for (const i of chosen) { + const ax = seg.x0[i] + o.x; + const ay = seg.y0[i] + o.y; + const az = seg.z0[i] + o.z; + const bx = seg.x1[i] + o.x; + const by = seg.y1[i] + o.y; + const bz = seg.z1[i] + o.z; + let dx = bx - ax; + let dy = by - ay; + let dz = bz - az; + const dl = Math.hypot(dx, dy, dz); + dx /= dl; + dy /= dl; + dz /= dl; + // An arbitrary unit perpendicular u, then v = d × u — the square cross-section basis. + let ux; + let uy; + let uz; + if (Math.abs(dz) < 0.9) { + ux = -dy; + uy = dx; + uz = 0; + } else { + ux = 0; + uy = -dz; + uz = dy; + } + const ul = Math.hypot(ux, uy, uz) || 1; + ux /= ul; + uy /= ul; + uz /= ul; + const vx = dy * uz - dz * uy; + const vy = dz * ux - dx * uz; + const vz = dx * uy - dy * ux; + const corners = (px, py, pz) => [ + [px + radius * ux + radius * vx, py + radius * uy + radius * vy, pz + radius * uz + radius * vz], + [px - radius * ux + radius * vx, py - radius * uy + radius * vy, pz - radius * uz + radius * vz], + [px - radius * ux - radius * vx, py - radius * uy - radius * vy, pz - radius * uz - radius * vz], + [px + radius * ux - radius * vx, py + radius * uy - radius * vy, pz + radius * uz - radius * vz] + ]; + const A = corners(ax, ay, az); + const B = corners(bx, by, bz); + for (let k = 0; k < 4; k++) { + const k2 = (k + 1) % 4; + tri(A[k], A[k2], B[k2]); + tri(A[k], B[k2], B[k]); + } + } + + return { buffer, segments: idx.length, emitted: chosen.length, triangles: triCount }; +} + +/** Trigger a browser download of an STL built from `ir`. Returns the export summary for a status note. */ +export function downloadToolpathStl(ir, filename = 'toolpath.stl', opts = {}) { + const result = toolpathToStl(ir, opts); + const blob = new Blob([result.buffer], { type: 'model/stl' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + return result; +} diff --git a/tools/demo/src/validate.js b/tools/demo/src/validate.js new file mode 100644 index 00000000..4480aabc --- /dev/null +++ b/tools/demo/src/validate.js @@ -0,0 +1,618 @@ +/* + * CNC / laser VALIDATION HARNESS (#189, DD-012 phase 6). + * + * Purpose: turn "does it work?" into a per-file checklist. The user loads a file they have + * PHYSICALLY cut/milled/engraved, this page lists every claim the software makes about it + * (machine class, cut-vs-rapid split, tool-power range, canned cycles, envelope, skipped + * commands), and the user marks each claim ✓ / ✗ / n-a against what the real machine did. + * Export produces a markdown report to hand back — the discrepancies are the deliverable, and + * a clean pass on real hardware is what lets us flip a controller's tier experimental→validated. + * + * Everything runs locally in the page: parseGcodeToIR (synchronous, so we can wire the dialect + * hooks) + the CNC dialect set. No upload, no worker, no repo commit. + */ +import { parseGcodeToIR } from '@chestnutlabs/gcode-parser'; +import { createDialectRunner, grblLaser, grblMill, linuxCnc } from '@chestnutlabs/gcode-dialects'; +import { MoveKind } from '@chestnutlabs/toolpath-core'; + +const canvas = document.getElementById('view'); +const ctx = canvas.getContext('2d'); +const dropHint = document.getElementById('dropHint'); +const badges = document.getElementById('badges'); +const emptyMsg = document.getElementById('empty'); +const reportEl = document.getElementById('report'); +const fileInput = document.getElementById('file'); +const tgKind = document.getElementById('tgKind'); +const tgPower = document.getElementById('tgPower'); + +const COLORS = { + cut: '#ff8c42', + rapid: '#3a6a86', + extrude: '#4caf7d' +}; + +/** Current parse, kept so view toggles + resize can redraw without re-parsing. */ +let current = null; // { ir, viewMode } + +// ---- parse + analyze --------------------------------------------------------------------- + +function analyze(text, fileName) { + const runner = createDialectRunner([grblLaser(), grblMill(), linuxCnc()]); + const run = runner.createRun({ + selection: 'auto', + headText: text.slice(0, 64 * 1024), + tailText: text.slice(-16 * 1024) + }); + + // Count command frequency ourselves (canned cycles, spindle/laser, motion) by wrapping the + // dialect runner's onCommand — a read-only observer, it never alters the parse. + const cmdCounts = Object.create(null); + const onCommand = (event) => { + if (event.gcode) cmdCounts[event.gcode] = (cmdCounts[event.gcode] || 0) + 1; + if (run) run.onCommand(event); + }; + + const result = parseGcodeToIR(text, { + sourceId: fileName, + modalChannels: ['toolPower'], + onComment: run ? run.onComment : undefined, + onCommand + }); + const ir = result.ir; + const out = run ? run.finalize(ir) : { metadata: { raw: {} }, warnings: [] }; + const raw = (out.metadata && out.metadata.raw) || {}; + + // Move breakdown from the SoA kind column. + const seg = ir.segments; + let cut = 0; + let rapid = 0; + let extrude = 0; + for (let i = 0; i < seg.count; i++) { + const k = seg.kind[i]; + if (k & MoveKind.Cut) cut++; + else if (k & MoveKind.Extrude) extrude++; + else if (k & MoveKind.Travel) rapid++; + } + + // Tool-power range over CUTTING moves only — that's the power actually delivered to the work. A + // rapid can still carry a latched S (M4 not yet cancelled) but the beam is off, so counting it would + // over-report the "engaged" tally. Range collapses to lo===hi for a constant-power job. + let pLo = Infinity; + let pHi = -Infinity; + let pSamples = 0; + const power = seg.modal && seg.modal.toolPower; + if (power) { + for (let i = 0; i < seg.count; i++) { + if (!(seg.kind[i] & MoveKind.Cut)) continue; + const v = power[i]; + if (Number.isNaN(v)) continue; + pSamples++; + if (v < pLo) pLo = v; + if (v > pHi) pHi = v; + } + } + + const canned = ['g81', 'g82', 'g83'].reduce((n, g) => n + (cmdCounts[g] || 0), 0); + + return { + fileName, + ir, + raw, + counts: { cut, rapid, extrude, total: seg.count }, + power: pSamples ? { lo: pLo, hi: pHi, samples: pSamples } : null, + cmdCounts, + canned, + dialectWarnings: out.warnings || [] + }; +} + +// ---- rendering (top-down XY, cut vs rapid or power heatmap) ------------------------------- + +function fitView() { + const wrap = canvas.parentElement; + canvas.width = wrap.clientWidth; + canvas.height = wrap.clientHeight; + if (current) draw(); +} +window.addEventListener('resize', fitView); + +function powerColor(t) { + // dark violet → magenta → amber → white (cold→hot laser/spindle power). + const stops = [ + [0.15, 0.05, 0.3], + [0.7, 0.1, 0.5], + [1.0, 0.55, 0.1], + [1.0, 0.98, 0.85] + ]; + const x = Math.max(0, Math.min(1, t)) * (stops.length - 1); + const i = Math.floor(x); + const f = x - i; + const a = stops[i]; + const b = stops[Math.min(stops.length - 1, i + 1)]; + const c = a.map((v, j) => Math.round(255 * (v + (b[j] - v) * f))); + return `rgb(${c[0]},${c[1]},${c[2]})`; +} + +function draw() { + const { ir, viewMode } = current; + const seg = ir.segments; + const off = ir.header.originOffset; + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!seg.count) return; + + // Absolute XY bounds (delta + origin) over all moves. + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (let i = 0; i < seg.count; i++) { + const ax = seg.x0[i] + off.x, + bx = seg.x1[i] + off.x, + ay = seg.y0[i] + off.y, + by = seg.y1[i] + off.y; + if (ax < minX) minX = ax; + if (bx < minX) minX = bx; + if (ax > maxX) maxX = ax; + if (bx > maxX) maxX = bx; + if (ay < minY) minY = ay; + if (by < minY) minY = by; + if (ay > maxY) maxY = ay; + if (by > maxY) maxY = by; + } + const pad = 24; + const w = canvas.width - pad * 2; + const h = canvas.height - pad * 2; + const spanX = Math.max(1e-6, maxX - minX); + const spanY = Math.max(1e-6, maxY - minY); + const s = Math.min(w / spanX, h / spanY); + const ox = pad + (w - spanX * s) / 2; + const oy = pad + (h - spanY * s) / 2; + // Y flips: machine +Y is up, canvas +Y is down. + const px = (x) => ox + (x - minX) * s; + const py = (y) => canvas.height - (oy + (y - minY) * s); + + // Reuse the Cut-only range from analyze() so the drawing and the legend always agree. + const power = seg.modal && seg.modal.toolPower; + const pRange = current.power; // { lo, hi } over cutting moves, or null + const constantPower = pRange && pRange.hi - pRange.lo < 1e-6; + + ctx.lineWidth = 1.1; + ctx.lineCap = 'round'; + // Draw rapids first (thin, behind), then productive moves on top. + for (const pass of ['rapid', 'productive']) { + for (let i = 0; i < seg.count; i++) { + const k = seg.kind[i]; + const isProductive = k & (MoveKind.Cut | MoveKind.Extrude); + if (pass === 'rapid' && isProductive) continue; + if (pass === 'productive' && !isProductive) continue; + + let color; + if (viewMode === 'power' && pRange && isProductive) { + const v = power[i]; + // Constant-power job: one flat mid-hot tone (there's no range to map); else cold→hot. + color = Number.isNaN(v) + ? COLORS.cut + : constantPower + ? powerColor(0.7) + : powerColor((v - pRange.lo) / (pRange.hi - pRange.lo)); + } else if (isProductive) { + color = k & MoveKind.Extrude ? COLORS.extrude : COLORS.cut; + } else { + color = COLORS.rapid; + } + ctx.strokeStyle = color; + ctx.globalAlpha = isProductive ? 1 : viewMode === 'power' ? 0.15 : 0.5; + ctx.beginPath(); + ctx.moveTo(px(seg.x0[i] + off.x), py(seg.y0[i] + off.y)); + ctx.lineTo(px(seg.x1[i] + off.x), py(seg.y1[i] + off.y)); + ctx.stroke(); + } + } + ctx.globalAlpha = 1; +} + +// ---- claims report + checklist ----------------------------------------------------------- + +const marks = new Map(); // claimId -> { verdict, note } + +function markButtons(id) { + const state = marks.get(id) || { verdict: '', note: '' }; + const btn = (v, label, cls) => + ``; + return `
+ ${btn('ok', '✓ matches', 'sel-ok')} + ${btn('bad', '✗ wrong', 'sel-bad')} + ${btn('na', 'n/a', 'sel-na')} + +
`; +} + +function claim(id, question, valueHtml) { + return `
+
${question} ${valueHtml ? `${valueHtml}` : ''}
+ ${markButtons(id)} +
`; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]); +} +function escapeAttr(s) { + return String(s).replace(/"/g, '"').replace(/ 0 && !!machineClass; + + // Group parser warnings (esp. unsupported-command) by code. + const warnGroups = Object.create(null); + for (const w of ir.header.warnings || []) { + warnGroups[w.code] = warnGroups[w.code] || { count: 0, sample: w.message }; + warnGroups[w.code].count += w.count || 1; + } + + const b = ir.boundsWithTravel; + const dim = (lo, hi) => (hi - lo).toFixed(1); + + let html = ''; + + // --- headline: detection --- + html += `

What we detected

`; + if (detected) { + html += `
+
Machine class: ${escapeHtml(machineClass)}
+
Controller: ${escapeHtml(controller || dialects[0].id)}
+
Confidence tier: ${escapeHtml( + tier || 'experimental' + )}
+
`; + } else { + html += `
Not recognized as CNC/laser. +
No CNC dialect matched — either it's an FDM file, or a controller/emitter we + don't yet fingerprint. The claims below still come from the raw parse.
`; + } + + // --- the checkable claims --- + html += `

Check each against the real cut

`; + + if (detected) { + html += claim( + 'machineClass', + `We call this a ${escapeHtml(machineClass)} job. Is that the machine you ran it on?`, + '' + ); + } + + html += claim( + 'moveSplit', + `${counts.cut.toLocaleString()} cutting/burning moves and ${counts.rapid.toLocaleString()} rapids. + Do the highlighted (orange) moves match where the tool was actually engaged, and the faded ones where it traveled in the air?`, + '' + ); + + if (power) { + const source = machineClass === 'mill' ? 'spindle' : 'laser'; + const constant = power.hi - power.lo < 1e-6; + const desc = constant + ? `Constant ${escapeHtml(powerLabel)} ${fmt(power.lo)} across all + ${power.samples.toLocaleString()} cutting moves — the ${source} was set once and never modulated + (so the Power view is a single flat color; there's no ramp to show). Was it a single-power job?` + : `${escapeHtml(powerLabel)} ranges ${fmt(power.lo)}${fmt( + power.hi + )} across ${power.samples.toLocaleString()} cutting moves. Switch to the Power view (colors + run cold→hot) — does the intensity track how the ${source} actually behaved?`; + html += claim('power', desc, ''); + } else { + html += `
No modal tool-power (S while engaged) seen — power claim n/a for this file.
`; + } + + if (canned > 0) { + html += claim( + 'canned', + `${canned} drilling/canned-cycle call(s) (G81/G82/G83). Do the drilled holes land where — and as deep as — the machine actually drilled?`, + '' + ); + } + + html += claim( + 'envelope', + `Work envelope ${dim(b.min.x, b.max.x)} × ${dim(b.min.y, b.max.y)} × ${dim( + b.min.z, + b.max.z + )} ${units} (X×Y×Z). Does that match the real part's dimensions?`, + '' + ); + + html += claim('geometry', `Does the overall path shape on the left match the real part / toolpath?`, ''); + + // --- honesty / capability tiers --- + html += `

Capability honesty (what tier we report)

`; + html += `
`; + for (const key of ['cutMoves', 'toolPower', 'cannedCycles']) { + if (!cap[key]) continue; + html += `
${key}: ${cap[key]}
`; + } + html += `
`; + if (tier === 'experimental') { + html += `
These read inferred because ${escapeHtml( + controller || 'this controller' + )} is still experimental. A clean ✓ pass here is exactly what lets us flip it to validated (→ these become known, warnings drop).
`; + } + + // --- commands we skipped (the gap list) --- + html += `

Commands we skipped

`; + const unsupported = warnGroups['unsupported-command']; + if (unsupported) { + html += `
${ + unsupported.count + } command(s) the parser didn't interpret. If the real part has features missing from the render, they're likely here.
+
${escapeHtml(unsupported.sample)}
`; + } else { + html += `
None — every command in the file was interpreted.
`; + } + + // --- all warnings --- + const warnCodes = Object.keys(warnGroups); + if (warnCodes.length) { + html += `

All parser warnings

`; + for (const code of warnCodes) { + html += `
${escapeHtml(code)} ×${warnGroups[code].count}
${escapeHtml( + warnGroups[code].sample + )}
`; + } + html += `
`; + } + + // --- command frequency (reference) --- + html += `

Command frequency

`; + const freq = Object.entries(cmdCounts) + .filter(([g]) => g) + .sort((x, y) => y[1] - x[1]) + .slice(0, 16); + html += freq.map(([g, n]) => `${escapeHtml(g.toUpperCase())} ${n}`).join('   '); + html += `
`; + + html += `
+ + +
`; + + reportEl.innerHTML = html; + reportEl.style.display = 'block'; + emptyMsg.style.display = 'none'; + + wireMarks(); + document.getElementById('exportBtn').addEventListener('click', () => exportReport(a, 'download')); + document.getElementById('copyBtn').addEventListener('click', () => exportReport(a, 'clipboard')); +} + +function fmt(v) { + return Number.isInteger(v) ? String(v) : v.toFixed(1); +} + +function wireMarks() { + reportEl.querySelectorAll('button[data-claim]').forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.dataset.claim; + const state = marks.get(id) || { verdict: '', note: '' }; + state.verdict = state.verdict === btn.dataset.verdict ? '' : btn.dataset.verdict; + marks.set(id, state); + // repaint just this claim's buttons + const row = btn.closest('.claim'); + row.querySelectorAll('button[data-claim]').forEach((b2) => { + const sel = state.verdict === b2.dataset.verdict; + b2.className = sel ? { ok: 'sel-ok', bad: 'sel-bad', na: 'sel-na' }[b2.dataset.verdict] : ''; + }); + }); + }); + reportEl.querySelectorAll('input[data-note]').forEach((inp) => { + inp.addEventListener('input', () => { + const id = inp.dataset.note; + const state = marks.get(id) || { verdict: '', note: '' }; + state.note = inp.value; + marks.set(id, state); + }); + }); +} + +const CLAIM_LABELS = { + machineClass: 'Machine class correct', + moveSplit: 'Cut-vs-rapid split correct', + power: 'Tool-power ramp correct', + canned: 'Canned/drilling cycles correct', + envelope: 'Work envelope matches part', + geometry: 'Overall path shape matches' +}; + +function exportReport(a, dest) { + const { ir, raw, counts, power, canned } = a; + const tier = raw['cnc.validationTier'] || 'n/a'; + const lines = []; + lines.push(`# Hardware validation — ${a.fileName}`); + lines.push(''); + lines.push( + `- Detected: **${raw['cnc.machineClass'] || 'NOT DETECTED'}** / ${raw['cnc.controller'] || '—'} (tier: ${tier})` + ); + lines.push(`- Moves: ${counts.cut} cut, ${counts.rapid} rapid, ${counts.total} total`); + if (power) + lines.push( + power.hi - power.lo < 1e-6 + ? `- Tool power: constant ${fmt(power.lo)} over ${power.samples} cutting moves` + : `- Tool power: ${fmt(power.lo)}–${fmt(power.hi)} over ${power.samples} cutting moves` + ); + if (canned) lines.push(`- Canned cycles: ${canned}`); + const b = ir.boundsWithTravel; + lines.push( + `- Envelope: ${(b.max.x - b.min.x).toFixed(1)} × ${(b.max.y - b.min.y).toFixed(1)} × ${(b.max.z - b.min.z).toFixed( + 1 + )} ${ir.header.units}` + ); + lines.push(''); + lines.push('## Findings'); + lines.push(''); + lines.push('| Claim | Verdict | Notes |'); + lines.push('| --- | --- | --- |'); + for (const [id, label] of Object.entries(CLAIM_LABELS)) { + const m = marks.get(id); + if (!m || (!m.verdict && !m.note)) continue; + const v = { ok: '✓ matches', bad: '✗ WRONG', na: 'n/a' }[m.verdict] || '—'; + lines.push(`| ${label} | ${v} | ${(m.note || '').replace(/\|/g, '\\|')} |`); + } + // Any claims left unmarked + const unmarked = Object.entries(CLAIM_LABELS).filter(([id]) => { + const m = marks.get(id); + return !m || (!m.verdict && !m.note); + }); + if (unmarked.length) { + lines.push(''); + lines.push(`_Unreviewed: ${unmarked.map(([, l]) => l).join(', ')}_`); + } + const md = lines.join('\n'); + + if (dest === 'clipboard') { + navigator.clipboard.writeText(md).then( + () => flash('Copied report to clipboard'), + () => flash('Clipboard blocked — use Export instead') + ); + } else { + const blob = new Blob([md], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = a.fileName.replace(/\.[^.]+$/, '') + '.validation.md'; + link.click(); + URL.revokeObjectURL(url); + } +} + +function flash(msg) { + const el = document.createElement('div'); + el.textContent = msg; + el.style.cssText = + 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1c5a5f;color:#eafeff;padding:8px 16px;border-radius:6px;font-size:13px;z-index:10'; + document.body.appendChild(el); + setTimeout(() => el.remove(), 1800); +} + +// ---- load flow --------------------------------------------------------------------------- + +async function loadFile(file) { + const text = await file.text(); + marks.clear(); + const a = analyze(text, file.name); + current = { + ir: a.ir, + viewMode: tgPower.classList.contains('on') ? 'power' : 'kind', + power: a.power, + powerLabel: a.raw['cnc.toolPowerLabel'] || 'tool power (S)', + hasExtrude: a.counts.extrude > 0 + }; + dropHint.classList.add('hide'); + updateBadges(); + fitView(); + renderReport(a); +} + +/** The view legend, keyed to the active color mode — cut/rapid swatches, or a cold→hot power scale + * with the real min/max (or a "constant" note when the job never modulates power). */ +function updateBadges() { + if (!current) { + badges.style.display = 'none'; + return; + } + badges.style.display = 'flex'; + if (current.viewMode === 'power') { + const p = current.power; + if (!p) { + badges.innerHTML = `No tool-power (S) recorded — nothing to color by power.`; + return; + } + if (p.hi - p.lo < 1e-6) { + badges.innerHTML = `constant ${escapeHtml(current.powerLabel)} ${fmt(p.lo)} (no variation)`; + return; + } + const bar = 'linear-gradient(to right,' + [0, 0.33, 0.66, 1].map((t) => powerColor(t)).join(',') + ')'; + badges.innerHTML = + `${escapeHtml(current.powerLabel)}` + + `${fmt(p.lo)}` + + `` + + `${fmt(p.hi)}` + + `cold → hot · rapids faded`; + return; + } + badges.innerHTML = + `${ + current.hasExtrude ? 'cut / extrude' : 'cut / burn' + }` + `rapid / travel`; +} + +fileInput.addEventListener('change', () => { + if (fileInput.files[0]) loadFile(fileInput.files[0]); +}); + +/** Load one of the bundled test-data fixtures by name (?sample=easel) as a synthesized File — + * a zero-setup "try it" path that runs the exact same flow as a user-picked file. */ +async function loadSample(name) { + const res = await fetch(`./gcodes/${name}.gcode`); + if (!res.ok) return; + const text = await res.text(); + await loadFile(new File([text], `${name}.gcode`, { type: 'text/plain' })); +} +const sample = new URLSearchParams(location.search).get('sample'); +if (sample) loadSample(sample.replace(/[^\w.-]/g, '')); + +// drag & drop +const wrap = document.getElementById('viewWrap'); +['dragover', 'dragenter'].forEach((ev) => + wrap.addEventListener(ev, (e) => { + e.preventDefault(); + wrap.style.outline = '2px dashed #1c5a5f'; + }) +); +['dragleave', 'drop'].forEach((ev) => + wrap.addEventListener(ev, (e) => { + e.preventDefault(); + wrap.style.outline = 'none'; + }) +); +wrap.addEventListener('drop', (e) => { + const f = e.dataTransfer.files[0]; + if (f) loadFile(f); +}); + +// view toggles +tgKind.addEventListener('click', () => { + tgKind.classList.add('on'); + tgPower.classList.remove('on'); + if (current) { + current.viewMode = 'kind'; + updateBadges(); + draw(); + } +}); +tgPower.addEventListener('click', () => { + tgPower.classList.add('on'); + tgKind.classList.remove('on'); + if (current) { + current.viewMode = 'power'; + updateBadges(); + draw(); + } +}); + +fitView(); diff --git a/tools/demo/validate.html b/tools/demo/validate.html new file mode 100644 index 00000000..883d1329 --- /dev/null +++ b/tools/demo/validate.html @@ -0,0 +1,318 @@ + + + + + + CNC / laser validation harness — #189 hardware ground-truth + + + +
+
+

CNC / laser validation harness

+ #189 · load a file you've physically cut → check each claim against the real machine +
+
+ +
+
+ +
+ +
+ Drop a CNC / laser G-code file here, or use Load file…
+ Your file is parsed locally in this page — nothing is uploaded, nothing is committed. +
+
+ + +
+ +
+ +
+
Load a file to see what the software claims about it.
+ +
+ + + + diff --git a/tools/pack-check-snapshots.json b/tools/pack-check-snapshots.json index 51c2c57a..d4876b4c 100644 --- a/tools/pack-check-snapshots.json +++ b/tools/pack-check-snapshots.json @@ -45,6 +45,9 @@ "dist/annotate.d.ts", "dist/annotate.d.ts.map", "dist/annotate.js", + "dist/cnc.d.ts", + "dist/cnc.d.ts.map", + "dist/cnc.js", "dist/contracts.d.ts", "dist/contracts.d.ts.map", "dist/contracts.js",