diff --git a/README.md b/README.md index a5147165..09965583 100644 --- a/README.md +++ b/README.md @@ -395,7 +395,7 @@ row inspects the *toolpath*, the bottom row presents the *source model* (see Design rationale, boundaries, and the accepted architecture live in [`docs/design/`](docs/design); the [docs index](docs/README.md) maps the whole set. Fourteen -packages publish to npm in lockstep (currently **v0.17.0**) with npm provenance. +packages publish to npm in lockstep (currently **v0.18.0**) with npm provenance. ## Development diff --git a/docs/README.md b/docs/README.md index 3204c67e..cdf5cc66 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-08-27 — **v0.17.0 shipped to npm** (14 lockstep pkgs): `frameContent: 'object'` now frames the **printed model** — a non-model classifier excludes slicer housekeeping (skirt, brim, raft, prime/wipe tower, purge) by object membership when the file carries it, else by feature role, producing a new additive `modelBounds` and a `nonModelClassification` confidence (`known` / `inferred` / `unavailable`); the framing precedence is `modelBounds → objectBounds → bounds`, so a Bambu wipe tower at the plate edge no longer inflates the frame — validated on a real 6.5 M-segment four-colour plate ([DD-026](design/DD-026-non-model-geometry-classification-and-object-framing.md) T2), and the OrcaSlicer/Bambu adapter now reads `FLUSH_START/END` and `WIPE_TOWER_START/END` brackets into first-class `Purge` / `WipeTower` roles. `renderStill` gains **`tubeByteBudget`** so a deployment with RAM to spare can retain full tubes on a large plate instead of silently falling to lines behind the conservative default — a separate axis from the parallel build's `geometryMemoryBudgetBytes` (CPU→build speed vs RAM→tubes-retained). And a geometry-worker-pool failure now degrades to a **serial main-thread tube build** — never an unhandled rejection or a silent drop to lines — so the [DD-028](design/DD-028-parallel-geometry-worker-pool.md) pool is safe to enable in headless bundlers that cannot construct the worker URL. Prior: **v0.16.0** — heavy toolpaths now build their tube geometry **off the main thread** on a capability-sized, memory-bounded worker pool — byte-identical to the serial build and degrading to continuous lines (never chopped) if a worker fails ([DD-028](design/DD-028-parallel-geometry-worker-pool.md)); the viewer prepares the **complete** geometry and reveals it **once** via `progressivePreview: 'auto' | 'lines' | 'hold' | 'off'` (`'hold'` reveals only the final, correctly-coloured build — no low-quality preview, no segment-dropping), backed by a staged `stage` event that carries a real building-geometry `%` ([DD-029](design/DD-029-preparation-and-reveal.md)); a new **render-diagnostics** surface — `getRenderStats()` plus a `renderStats` event — reports whether WebGL is running on **hardware or software**, the GPU renderer, geometry mode, segment/vertex counts, draw calls, and timings, with honest `null`s rather than guesses ([DD-027](design/DD-027-render-diagnostics.md)); and the slicer adapters now capture real **object labels and feature roles** from OrcaSlicer, PrusaSlicer, ideaMaker, and Simplify3D, with first-class non-model **FeatureRoles** — PrimeTower, WipeTower, Raft, Purge ([DD-026](design/DD-026-non-model-geometry-classification-and-object-framing.md) T1/T2). Prior: **v0.15.0** — a **`progressivePreview`** curtain (`'lines'` default / `'hold'` / `'off'`) over the during-parse preview, across the toolpath renderer, core controller, and all four adapters ([#373](https://github.com/ChestnutLabs/gcode-preview/pull/373)). `'hold'` keeps the progress signal (drive a `%` bar from `parse-progress`) but reveals **only the final, correctly-coloured, policy-quality build** — replacing the neutral-then-recolour "double render" on streamed files with a single clean reveal; `'off'` suppresses the built-in preview for a consumer-driven loading treatment; `'lines'` (the default) preserves today's streaming line preview. Orthogonal to `quality`/`qualityMode` — the revealed representation is always the policy-correct one, never a silent large-file lines fallback. Prior: **v0.14.0** — a **`qualityMode`** fidelity policy (Full / Adaptive / Fast) across the toolpath renderer, core controller, and all four adapters ([DD-023](design/DD-023-capability-aware-render-budget.md) §4 D6, Phase B) — `'full'` renders the COMPLETE representation (no every-Nth decimation, full-radial continuous tubes, no budget→lines fallback); `'adaptive'` (default) is the capability-aware auto path; `'fast'` is flat lines. Plus a **continuity correction: tubes are never segment-decimated** — `autoDecimation`'s every-Nth drop was chopping tube geometry into disconnected stubs (the RR-006 continuity break via the decimation lever the v0.12.0 fix didn't cover); tube memory is now bounded ONLY by continuity-preserving radial reduction, degrading to continuous lines when even a 3-sided tube won't fit — never chopped. The honest order is full-radial tubes → lower-radial tubes → continuous lines. (Classifier-driven auto budget + the too-heavy signal land later.) Prior: **v0.13.0** (14 lockstep pkgs), a farm-workload batch: render the colour/structure a source model actually carries, stop over-degrading normal large plates, and lay the capability-aware groundwork. **Toolpath:** the tube memory budget now counts only **extrude** segments (not travel/wipe), so a heavy-travel plate (800+ parts) renders as continuous tubes instead of dropping to lines; a new **`filament` colour mode** renders a toolpath in the file's OWN filament colours (resolved from parse metadata before the first build — no neutral-then-recolour flash on raw `.gcode`). **Source model** ([`gcode-model-renderer`](../packages/gcode-model-renderer)): **multi-plate structure** ([DD-025](design/DD-025-multi-plate-model-structure.md) Phase A — `ModelScene.plates` + placement-level `plateIds` + `capabilities.plates`, declared-only), **Bambu/Orca per-object extruder colours** (a `.3mf` coloured via the project convention renders in colour, not neutral), and **staged loading progress** ([DD-024](design/DD-024-staged-loading-progress.md) Phase A — typed, consumer-neutral, generation-scoped, never a faked %). **Capability groundwork** ([DD-023](design/DD-023-capability-aware-render-budget.md) Phase A): a shared fail-safe WebGL hw/sw classifier + `QualityPolicy`/`CapabilityHint` types (no behaviour change yet; capability-aware budgets + `qualityMode` land later). Prior: **v0.12.0** — fixes the two regressions surfaced against v0.11.0 and promotes the tube memory bound to an honest control. **Tube rendering** ([RR-006](research/RR-006-tube-mesh-memory-and-large-file-budget.md) correction) now bounds memory by **coarsening the cross-section** (fewer radial sides) at full segment continuity — replacing the v0.10.0 drop-every-Nth-segment budget that shredded tubes into spiky capped stubs; new public `tubeByteBudget` option, `tubeSegmentBudget` deprecated, lines fallback only when even a 3-sided tube can't fit. **Model estimate** now counts **unique** master triangles, not baked-per-placement, so a full-sheet instanced `.3mf` plate (~40 reused copies) parses/renders at default limits instead of falsely rejecting as "too large" (instance bomb still guarded by `maxInstances`). Prior: **v0.11.0** — **instance-aware source-model rendering** ([DD-022](design/DD-022-model-instancing-and-lod.md), Phases 0–1) — a full-sheet `.3mf` that reuses a master mesh via production-extension components / repeated build items now renders each master **once** via GPU instancing (three `InstancedMesh`) instead of baking a copy per placement, so memory scales with unique geometry not copy count (a ~40× instanced plate that used to exceed the triangle limit now renders); plus a **sub-second byte-estimate fast-reject** for genuinely oversize / instance-bomb files, and `instancedCount` + `decimationApplied` disclosure on the model `ready`/still result. **v0.10.0** added a **tube-mesh memory budget** so large forced-`tubes` renders (≈1.6 M+ segments) no longer OOM a 2 GB render worker — tubes decimate to a `TUBE_SEGMENT_BUDGET`, disclosed via `decimationApplied` (also on `RenderStillResult`), layer boundaries preserved ([RR-006](research/RR-006-tube-mesh-memory-and-large-file-budget.md)); plus a fix so `frameContent:'object'` frames the printed object on AnycubicSlicerNext and every object-labeling file (`objectBounds` is refreshed after dialect annotation). **v0.9.0** shipped the **interactive source-model viewer** `createModelViewer` in `@chestnutlabs/gcode-model-renderer` — the live orbit/zoom/pan analogue of `renderModelStill` for an STL or 3MF, over a shared interactive "stage" extracted in `gcode-renderer-three`, with an open-`kind` model-source loader registry (STL/3MF now, OBJ/STEP/PLY later without an API change), capability-honest color pass-through, and a `renderer-unsupported` fallback ([DD-021](design/DD-021-interactive-model-viewer.md)). v0.8.1 added production Bambu/Orca 3MF `paint_color` multicolor decode ([RR-005](research/RR-005-3mf-paint-color-facet-format.md)) so a designer's painted model renders in its true colors without slicing, plus a filament-slot dedup fix. v0.7.0 shipped the presentation package `@chestnutlabs/gcode-model-renderer` (headless `renderModelStill`, STL + 3MF multi-object/material, [DD-018](design/DD-018-model-renderer.md)), the 3MF Production-Extension (`p:path`) reader, and a toolpath-renderer batch — slice metadata on `ready`, structured multi-plate metadata, independent build-volume cage, frame-to-content, interaction-aware render quality ([DD-020](design/DD-020-interaction-aware-quality.md)), and a `renderStill` background option. v0.5.x added camera preset-views + serializable `CameraState`, zoom-to-cursor, keyboard-a11y, `capabilities`/`warnings` on `ready`, and the Vue/Svelte adapter mount-prop fixes (epics #279/#296/#306/DD-021). DD-017 RS274NGC parametric programs proposed, unbuilt) +## Current state (updated 2026-08-27 — **v0.18.0 shipped to npm** (14 lockstep pkgs): the parser now executes the full **RS274NGC programming layer** that CAM / LinuxCNC output uses to *generate* geometry — numbered/named/global **parameters**, bracket **expressions** (degree trig + the full operator set), **O-word control flow** (`if`/`elseif`/`else`, `while`/`do`/`repeat`, `break`/`continue`), and in-file **subroutines** (`sub`/`call`/`return` with recursion-safe LinuxCNC scoping) — so a bolt circle written as a `while` loop, or a feature factored into a called sub, resolves to the real toolpath instead of rendering empty; all bounded (`maxProgramIterations` / `maxCallDepth` — a hostile program wastes only bounded CPU), honesty-tiered (`parametricProgram` `known` / `approximated`), and **byte-identical** for FDM / non-parametric input ([DD-017](design/DD-017-rs274ngc-parametric-programs.md), phases 1–3). Plus a generic **renderer/viewer interoperability** batch ([DD-030](design/DD-030-renderer-viewer-interop.md)): interactive **`capture()` → `Blob`** (render-to-target — user-selected thumbnails, large-file fallback, screenshots) on the controls (all four adapters inherit it) and the model viewer; **per-plate render scope** so a single-plate variant of a multi-plate project renders and frames just *that* plate (`renderModelStill({ renderScope })` + `ModelViewer.setRenderScope()`); and **non-rectangular build beds** (`BuildVolumeDef.shape` — delta / round / polygon, with the floor grid clipped to the true outline). Prior: **v0.17.0** — `frameContent: 'object'` now frames the **printed model** — a non-model classifier excludes slicer housekeeping (skirt, brim, raft, prime/wipe tower, purge) by object membership when the file carries it, else by feature role, producing a new additive `modelBounds` and a `nonModelClassification` confidence (`known` / `inferred` / `unavailable`); the framing precedence is `modelBounds → objectBounds → bounds`, so a Bambu wipe tower at the plate edge no longer inflates the frame — validated on a real 6.5 M-segment four-colour plate ([DD-026](design/DD-026-non-model-geometry-classification-and-object-framing.md) T2), and the OrcaSlicer/Bambu adapter now reads `FLUSH_START/END` and `WIPE_TOWER_START/END` brackets into first-class `Purge` / `WipeTower` roles. `renderStill` gains **`tubeByteBudget`** so a deployment with RAM to spare can retain full tubes on a large plate instead of silently falling to lines behind the conservative default — a separate axis from the parallel build's `geometryMemoryBudgetBytes` (CPU→build speed vs RAM→tubes-retained). And a geometry-worker-pool failure now degrades to a **serial main-thread tube build** — never an unhandled rejection or a silent drop to lines — so the [DD-028](design/DD-028-parallel-geometry-worker-pool.md) pool is safe to enable in headless bundlers that cannot construct the worker URL. Prior: **v0.16.0** — heavy toolpaths now build their tube geometry **off the main thread** on a capability-sized, memory-bounded worker pool — byte-identical to the serial build and degrading to continuous lines (never chopped) if a worker fails ([DD-028](design/DD-028-parallel-geometry-worker-pool.md)); the viewer prepares the **complete** geometry and reveals it **once** via `progressivePreview: 'auto' | 'lines' | 'hold' | 'off'` (`'hold'` reveals only the final, correctly-coloured build — no low-quality preview, no segment-dropping), backed by a staged `stage` event that carries a real building-geometry `%` ([DD-029](design/DD-029-preparation-and-reveal.md)); a new **render-diagnostics** surface — `getRenderStats()` plus a `renderStats` event — reports whether WebGL is running on **hardware or software**, the GPU renderer, geometry mode, segment/vertex counts, draw calls, and timings, with honest `null`s rather than guesses ([DD-027](design/DD-027-render-diagnostics.md)); and the slicer adapters now capture real **object labels and feature roles** from OrcaSlicer, PrusaSlicer, ideaMaker, and Simplify3D, with first-class non-model **FeatureRoles** — PrimeTower, WipeTower, Raft, Purge ([DD-026](design/DD-026-non-model-geometry-classification-and-object-framing.md) T1/T2). Prior: **v0.15.0** — a **`progressivePreview`** curtain (`'lines'` default / `'hold'` / `'off'`) over the during-parse preview, across the toolpath renderer, core controller, and all four adapters ([#373](https://github.com/ChestnutLabs/gcode-preview/pull/373)). `'hold'` keeps the progress signal (drive a `%` bar from `parse-progress`) but reveals **only the final, correctly-coloured, policy-quality build** — replacing the neutral-then-recolour "double render" on streamed files with a single clean reveal; `'off'` suppresses the built-in preview for a consumer-driven loading treatment; `'lines'` (the default) preserves today's streaming line preview. Orthogonal to `quality`/`qualityMode` — the revealed representation is always the policy-correct one, never a silent large-file lines fallback. Prior: **v0.14.0** — a **`qualityMode`** fidelity policy (Full / Adaptive / Fast) across the toolpath renderer, core controller, and all four adapters ([DD-023](design/DD-023-capability-aware-render-budget.md) §4 D6, Phase B) — `'full'` renders the COMPLETE representation (no every-Nth decimation, full-radial continuous tubes, no budget→lines fallback); `'adaptive'` (default) is the capability-aware auto path; `'fast'` is flat lines. Plus a **continuity correction: tubes are never segment-decimated** — `autoDecimation`'s every-Nth drop was chopping tube geometry into disconnected stubs (the RR-006 continuity break via the decimation lever the v0.12.0 fix didn't cover); tube memory is now bounded ONLY by continuity-preserving radial reduction, degrading to continuous lines when even a 3-sided tube won't fit — never chopped. The honest order is full-radial tubes → lower-radial tubes → continuous lines. (Classifier-driven auto budget + the too-heavy signal land later.) Prior: **v0.13.0** (14 lockstep pkgs), a farm-workload batch: render the colour/structure a source model actually carries, stop over-degrading normal large plates, and lay the capability-aware groundwork. **Toolpath:** the tube memory budget now counts only **extrude** segments (not travel/wipe), so a heavy-travel plate (800+ parts) renders as continuous tubes instead of dropping to lines; a new **`filament` colour mode** renders a toolpath in the file's OWN filament colours (resolved from parse metadata before the first build — no neutral-then-recolour flash on raw `.gcode`). **Source model** ([`gcode-model-renderer`](../packages/gcode-model-renderer)): **multi-plate structure** ([DD-025](design/DD-025-multi-plate-model-structure.md) Phase A — `ModelScene.plates` + placement-level `plateIds` + `capabilities.plates`, declared-only), **Bambu/Orca per-object extruder colours** (a `.3mf` coloured via the project convention renders in colour, not neutral), and **staged loading progress** ([DD-024](design/DD-024-staged-loading-progress.md) Phase A — typed, consumer-neutral, generation-scoped, never a faked %). **Capability groundwork** ([DD-023](design/DD-023-capability-aware-render-budget.md) Phase A): a shared fail-safe WebGL hw/sw classifier + `QualityPolicy`/`CapabilityHint` types (no behaviour change yet; capability-aware budgets + `qualityMode` land later). Prior: **v0.12.0** — fixes the two regressions surfaced against v0.11.0 and promotes the tube memory bound to an honest control. **Tube rendering** ([RR-006](research/RR-006-tube-mesh-memory-and-large-file-budget.md) correction) now bounds memory by **coarsening the cross-section** (fewer radial sides) at full segment continuity — replacing the v0.10.0 drop-every-Nth-segment budget that shredded tubes into spiky capped stubs; new public `tubeByteBudget` option, `tubeSegmentBudget` deprecated, lines fallback only when even a 3-sided tube can't fit. **Model estimate** now counts **unique** master triangles, not baked-per-placement, so a full-sheet instanced `.3mf` plate (~40 reused copies) parses/renders at default limits instead of falsely rejecting as "too large" (instance bomb still guarded by `maxInstances`). Prior: **v0.11.0** — **instance-aware source-model rendering** ([DD-022](design/DD-022-model-instancing-and-lod.md), Phases 0–1) — a full-sheet `.3mf` that reuses a master mesh via production-extension components / repeated build items now renders each master **once** via GPU instancing (three `InstancedMesh`) instead of baking a copy per placement, so memory scales with unique geometry not copy count (a ~40× instanced plate that used to exceed the triangle limit now renders); plus a **sub-second byte-estimate fast-reject** for genuinely oversize / instance-bomb files, and `instancedCount` + `decimationApplied` disclosure on the model `ready`/still result. **v0.10.0** added a **tube-mesh memory budget** so large forced-`tubes` renders (≈1.6 M+ segments) no longer OOM a 2 GB render worker — tubes decimate to a `TUBE_SEGMENT_BUDGET`, disclosed via `decimationApplied` (also on `RenderStillResult`), layer boundaries preserved ([RR-006](research/RR-006-tube-mesh-memory-and-large-file-budget.md)); plus a fix so `frameContent:'object'` frames the printed object on AnycubicSlicerNext and every object-labeling file (`objectBounds` is refreshed after dialect annotation). **v0.9.0** shipped the **interactive source-model viewer** `createModelViewer` in `@chestnutlabs/gcode-model-renderer` — the live orbit/zoom/pan analogue of `renderModelStill` for an STL or 3MF, over a shared interactive "stage" extracted in `gcode-renderer-three`, with an open-`kind` model-source loader registry (STL/3MF now, OBJ/STEP/PLY later without an API change), capability-honest color pass-through, and a `renderer-unsupported` fallback ([DD-021](design/DD-021-interactive-model-viewer.md)). v0.8.1 added production Bambu/Orca 3MF `paint_color` multicolor decode ([RR-005](research/RR-005-3mf-paint-color-facet-format.md)) so a designer's painted model renders in its true colors without slicing, plus a filament-slot dedup fix. v0.7.0 shipped the presentation package `@chestnutlabs/gcode-model-renderer` (headless `renderModelStill`, STL + 3MF multi-object/material, [DD-018](design/DD-018-model-renderer.md)), the 3MF Production-Extension (`p:path`) reader, and a toolpath-renderer batch — slice metadata on `ready`, structured multi-plate metadata, independent build-volume cage, frame-to-content, interaction-aware render quality ([DD-020](design/DD-020-interaction-aware-quality.md)), and a `renderStill` background option. v0.5.x added camera preset-views + serializable `CameraState`, zoom-to-cursor, keyboard-a11y, `capabilities`/`warnings` on `ready`, and the Vue/Svelte adapter mount-prop fixes (epics #279/#296/#306/DD-021). DD-017 RS274NGC parametric programs proposed, unbuilt) | Epic | Status | Gate | |---|---|---| @@ -67,7 +67,7 @@ golden/native fixtures + manifest with CI validation, boundary lint, license CI consumer smoke harnesses, plus the five framework adapters (`gcode-preview-core`/`-vue`/`-react`/ `-svelte`/`-element`) and the presentation renderer (`gcode-model-renderer`). CI (`build`) is a required check on `main`/`dev`. **All fourteen `@chestnutlabs/*` packages are published to npm at -`v0.17.0`** (lockstep, npm provenance); `main` is the release branch, publishing from tagged GitHub +`v0.18.0`** (lockstep, npm provenance); `main` is the release branch, publishing from tagged GitHub Releases (`v0.1.0` E7/DD-008, `v0.2.0` E9/DD-009, `v0.3.0` E8/#188, `v0.4.0` #189 non-extrusion, `v0.5.x` hardening #279, `v0.6.0`/`v0.7.0` ModelRenderer + toolpath batch #296/#306, `v0.8.1` 3MF `paint_color` multicolor decode, `v0.9.0` interactive `createModelViewer` #296/DD-021, `v0.10.0` @@ -75,5 +75,8 @@ tube-mesh memory budget + object-framing fix RR-006, `v0.11.0` model instancing `v0.12.0` tube cross-section budget + unique-triangle estimate fixes, `v0.13.0` multi-plate model structure DD-025 + Bambu extruder/filament colours + model load progress DD-024 + tube extrude-only budget + capability classifier DD-023, all Phase A; `v0.14.0` qualityMode Full/Adaptive/Fast DD-023 -Phase B + tubes-never-chopped continuity fix; `v0.15.0` `progressivePreview` curtain (lines/hold/off) #373; `v0.16.0` off-main-thread geometry worker pool + single-clean-reveal + render diagnostics + slicer object/feature capture DD-026/027/028/029; `v0.17.0` non-model classifier + `modelBounds` object framing + FLUSH/WIPE_TOWER brackets DD-026 T2, `tubeByteBudget` on `renderStill`, geometry-pool→serial-tubes fallback DD-028). +Phase B + tubes-never-chopped continuity fix; `v0.15.0` `progressivePreview` curtain (lines/hold/off) #373; `v0.16.0` off-main-thread geometry worker pool + single-clean-reveal + render diagnostics + slicer object/feature capture DD-026/027/028/029; `v0.17.0` non-model classifier + `modelBounds` object framing + FLUSH/WIPE_TOWER brackets DD-026 T2, `tubeByteBudget` on `renderStill`, geometry-pool→serial-tubes fallback DD-028; `v0.18.0` RS274NGC +programming layer — parameters/expressions/O-word control flow/subroutines DD-017 (phases 1–3) + +renderer/viewer interop — interactive `capture()`, per-plate render scope, non-rectangular build beds +DD-030). Changesets accumulate on `dev` for the next release. diff --git a/docs/design/DD-017-rs274ngc-parametric-programs.md b/docs/design/DD-017-rs274ngc-parametric-programs.md index 72c1a09c..6894290b 100644 --- a/docs/design/DD-017-rs274ngc-parametric-programs.md +++ b/docs/design/DD-017-rs274ngc-parametric-programs.md @@ -1,8 +1,11 @@ # DD-017 — RS274NGC parametric programs: parameters, expressions & O-word flow (#189 phase 7) -**Status:** **Proposed** +**Status:** **Accepted (Phases 1–2)** — Phase 1 (parameters + expressions, D2/D3, §5.1) and Phase 2 +(O-word control flow — conditionals + bounded loops, D4/D5/D6 for `if`/`while`/`do`/`repeat`, §5.2) both +accepted & built 2026-08-27; **Phases 3–4 (subroutines, capability/docs polish) remain Proposed**, gated +on measured Phase 2 results. **Authors/Owners:** Nathaniel Chestnut -**Date:** 2026-07-29 · **Last revised:** 2026-07-29 +**Date:** 2026-07-29 · **Last revised:** 2026-08-27 **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). @@ -202,3 +205,5 @@ the existing SoA). No renderer/adapter/color changes (they consume the same segm | 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 | +| 2026-08-27 | **Phase 1 accepted & built** (parameters + expressions only; D2/D3/§5.1). New `packages/gcode-parser/src/rs274.ts` — a pure recursive-descent evaluator (RS274NGC operator/function set, degree trig, LinuxCNC MOD/EQ semantics, `MAX_EXPR_DEPTH` guard, no `eval`) + parameter store (numbered/named/global, indirect `##`, computed `#[expr]`, read-only system-param allow-list `#5420–#5422`), gated on a per-line `#`/`[` scan (FDM byte-identical, native goldens regenerated for the additive `parametricProgram` capability only; ~2.7% parse overhead from the gate, measured on 3DBenchy). O-word control flow (D4, Phases 2–4) explicitly **not** authorized until Phase 1 results are reviewed. | Chestnut Labs (lead) | +| 2026-08-27 | **Phase 2 accepted & built** (O-word control flow — conditionals + bounded loops; D4/D5/D6, §5.2). New `packages/gcode-parser/src/rs274-flow.ts` — the program-buffered interpreter of D1 Option A: engaged only when `programUsesOWords(text)` (a cheap char-scan; FDM never enters it, byte-identical), it buffers the program, builds a **block tree**, and executes `if`/`elseif`/`else`/`endif`, `while`/`endwhile`, `do`/`while`, `repeat`/`endrepeat`, `break`/`continue` — re-feeding each plain line through the same `processLine` (one geometry path) and evaluating conditions/counts against the shared Phase-1 store (`Rs274Context.evalExpression`). **Bounded execution (D5):** new public `maxProgramIterations` limit (default 1M) charged per loop-body pass → `rs274-iteration-limit` + partial IR; internal `MAX_CONTROL_NESTING` cap; loops are iteration, not recursion, so only structural nesting (capped) uses JS stack — a hostile `o while [1]` wastes only bounded CPU, cannot hang/OOM/stack-overflow. **Honesty (D6):** clean flow → `parametricProgram: 'known'`; a degraded run (hit cap / unbalanced / unsupported / malformed condition) → `'approximated'` + a specific disclosure. `sub`/`call`/`return` disclosed unsupported and **not** executed (Phase 3). Streaming discloses + runs linearly (a partial stream can't re-run a line range, §4.1). Both golden suites unchanged; validated on a real LinuxCNC-idiom `while`-loop bolt circle (params + degree trig + flow). `maxCallDepth` (subroutine recursion) deferred to Phase 3. | Chestnut Labs (lead) | diff --git a/docs/design/DD-030-renderer-viewer-interop.md b/docs/design/DD-030-renderer-viewer-interop.md new file mode 100644 index 00000000..ff9ffcf4 --- /dev/null +++ b/docs/design/DD-030-renderer-viewer-interop.md @@ -0,0 +1,305 @@ +# DD-030 — Renderer/viewer interoperability: capture(), per-plate render scope & custom bed geometry + +**Status:** **Accepted** — capabilities and the public-API shape approved by the maintainer 2026-08-27 +(the four §7 open questions resolved in the decision log). Implementation proceeds in the §4.5 sequence +(bed → render scope → capture); the accepted shape is the contract. + +**Authors/Owners:** Nathaniel Chestnut (drafted by Claude, project lead) +**Date:** 2026-08-27 +**Owning Epic:** renderer/viewer consumer-interoperability (consumer-driven; AnyBridge is the first +consumer) · **Milestone:** next lockstep minor +**Related:** [DD-018](./DD-018-model-renderer.md) (ModelRenderer / model presentation surface), +[DD-025](./DD-025-multi-plate-model-structure.md) (multi-plate model structure — the data this activates), +[DD-023](./DD-023-capability-aware-render-budget.md) (capability-aware render budget — capture reflects +current live fidelity), +DD-001 (capability/confidence honesty model), DD-005 (adapter contracts — the 4 framework adapters over +one controller). Reference: the still-render readback path (`render-still.ts`, `render-model-still.ts`). + +--- + +> **Why now.** Three distinct consumers need to drive the renderer in ways the current public surface does +> not allow, and they are all **generic renderer/viewer primitives** — not one application's needs. A +> partner integration (AnyBridge) surfaced concrete use-cases for each, but the capabilities are written +> to serve any consumer that needs the *currently displayed view as an image*, a *specific plate of a +> multi-plate source*, or an *honest non-rectangular bed*. Two adjacent things already work and are +> **out of scope**: sliced-gcode variant thumbnails (`renderStill` already renders a parsed toolpath) and +> rectangular bed dimensions + texture (`BuildVolumeDef` + `BedSurface` already do this). One thing was +> investigated and found to have **no substrate** — overhang/support-need visualization — and is +> deliberately deferred to its own future capability (there is no geometry/normal-analysis layer to build +> it on today). + +## 1. Problem + +The engine renders well but cannot yet be *driven* by a consumer for three common integration needs: + +1. **No image capture from the live viewer.** The interactive stage exposes `render()`, camera state, and + framing, but **no readback** — a consumer cannot turn "what the operator is looking at right now" into + a `Blob`/image. The headless `renderStill`/`renderModelStill` path *does* produce a readable canvas + (it forces `preserveDrawingBuffer: true`, renders once, and hands the caller the canvas to extract + pixels), but there is no equivalent for the **interactive** view, and the interactive context defaults + `preserveDrawingBuffer: false` (deliberately, for interaction perf), so an out-of-band `toBlob()` on + the live canvas returns a blank frame. + +2. **No per-plate render isolation.** DD-025 parses multi-plate project 3MFs and already exposes + `ModelScene.plates` (per-plate summaries **with bounds**) and per-placement `ModelObject.plateIds[]` — + but the model renderer always renders and frames the **whole** project. A consumer cannot render "just + plate 3" of a 6-plate tray. The structural data exists; the render-time selector does not. + +3. **No non-rectangular bed.** `BuildVolumeDef` is rectangular-only (`x`/`y`/`z` + corner origin). A + delta, round, or otherwise irregular machine bed cannot be drawn honestly; worse, `machineToVolume()` + currently *discards* a discovered circular/polygon bed into its bounding rectangle, so even + already-detected non-rect beds render as a wrong rectangle. + +All three are **additive** — no existing behavior is removed — but each adds public surface, so the shape +is worth agreeing before it ships and becomes a contract across 13 lockstep packages + 4 adapters. + +## 2. Scope + +Three capabilities, each a generic primitive: + +1. **`capture()` → `Blob`** on the interactive viewer(s): return the currently-displayed view as an image. +2. **Per-plate render scope**: render/frame a caller-selected subset (a plate, or any object subset) of a + multi-plate/multi-object **model** source. +3. **Custom bed geometry**: draw an honest non-rectangular bed (delta/round/irregular) from a + caller-supplied outline (or, as a reserved escape hatch, a caller-supplied mesh). + +## 3. Non-goals + +- **Overhang / support-need visualization** — deferred. There is no face-normal / slope / downward-facing + analysis substrate anywhere in the repo (the only "overhang" strings are slicer feature-role *labels*). + A genuine "these areas may need support" preflight is a new geometry-analysis capability that needs its + own investigation/DD and a real consumer need; it is explicitly **not** part of this batch. +- **Sliced-gcode (variant) thumbnails** — already shipped: `renderStill(source)` renders a parsed + `ToolpathIR` or raw G-code. A single-plate *variant* is already its own single-plate gcode (toolpath + plate isolation happens at **parse time** via `WireParseOptions.plate`), so its thumbnail needs no new + API. This DD adds **no toolpath render scope** (§4.2 D2 explains the parity). +- **Rectangular bed dimensions + texture** — already shipped (`BuildVolumeDef` + `BedSurface`). §4.3 only + *extends* the shape abstraction; the rectangular path stays byte-identical. +- **Machine-profile parsing.** The renderer draws whatever shape/mesh the caller hands it. No printer + database, no OrcaSlicer/vendor semantics in the renderer API — discovery lives in the parser/dialects + layer (`MachineGeometry`) and only *feeds* the renderer. +- **Downloading/saving images.** `capture()` returns a `Blob` to the consumer; the library never triggers + a download (same "caller owns the pixels" contract as the still path). + +## 4. Decisions + +### 4.1 D1 — Interactive `capture()` → Blob + +**Where it lives (three additive layers):** + +- **Real implementation on the interactive stage** (`InteractiveStage`), which owns the GL context and + canvas. +- **Thin delegators** on `ToolpathRenderer` and on the model surface (`ModelRenderer` / `ModelViewer`). +- **`GcodePreviewControls.capture(opts?)`** on the controller, routed through the neutral `PreviewRenderer` + seam — this is what threads to **all four framework adapters for free** (they pass `controls` straight + through; the Web Component adds one imperative method). + +**Signature (generic, vendor-neutral):** + +```ts +export interface CaptureOptions { + width?: number; // default: current drawing-buffer size + height?: number; + format?: 'image/png' | 'image/jpeg' | 'image/webp'; // default 'image/png' + quality?: number; // 0..1 for lossy formats + background?: 'transparent' | ThemeColor; // default: current scene background +} +capture(opts?: CaptureOptions): Promise; +``` + +**Mechanism — render-to-target (recommended).** The core design tension is that the interactive context +defaults `preserveDrawingBuffer: false`. Rather than flip that default (which taxes every interactive +frame for the whole session — the documented reason it is off), `capture()` renders the current scene + +active camera **into an off-screen `WebGLRenderTarget`** sized to `opts.width/height`, then `readPixels` +into a canvas and `convertToBlob()`. This gives (a) arbitrary output size (a 2048px thumbnail from a +400px viewport), (b) an independent/transparent background without disturbing the live view, and (c) full +decoupling from `preserveDrawingBuffer`. It literally re-runs a still-style single render off to the side, +so it reuses the **visual family** of the headless still path. A fast path (direct `readPixels` after a +synchronous `render()`) may be used when `width/height` match the viewport and no custom background is +requested; it is a pure optimization, not the contract. + +- **Rejected — flip the interactive `preserveDrawingBuffer` to true** (a `capturable` construction flag). + Simplest, but taxes every interactive frame for the session; only justified for continuous capture, + which no consumer needs. +- **Rejected — leave capture to the consumer via `raw.renderer()`.** Pushes GL readback, row-flip, and + the preserve-buffer problem onto every consumer; defeats the "one controller, adapters inherit" model. + +**Honesty note.** Capture reflects the **current live fidelity** (which already ran under the DD-023 +capability-aware render budget) — unlike `renderStill`, which builds fresh. AA/pose differences mean capture pixels +are not byte-identical to a `renderStill` of the same pose (already disclaimed for stills). No +capability-tier change: capture is a mechanical readback and asserts nothing about the source. + +### 4.2 D2 — Per-plate render scope (model side) + +**Generalize "plate" to an object-subset filter** — plate is one derivation of the subset, which keeps +the API vendor-neutral and also serves non-plate consumers (isolate one object for a detail card): + +```ts +export type RenderScope = + | { objectIds: string[] } // ModelObject.id subset + | { plateId: number } // sugar: derive the subset from ModelObject.plateIds + | { instanceFilter: (objectId: string, placementIndex: number) => boolean }; // fully general +``` + +**Where it applies:** + +- `renderModelStill`: add `renderScope?: RenderScope` to `RenderModelStillOptions`; resolve to a filtered + `ModelScene` before `setScene`; **fold `renderScope` into the still cache key** so plate-1 and plate-2 + thumbnails cache distinctly. +- `createModelViewer`: add `setRenderScope(scope | null)` to the `ModelViewer` handle (and an initial + option); `null` = whole project (today's behavior). +- The filter itself is a pure, three-free transform on `ModelScene` (drop non-matching objects; drop + non-matching placements from `instances`/`plateIds`, which are already index-aligned per DD-025). + +**Framing** reuses `ModelPlateSummary.bounds` for `{plateId}` (already computed) and the union AABB of the +filtered placements for an arbitrary subset — fed to the existing framing path. + +**Honesty.** `{plateId}` is meaningful only when `capabilities.plates === 'known'` (DD-025). When plates +are `unavailable`, resolving `{plateId}` yields an empty scene → emit a disclosure (a +`renderer-unsupported`-style signal), never silently render nothing. An `objectIds`/`instanceFilter` scope +needs no capability (ids are always known). + +**Toolpath parity — no toolpath render scope.** Toolpath plates are already isolated at **parse time** +(`WireParseOptions.plate: N` — one plate parsed, never merged), so a variant thumbnail is `renderStill` +of that already-single-plate gcode. Adding a toolpath render scope would duplicate that. The DD records +the parity: **model = render-scope filter; toolpath = re-parse.** (A future sub-object toolpath scope, if +ever needed, would ride DD-026's object/feature membership channel — out of scope here.) + +### 4.3 D3 — Custom bed geometry + +**Minimal honest primitive: a 2D outline polygon the renderer fills/extrudes.** Extend `BuildVolumeDef` +additively: + +```ts +export interface BuildVolumeDef { + x: number; y: number; z: number; // KEEP — bounding size (cage height, camera framing) + grid?: number; + min?: { x: number; y: number }; + excludedRegions?: Region2[]; + shape?: BedShape; // NEW — omit ⇒ rectangular [min .. min+size] (today, byte-identical) +} + +export type BedShape = + | { kind: 'rect' } // explicit default + | { kind: 'circular'; center: { x: number; y: number }; diameter: number } + | { kind: 'polygon'; points: { x: number; y: number }[] } // delta / irregular / any loop + | { kind: 'mesh'; geometry: MeshOutline }; // reserved escape hatch (Phase 2) +``` + +- **Delta / round / irregular all reduce to `polygon`** (a circle is a polygonized loop; a delta is a + rounded triangle). The renderer fills the polygon (a `ShapeGeometry` instead of `PlaneGeometry`) and + draws its outline — reusing machinery that already exists (`Region2` polygons + the `excludedRegion` + polygon-outline renderer). **Ship `polygon` + `circular` first**; treat `{kind:'mesh'}` (a caller- + supplied bed mesh, three-free flat `positions`/`indices`) as a reserved later field — rarely needed, + more surface. +- **`machineToVolume()` upgrade:** today it collapses a discovered `circular`/`polygon` `MachineGeometry.bed` + into a bounding rect; make it populate `shape`. This makes discovered round/delta beds render honestly + **with no new consumer code** — but it is a **visible behavior change** for already-discovered non-rect + beds (they render as a circle/polygon instead of a bounding box). Gate it on the field's presence / + regenerate visual baselines deliberately and note it in release notes (§4.4). +- **Grid on a non-rect bed:** clip the existing rectangular grid to the polygon (the grid represents the + printable area). Origin tripod unchanged; the cage may stay a bounding box (gated by `showCage`) or + become a polygon prism — cosmetic, decide at implementation. + +**Vendor-neutrality:** the caller supplies the shape (from wherever — an Orca profile, a config, a +hard-coded delta); the renderer is a dumb draw-the-polygon consumer. No profile parsing in the library. + +### 4.4 D4 — Versioning, compatibility & honesty + +- **All three are additive** (new optional fields/methods) → the whole batch is **one lockstep minor** + (no majors). The four adapters inherit `capture()` via the existing `controls` passthrough; the model + viewer is a separate surface versioned in the same bump. +- **`PreviewRenderer` interface** gains an optional `capture?` member (2D renderer honestly returns a + `renderer-unsupported`-style rejection) — a coordinated in-repo change, not a consumer break. +- **The one behavioral watch-item** is the bed: `createBuildVolume` internals change (grid clip, + `ShapeGeometry` fill), which must stay byte-identical when `shape` is absent/`'rect'` (verify against + the rectangular-bed visual baselines), and the `machineToVolume` change alters output for discovered + non-rect beds — an intended improvement that needs a deliberate golden/screenshot regen (docs-freshness + release gate). +- **Honesty model:** capture adds no tier; render-scope ties to the existing `plates` tier; bed may + surface a "bed shape approximated as bounding rect" disclosure when only a bbox is available + (`known`/`inferred`/`approximated`/`unavailable`, per DD-001). No new IR shape. +- **Worker/OffscreenCanvas/SSR:** capture's render-to-target and the scene-filter are OffscreenCanvas-safe + and three-free respectively; bed textures/meshes stay CSP-safe (`ImageBitmap`/typed arrays, never URLs). + Nothing runs at SSR time (all are runtime calls on a live/headless context). + +### 4.5 D5 — Sequencing (dependency-ordered) + +The three are largely independent (different files/packages). An optional **shared readback helper** +(render-scene-to-Blob) can de-duplicate the pixel readback across the toolpath still, the model still, and +interactive capture — extract it with (or just before) capture. + +1. **Bed geometry — Phase 1 (`polygon`/`circular` outline).** Self-contained in `gcode-renderer-three` + (build-volume + theme + `machineToVolume`); no cross-package or adapter change. Lowest risk except the + golden regen. Defer `{kind:'mesh'}`. +2. **Render scope — model side.** Self-contained in `gcode-model-renderer` (scene filter + still option + + `ModelViewer.setRenderScope` + cache key). Builds directly on DD-025 data already present. No adapter + change (model viewer is its own surface). +3. **`capture()` — last (widest surface).** Stage/renderer implementation + render-to-target first, then + thread onto `GcodePreviewControls` (adapters inherit free) + the Web Component method + `ModelViewer`. + +This ordering matches the consumer's stated priority for *consumption* (plate → capture → bed) closely +enough; the two lowest-risk, self-contained pieces (bed, plate) land first, and the highest-fan-out piece +(capture) lands once the pattern is proven. Each is independently landable and independently valuable. + +## 5. Consumer use-cases (evidence) + +Recorded from the first consumer (AnyBridge) so the shapes are grounded, not speculative: + +- **Per-plate (priority 1):** a single-plate *variant* of a multi-plate project must visually represent + *that* plate (plate 3 of a 6-plate set looks like plate 3, not the whole tray); the source drawer wants + per-plate previews. Consumes `RenderScope {plateId}` on `renderModelStill`/`ModelViewer`. (An embedded + `plate_N.png` covers the cheap case consumer-side meanwhile.) +- **`capture()` (priority 2):** (a) an operator frames the model in the live 3D view and snapshots it as + the file thumbnail (a built, capability-gated consumer feature waits only on this); (b) a large-file + thumbnail fallback — a >200 MB STL the server can't render but a client GPU renders fine. Consumes + `capture(opts?) → Blob`. +- **Non-rect bed (priority 3):** draw the real bed of the sliced-for printer (delta/round/arbitrary + outline) from the machine profile's bed data. Consumer passes dims/shape/texture; renderer draws the + polygon. + +## 6. Alternatives considered + +- **Ship each capability piecemeal / consumer-specific.** Rejected by the maintainer: design the three as + one coherent, generic surface, no consumer semantics baked in. +- **Plate-primitive instead of a general subset filter.** Rejected: a general `RenderScope` (with + `plateId` sugar) stays vendor-neutral and serves non-plate consumers at no extra cost. +- **Arbitrary caller mesh as the primary bed API.** Rejected as the *first* increment: a 2D outline + polygon is the minimal honest primitive that covers delta/round/irregular; the mesh escape hatch is a + reserved later field. +- **Overhang/support viz in this batch.** Rejected: no analysis substrate exists; it is a separate future + capability with its own DD. + +## 7. Open questions (resolved at acceptance — see the decision log) + +1. **capture() output ergonomics** → **`Blob`-only** for now (covers every stated use-case: thumbnail + POST, large-file fallback). `captureToCanvas()` is a trivial additive follow-up if a consumer needs + raw pixels; not shipped in the first cut to keep the surface minimal. +2. **Bed cage on a non-rect bed** → **keep the bounding-box cage** (gated by `showCage`). The bed + *outline* carries the honesty; a polygon prism is cosmetic and can come later. +3. **`machineToVolume` behavior change** → **default** (render a discovered non-rect bed as its true + outline — the honest result) **with a deliberate visual-baseline + screenshot regen and a release + note**. The rectangular path stays byte-identical; only already-wrong bounding-rect renders change. +4. **`{kind:'mesh'}` bed** → **omit for now** (YAGNI). Ship `polygon` + `circular`; reserve `mesh` in the + DD as the escape hatch, add the field only when a real consumer needs an arbitrary bed mesh. + +## 8. Acceptance criteria + +- [ ] D1–D5 reviewed; the public-surface shape signed off by the maintainer; DD marked Accepted. +- [ ] `capture()` returns a correct `Blob` from the interactive toolpath viewer and the model viewer, at + the viewport size and at an arbitrary requested size, with transparent and themed backgrounds, + **without** changing the interactive `preserveDrawingBuffer` default; available on all 4 adapters. +- [ ] `renderModelStill`/`ModelViewer` render and frame a selected plate (and an arbitrary object subset); + the still cache keys distinctly per scope; `{plateId}` on a `plates: unavailable` source discloses. +- [ ] A delta/round/irregular bed renders as its true outline; discovered non-rect beds no longer collapse + to a bounding rect; the rectangular-bed path stays byte-identical (visual baselines). +- [ ] Additive only — one lockstep minor; no IR shape change; honesty tiers preserved; docs/screenshots + regenerated where bed rendering visibly changed. + +## Decision log + +| Date | Decision | By | +|---|---|---| +| 2026-08-27 | The three capabilities (interactive `capture()`, per-plate render scope, non-rectangular bed geometry) **product-approved** as one generic renderer/viewer interoperability batch — design together, keep vendor-neutral, no consumer semantics in the API. Overhang/support-need viz explicitly **deferred** (no analysis substrate; needs its own DD). Sliced-gcode variant thumbnails + rectangular bed/texture confirmed already-shipped and **out of scope**. | Chestnut Labs (maintainer) | +| 2026-08-27 | DD-030 drafted **Proposed** with the concrete API shapes (D1 render-to-target `capture()` on `GcodePreviewControls` + `ModelViewer`; D2 generic `RenderScope` model-side with `{plateId}` sugar, toolpath stays parse-time; D3 `BedShape` polygon primitive + `machineToVolume` upgrade, `mesh` reserved). Brought for public-surface sign-off before implementation. | Chestnut Labs (lead) | +| 2026-08-27 | **API shape accepted** by the maintainer. §7 open questions resolved: (1) `capture()` **`Blob`-only** first (`captureToCanvas()` deferred as a trivial follow-up); (2) **bounding-box cage** kept on non-rect beds (the outline carries honesty); (3) `machineToVolume` renders discovered non-rect beds honestly **by default** with a deliberate visual-baseline + screenshot regen and a release note (rectangular path byte-identical); (4) `{kind:'mesh'}` **omitted** for now (reserved in the DD; `polygon`+`circular` ship). Implementation authorized in the §4.5 sequence: bed → render scope → capture, each an independently-reviewable increment; flag the consumer with the version target on each landing. | Chestnut Labs (maintainer + lead) | diff --git a/docs/manual/index.md b/docs/manual/index.md index 244037b7..56d339cc 100644 --- a/docs/manual/index.md +++ b/docs/manual/index.md @@ -67,6 +67,6 @@ viewer is reachable without switching APIs. [Live progress & motion model](concept-progress-motion.md). - **API reference** — pick a package in the sidebar (`toolpath-core`, `gcode-parser`, …). -> **Status:** published — all fourteen `@chestnutlabs/*` packages are on npm (latest `v0.17.0`, +> **Status:** published — all fourteen `@chestnutlabs/*` packages are on npm (latest `v0.18.0`, > lockstep-versioned with provenance). Governance and epic status live in the > [repository docs](https://github.com/ChestnutLabs/gcode-preview/tree/dev/docs). diff --git a/docs/research/RR-009-provenance-detection-corpus-and-multi-candidate-families.md b/docs/research/RR-009-provenance-detection-corpus-and-multi-candidate-families.md new file mode 100644 index 00000000..342367df --- /dev/null +++ b/docs/research/RR-009-provenance-detection-corpus-and-multi-candidate-families.md @@ -0,0 +1,120 @@ +# RR-009 — Provenance detection: a multi-candidate slicer/controller-family model & corpus + +**Status:** In progress · **scope-only** (draft the plan; the +detector itself is a future epic, not authorized to build) +**Authors/Owners:** Nathaniel Chestnut (drafted by Claude, project lead) +**Date:** 2026-08-27 +**Related:** DD-005 (dialect plugin & container-adapter contracts — the current `detect()` surface), +RR-004 §7 (spec landscape), RR-007 (cross-slicer marker research), DD-026 (slicer object/feature +capture — a consumer of correct family detection). `#189` evidence-based CNC detection (#258) is the +closest prior art. + +--- + +> **Why this RR exists.** The owner asked for a *grounded plan* for multi-candidate slicer-family +> detection, to stay a scoped future capability until there is enough real provenance data and a concrete +> need. This record defines the question, audits today's behaviour, proposes a model to evaluate, and +> lists what the future epic must gather — **without building the detector**. + +## 1. Question & the decision it informs + +**Question.** Should the library's dialect detection move from **single-winner-per-kind** to a +**multi-candidate, confidence-ranked** model with an explicit *ambiguity* signal, and what real-file +corpus would validate it? The decision it informs: whether to open a "provenance detection" epic, and +if so, what public shape the detection result takes (a ranked candidate set vs. today's single decision). + +**Why it matters.** Slicer families are **derivative and share markers**, so a single winner is often +wrong or discarded: +- OrcaSlicer, Bambu Studio, AnycubicSlicerNext, QIDIStudio, ELEGOO/OrcaSlicer forks all emit the + Orca-lineage `;TYPE:` vocabulary + `; printing object` (RR-007 §5.8) — a header may name one while the + body is pure Orca. +- SuperSlicer forks PrusaSlicer; both emit `;TYPE:` + `EXCLUDE_OBJECT` + `; printing object`. +- Firmware flavour (Marlin / Klipper / RepRap / GRBL / LinuxCNC) is a **second, orthogonal** axis that + already composes (DD-005 amendment 1) — the same file has a *slicer* family and a *controller* family. + +Correct family attribution feeds real behaviour: DD-026 object/feature capture, colour conventions, +bed/geometry parsing, and honest provenance in the UI. A wrong single winner silently mis-parses; the +current tie→**none** rule is honest but discards a usable ranked answer. + +## 2. Candidates / prior art tested (current behaviour) + +The shipped model (`gcode-dialects/src/registry.ts`, `createDialectRunner`): +- Each adapter `detect(input): DialectDetection | null` returns **one** decision with a `confidence` + (`known`/`inferred`/…) + `evidence` string (`contracts.ts:73`). +- The runner keeps the **highest-confidence adapter per `kind`** (`slicer` | `firmware` | `generic`); + **ties select none** for that kind ("never a guess presented as a decision", registry.ts). +- CNC (#258) is the one place with **evidence scoring**: `scoreEvidence` in `cnc.ts` accumulates process + signals (laser = tool-on + `S` + no-Z-plunge; mill = M3 + neg-Z-plunge; linuxcnc = header/O-words). + +**Observed limitations** (to quantify against the corpus in the epic, not fully measured here): +1. A derivative file (Anycubic header, Orca body) resolves to whichever adapter claims highest + confidence — no signal that a second family fits nearly as well. +2. Genuine ambiguity (two Prusa-lineage adapters both `known`) → tie → **none**, so a *usable* ranked + answer ("PrusaSlicer 0.7 / SuperSlicer 0.6") is thrown away. +3. `evidence` is a free string, not a structured score, so a consumer can't reason about *why* or *how + close* the runner-up was. + +## 3. Proposed model to evaluate (the epic's hypothesis — not built) + +A **multi-candidate scorer** layered over the existing per-adapter `detect()`: +- Each adapter returns a **score** (0..1) from weighted evidence (header generator string, marker + vocabulary present/absent, config-block keys, container metadata) rather than a single boolean-ish + confidence. +- The runner produces a **ranked candidate list** per axis (slicer family, controller family) with each + candidate's score + structured evidence, plus a derived **ambiguity** measure (score gap between #1 and + #2). The chosen adapter(s) still drive annotation (one winner runs), but the *result* exposes the + ranked set so a consumer can show "OrcaSlicer (likely) / AnycubicSlicerNext (possible)" and decide + trust. +- **Lineage graph:** encode the derivative relationships (Orca←Bambu/Prusa; Super←Prusa; Anycubic←Orca) + so a shared marker credits the *family*, and a distinguishing marker breaks the tie. +- Honesty preserved: low top-score or high ambiguity → `inferred`/`unavailable`, never a fabricated pick. + +**Open public-API question (owner-level, for the epic's DD):** does `DialectDetection` gain an optional +`candidates: {dialectId, score, evidence}[]` + `ambiguity`, or does the ranked set stay internal and only +raise confidence fidelity? This is a new public contract → an owner decision at DD time, not here. + +## 4. Fixture / corpus manifest (what the epic must gather) + +The blocking input. A **provenance corpus**: real files with **known** origin (slicer name + version, +controller), spanning the derivative families above and the ambiguous cases. Sources: the owner's +private samples (kept in `ProjectSource/`, **never committed**) reduced to **MIT-clean synthetic +fixtures** that reproduce the distinguishing markers, plus community-contributed headers. Each corpus +entry: `{ file, trueSlicerFamily, trueControllerFamily, distinguishingMarkers[] }`. Target coverage: the +Orca lineage (Orca/Bambu/Anycubic/QIDI), the Prusa lineage (Prusa/Super), Cura, ideaMaker, Simplify3D, ++ the controller axis (Marlin/Klipper/RepRap/GRBL/LinuxCNC). **Out of scope (not G-code):** Heidenhain +`.h`, Siemens `.mpf`, proprietary DSP/galvo formats — different languages, a separate concern. + +## 5. Measurements & observable results + +Deferred to the epic. The measurement protocol: run the corpus through (a) today's single-winner runner +and (b) a prototype multi-candidate scorer; report per-family precision/recall, the **ambiguity-handling +delta** (cases where tie→none today but a ranked answer is correct), and any regression on unambiguous +files (must stay a clean single winner). No measurements are claimed in this scope-only record. + +## 6. License / provenance concerns + +The owner's real sample files are **private** (`ProjectSource/`, git-ignored) and must not enter the +public corpus. Detection heuristics are derived from **observed marker behaviour**, not copied spec text +(RR-004 §6). Community-donated files need explicit licensing before inclusion. Header generator strings +are provenance data, not creative content. + +## 7. Limitations & unknowns + +- Marker vocabularies are not exhaustive (RR-007 §7); a scorer must degrade honestly on unseen dialects. +- Version granularity (PrusaSlicer 2.7 vs 2.9) may be under-determined by markers alone — the epic should + decide whether family-level attribution is sufficient (likely yes). +- The corpus is the long pole: without enough labelled real files, a multi-candidate model can't be + validated, which is exactly why this stays scoped. + +## 8. Recommendation & rejected alternatives + +**Recommendation.** Keep this **scoped, not built.** Open a "provenance detection" epic only once (a) the +labelled provenance corpus reaches meaningful coverage of the derivative families, and (b) a concrete +consumer need appears (e.g. AnyBridge or the UI needs a ranked family + confidence, not just a single +decision). Until then, today's single-winner + tie→none model is honest and adequate for the unambiguous +majority, and DD-026's marker-driven capture does not depend on perfect family attribution. + +**Rejected now:** (1) building the multi-candidate scorer speculatively — no corpus to validate it, risks +over-fitting to a few files (the exact "benchy-lucky" trap the project avoids); (2) exposing a +`candidates[]` public API before a consumer needs it — a contract we'd have to keep. Revisit when §4 and +§7's unknowns are resolved. diff --git a/package-lock.json b/package-lock.json index a039c8b3..ad9c304f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7389,11 +7389,11 @@ }, "packages/gcode-bgcode": { "name": "@chestnutlabs/gcode-bgcode", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-containers": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-containers": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7401,10 +7401,10 @@ }, "packages/gcode-colors": { "name": "@chestnutlabs/gcode-colors", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7412,10 +7412,10 @@ }, "packages/gcode-containers": { "name": "@chestnutlabs/gcode-containers", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7423,10 +7423,10 @@ }, "packages/gcode-dialects": { "name": "@chestnutlabs/gcode-dialects", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7434,12 +7434,12 @@ }, "packages/gcode-model-renderer": { "name": "@chestnutlabs/gcode-model-renderer", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-containers": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-containers": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "devDependencies": { "@types/three": "0.178.0" @@ -7453,13 +7453,13 @@ }, "packages/gcode-parser": { "name": "@chestnutlabs/gcode-parser", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-bgcode": "0.17.0", - "@chestnutlabs/gcode-containers": "0.17.0", - "@chestnutlabs/gcode-dialects": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-bgcode": "0.18.0", + "@chestnutlabs/gcode-containers": "0.18.0", + "@chestnutlabs/gcode-dialects": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7467,13 +7467,13 @@ }, "packages/gcode-preview-core": { "name": "@chestnutlabs/gcode-preview-core", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.17.0", - "@chestnutlabs/gcode-renderer-2d": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-renderer-2d": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7481,13 +7481,13 @@ }, "packages/gcode-preview-element": { "name": "@chestnutlabs/gcode-preview-element", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7495,13 +7495,13 @@ }, "packages/gcode-preview-react": { "name": "@chestnutlabs/gcode-preview-react", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "devDependencies": { "@types/react": "^18.3.0", @@ -7517,13 +7517,13 @@ }, "packages/gcode-preview-svelte": { "name": "@chestnutlabs/gcode-preview-svelte", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "devDependencies": { "svelte": "^4.2.0" @@ -7537,13 +7537,13 @@ }, "packages/gcode-preview-vue": { "name": "@chestnutlabs/gcode-preview-vue", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "devDependencies": { "vue": "^3.4.0" @@ -7557,11 +7557,11 @@ }, "packages/gcode-renderer-2d": { "name": "@chestnutlabs/gcode-renderer-2d", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-colors": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-colors": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "engines": { "node": ">=22" @@ -7569,11 +7569,11 @@ }, "packages/gcode-renderer-three": { "name": "@chestnutlabs/gcode-renderer-three", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-colors": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-colors": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "devDependencies": { "@types/three": "0.178.0" @@ -7587,7 +7587,7 @@ }, "packages/toolpath-core": { "name": "@chestnutlabs/toolpath-core", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "engines": { "node": ">=22" diff --git a/packages/gcode-bgcode/CHANGELOG.md b/packages/gcode-bgcode/CHANGELOG.md index c9a8b027..af1c11f5 100644 --- a/packages/gcode-bgcode/CHANGELOG.md +++ b/packages/gcode-bgcode/CHANGELOG.md @@ -1,5 +1,13 @@ # @chestnutlabs/gcode-bgcode +## 0.18.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/gcode-containers@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-bgcode/package.json b/packages/gcode-bgcode/package.json index fe359813..2b6dd74c 100644 --- a/packages/gcode-bgcode/package.json +++ b/packages/gcode-bgcode/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-bgcode", - "version": "0.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/gcode-containers": "0.17.0" + "@chestnutlabs/toolpath-core": "0.18.0", + "@chestnutlabs/gcode-containers": "0.18.0" } } diff --git a/packages/gcode-colors/CHANGELOG.md b/packages/gcode-colors/CHANGELOG.md index 86e4aa83..fe63fb2e 100644 --- a/packages/gcode-colors/CHANGELOG.md +++ b/packages/gcode-colors/CHANGELOG.md @@ -1,5 +1,12 @@ # @chestnutlabs/gcode-colors +## 0.18.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-colors/package.json b/packages/gcode-colors/package.json index b6f13bab..aa9e8c07 100644 --- a/packages/gcode-colors/package.json +++ b/packages/gcode-colors/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-colors", - "version": "0.17.0", + "version": "0.18.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.17.0" + "@chestnutlabs/toolpath-core": "0.18.0" } } diff --git a/packages/gcode-containers/CHANGELOG.md b/packages/gcode-containers/CHANGELOG.md index 56f9ab88..2c63739e 100644 --- a/packages/gcode-containers/CHANGELOG.md +++ b/packages/gcode-containers/CHANGELOG.md @@ -1,5 +1,12 @@ # @chestnutlabs/gcode-containers +## 0.18.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-containers/package.json b/packages/gcode-containers/package.json index b6cae034..9bc84ffe 100644 --- a/packages/gcode-containers/package.json +++ b/packages/gcode-containers/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-containers", - "version": "0.17.0", + "version": "0.18.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.17.0" + "@chestnutlabs/toolpath-core": "0.18.0" } } diff --git a/packages/gcode-dialects/CHANGELOG.md b/packages/gcode-dialects/CHANGELOG.md index f91b37ff..1f808aba 100644 --- a/packages/gcode-dialects/CHANGELOG.md +++ b/packages/gcode-dialects/CHANGELOG.md @@ -1,5 +1,12 @@ # @chestnutlabs/gcode-dialects +## 0.18.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Minor Changes diff --git a/packages/gcode-dialects/package.json b/packages/gcode-dialects/package.json index 6b399c53..1244a8c1 100644 --- a/packages/gcode-dialects/package.json +++ b/packages/gcode-dialects/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-dialects", - "version": "0.17.0", + "version": "0.18.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.17.0" + "@chestnutlabs/toolpath-core": "0.18.0" } } diff --git a/packages/gcode-model-renderer/CHANGELOG.md b/packages/gcode-model-renderer/CHANGELOG.md index 7bcb2fa0..653fd651 100644 --- a/packages/gcode-model-renderer/CHANGELOG.md +++ b/packages/gcode-model-renderer/CHANGELOG.md @@ -1,5 +1,67 @@ # @chestnutlabs/gcode-model-renderer +## 0.18.0 + +### Minor Changes + +- [#418](https://github.com/ChestnutLabs/gcode-preview/pull/418) [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(renderer): interactive view capture() → Blob (DD-030 D1) + + The interactive viewer can now hand back **what is on screen right now** as an image `Blob` — for a + user-selected thumbnail, a large-file thumbnail fallback, or a screenshot. New `capture(opts?)` where + `opts` is `{ width?, height?, format?, quality?, background? }` (all optional; defaults match the live + view). + + Available on every interactive surface: `GcodePreviewControls.capture()` (so the Vue, React, Svelte, and + Web-Component adapters all inherit it — the Web Component also exposes an imperative `capture()` method), + and `ModelViewer.capture()` on the model-viewer handle. The toolpath `ToolpathRenderer` and the shared + `InteractiveStage` carry the implementation. + + **Mechanism (render-to-target).** Capture renders the current scene + active camera into an off-screen + `WebGLRenderTarget` at the requested size and reads it back, rather than flipping the interactive + context's `preserveDrawingBuffer` (which would tax every interactive frame). That gives an arbitrary + output size and an independent/transparent background **without** disturbing the live view, and reuses the + headless still path's "single render, then read pixels" recipe. The thumbnail is framed at its own aspect + so it isn't distorted; the live view is repainted afterward. The library returns the `Blob` and **never** + triggers a download — the caller owns the pixels (same contract as `renderStill`). + + **Honest.** When the renderer cannot render-to-target (the 2D renderer, a stub GL / no WebGL) or the stage + is disposed / its context is lost, `capture()` rejects with a typed `CaptureUnsupportedError` + (`code: 'E_CAPTURE_UNSUPPORTED'`) — never fabricated output. Purely additive (a new optional method on the + renderer contract; no existing signature changed). Final increment of the DD-030 renderer/viewer + interoperability batch (bed + per-plate scope + capture). + +- [#417](https://github.com/ChestnutLabs/gcode-preview/pull/417) [`5dc8079`](https://github.com/ChestnutLabs/gcode-preview/commit/5dc80794444742beddafe1c37322db751af50020) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(model-renderer): per-plate / object-subset render scope (DD-030 D2) + + A caller can now render just **one plate** (or any object/placement subset) of a multi-plate / multi-object + source, instead of always the whole project — so a single-plate variant thumbnail looks like _that_ plate. + + New `RenderScope` selector — `{ plateId }`, `{ objectIds }`, or `{ instanceFilter }` — with a pure, + three-free `applyRenderScope(scene, scope)` that returns a filtered `ModelScene` (dropping non-matching + objects/placements) with `bounds` recomputed from the kept placements, so framing follows the subset. + `{ plateId }` is sugar over the placement-level `plateIds` (DD-025); it's generic (a plate is just one way + to derive the subset) and vendor-neutral. + + Wired into both surfaces: + - `renderModelStill` gains a `renderScope?` option; it also folds the scope into the still `cacheKey` so a + plate-1 and a plate-2 thumbnail of the same source key distinctly (an opaque `instanceFilter` is marked + non-cacheable). + - `createModelViewer` gains an initial `renderScope?` and a `setRenderScope(scope | null)` handle method + that rebuilds and reframes to the subset (`null` restores the whole source). + + Honest and additive: omitting `renderScope` renders the whole project exactly as before. A `{ plateId }` + that matches nothing (e.g. a source with no declared plate structure, `capabilities.plates !== 'known'`) + renders an **empty** scene by design — the honest result of selecting a plate that isn't declared; + consumers gate `{ plateId }` on `plates: 'known'`. The source's declared `plates`/`capabilities` are + preserved (only what is _rendered_ is narrowed). Second increment of the DD-030 renderer/viewer interop + batch. + +### Patch Changes + +- Updated dependencies [[`0ebeadf`](https://github.com/ChestnutLabs/gcode-preview/commit/0ebeadfa839e52baf243ef07b5807f3974bbac7e), [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0)]: + - @chestnutlabs/gcode-renderer-three@0.18.0 + - @chestnutlabs/gcode-containers@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-model-renderer/package.json b/packages/gcode-model-renderer/package.json index e6243b65..29911c54 100644 --- a/packages/gcode-model-renderer/package.json +++ b/packages/gcode-model-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-model-renderer", - "version": "0.17.0", + "version": "0.18.0", "description": "Three.js presentation renderer for source models (STL/3MF) — clean thumbnails/cards, distinct from toolpath inspection (DD-018).", "keywords": [ "3d-printing", @@ -51,9 +51,9 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-containers": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-containers": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "devDependencies": { "@types/three": "0.178.0" diff --git a/packages/gcode-model-renderer/src/__tests__/render-scope.test.ts b/packages/gcode-model-renderer/src/__tests__/render-scope.test.ts new file mode 100644 index 00000000..708f8209 --- /dev/null +++ b/packages/gcode-model-renderer/src/__tests__/render-scope.test.ts @@ -0,0 +1,97 @@ +/** + * DD-030 D2 — per-plate / object-subset render scope. `applyRenderScope` is a pure, three-free filter, + * unit-tested here against a synthetic multi-plate scene (no GL, no 3MF needed). + */ +import { describe, it, expect } from 'vitest'; +import { applyRenderScope, type Mat4, type ModelScene } from '../index.js'; + +/** Column-major translate matrix (three `Matrix4.elements` layout). */ +const translate = (x: number, y: number, z: number): Mat4 => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]; +/** A unit-cube geometry (AABB 0..1) — only the extremes matter for bounds. */ +const unitCube = () => ({ positions: new Float32Array([0, 0, 0, 1, 1, 1]) }); + +/** A 2-object scene: 'a' instanced on plates 1 & 2 (x=0 and x=10); 'b' single placement on plate 1 (y=10). */ +function multiPlateScene(withPlateIds = true): ModelScene { + return { + objects: [ + { + id: 'a', + geometry: unitCube(), + transform: translate(0, 0, 0), + instances: [translate(0, 0, 0), translate(10, 0, 0)], + ...(withPlateIds ? { plateIds: [1, 2] } : {}) + }, + { + id: 'b', + geometry: unitCube(), + transform: translate(0, 10, 0), + ...(withPlateIds ? { plateIds: [1] } : {}) + } + ], + bounds: { min: [0, 0, 0], max: [11, 11, 1] }, + plates: { + list: [ + { id: 1, objectCount: 2, instanceCount: 2, bounds: { min: [0, 0, 0], max: [1, 11, 1] } }, + { id: 2, objectCount: 1, instanceCount: 1, bounds: { min: [10, 0, 0], max: [11, 1, 1] } } + ] + }, + capabilities: { + materials: 'unavailable', + transforms: 'known', + multiObject: 'known', + instanced: 'known', + plates: 'known' + } + }; +} + +describe('applyRenderScope — plate selection', () => { + it('{plateId:1} keeps plate 1 placements and reframes to them', () => { + const s = applyRenderScope(multiPlateScene(), { plateId: 1 }); + expect(s.objects.map((o) => o.id).sort()).toEqual(['a', 'b']); + // 'a' kept only its plate-1 placement (x=0) → collapsed to a single placement (no instances). + const a = s.objects.find((o) => o.id === 'a')!; + expect(a.instances).toBeUndefined(); + expect(a.transform[12]).toBe(0); // x translation of the kept placement + // Bounds cover a@(0,0,0)..(1,1,1) ∪ b@(0,10,0)..(1,11,1). + expect(s.bounds.max[1]).toBeCloseTo(11); + expect(s.bounds.max[0]).toBeCloseTo(1); + }); + + it('{plateId:2} keeps only object a at x=10 and drops b', () => { + const s = applyRenderScope(multiPlateScene(), { plateId: 2 }); + expect(s.objects.map((o) => o.id)).toEqual(['a']); + expect(s.objects[0].transform[12]).toBe(10); + expect(s.bounds.min[0]).toBeCloseTo(10); + expect(s.bounds.max[0]).toBeCloseTo(11); + }); + + it('{plateId} on a scene without plate structure matches nothing → empty scene (honest)', () => { + const s = applyRenderScope(multiPlateScene(false), { plateId: 1 }); + expect(s.objects).toHaveLength(0); + expect(s.bounds).toEqual({ min: [0, 0, 0], max: [0, 0, 0] }); + // The source's declared capabilities/plates are preserved (only the render is narrowed). + expect(s.plates).toBeDefined(); + }); +}); + +describe('applyRenderScope — object & instance subsets', () => { + it('{objectIds} keeps only the named objects', () => { + const s = applyRenderScope(multiPlateScene(), { objectIds: ['b'] }); + expect(s.objects.map((o) => o.id)).toEqual(['b']); + }); + + it('{instanceFilter} selects individual placements', () => { + const s = applyRenderScope(multiPlateScene(), { instanceFilter: (id, i) => id === 'a' && i === 1 }); + expect(s.objects.map((o) => o.id)).toEqual(['a']); + expect(s.objects[0].instances).toBeUndefined(); // single placement kept + expect(s.objects[0].transform[12]).toBe(10); + }); + + it('a multi-placement object that keeps ≥2 placements stays instanced', () => { + const s = applyRenderScope(multiPlateScene(), { instanceFilter: (id) => id === 'a' }); + const a = s.objects.find((o) => o.id === 'a')!; + expect(a.instances).toHaveLength(2); + expect(a.plateIds).toEqual([1, 2]); + }); +}); diff --git a/packages/gcode-model-renderer/src/index.ts b/packages/gcode-model-renderer/src/index.ts index 67ad57bc..39e9b8b5 100644 --- a/packages/gcode-model-renderer/src/index.ts +++ b/packages/gcode-model-renderer/src/index.ts @@ -20,7 +20,7 @@ export type { ModelLoader, ModelLoadOptions, ModelSourceInput } from './loaders. export { computeCacheKey, defaultEnvId } from './cache-key.js'; export { DEFAULT_LIMITS, resolveLimits, ModelParseError } from './limits.js'; export type { ModelLimits, ResolvedLimits } from './limits.js'; -export { IDENTITY_MAT4 } from './scene-model.js'; +export { IDENTITY_MAT4, applyRenderScope } from './scene-model.js'; export type { ModelScene, ModelObject, @@ -28,6 +28,7 @@ export type { ModelPlateSummary, MeshGeometry, ModelBounds, + RenderScope, RGB, Mat4 } from './scene-model.js'; diff --git a/packages/gcode-model-renderer/src/model-viewer.ts b/packages/gcode-model-renderer/src/model-viewer.ts index 6409a17f..426dc05e 100644 --- a/packages/gcode-model-renderer/src/model-viewer.ts +++ b/packages/gcode-model-renderer/src/model-viewer.ts @@ -14,11 +14,13 @@ */ import { Color, Scene } from 'three'; import { + CaptureUnsupportedError, InteractiveStage, createDefaultGLRenderer, type CameraMode, type CameraState, type CameraView, + type CaptureOptions, type GLRendererLike, type InteractiveStageOptions, type LoadProgress, @@ -29,7 +31,14 @@ import { ModelContent } from './model-content.js'; import { DEFAULT_MODEL_LOADERS, resolveModelScene, type ModelLoader, type ModelSourceInput } from './loaders.js'; import { ModelParseError, type ModelLimits } from './limits.js'; import type { ModelBackground, PresentationView } from './model-renderer.js'; -import { sceneInstanceCount, type ModelBounds, type ModelPlateSummary, type ModelScene } from './scene-model.js'; +import { + applyRenderScope, + sceneInstanceCount, + type ModelBounds, + type ModelPlateSummary, + type ModelScene, + type RenderScope +} from './scene-model.js'; export interface ModelViewerOptions { /** Loader registry (open `kind`). Default: `[stlLoader, threeMfLoader]`. */ @@ -48,6 +57,8 @@ export interface ModelViewerOptions { createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike; /** Injected orbit controls (tests). Default: three `OrbitControls` via the stage. */ createControls?: NonNullable; + /** Initial render scope (DD-030 D2) — e.g. `{ plateId: 3 }`. Omit to render the whole source. */ + renderScope?: RenderScope; /** * Staged loading-progress callback (DD-024): typed `stage`/`done`/`total`/`unit` events (no human copy), * each tagged with the load `generation` so a consumer drops events from a superseded `setSource`. The @@ -102,6 +113,17 @@ export interface ModelViewer { resize(width: number, height: number): void; /** Re-fit the camera to the current model bounds. */ frame(): void; + /** + * Render only a subset of the current source (DD-030 D2): `{ plateId }`, `{ objectIds }`, or an + * `{ instanceFilter }`; `null` restores the whole source. Rebuilds and reframes to the subset. Gate a + * `{ plateId }` on the ready info's `plates` being present — an unmatched scope renders empty. + */ + setRenderScope(scope: RenderScope | null): void; + /** + * Capture the current view as an image `Blob` (DD-030 D1) — for an operator-selected thumbnail or a + * large-file fallback. Rejects with `E_CAPTURE_UNSUPPORTED` when the renderer is unavailable. + */ + capture(opts?: CaptureOptions): Promise; onEvent(cb: (e: ModelViewerEvent) => void): () => void; dispose(): void; } @@ -139,7 +161,12 @@ export function createModelViewer(canvas: RenderTargetCanvas, options: ModelView // Retained so context-loss recovery can rebuild meshes from the last-set scene. let currentScene: ModelScene | null = null; + // Active render scope (DD-030 D2); applied to `currentScene` on build/reframe/restore. Null = whole source. + let currentScope: RenderScope | null = options.renderScope ?? null; let disposed = false; + /** The scene to actually build/render = the full source narrowed by the active scope. */ + const scopedScene = (): ModelScene | null => + currentScene === null ? null : currentScope ? applyRenderScope(currentScene, currentScope) : currentScene; // Monotonic token for last-wins overlapping setSource (stale async results are discarded). let sourceToken = 0; @@ -157,8 +184,9 @@ export function createModelViewer(canvas: RenderTargetCanvas, options: ModelView onContextRestored: () => { // GPU resources are gone; rebuild meshes from the retained scene and reframe, mirroring the // toolpath renderer's recovery. - if (currentScene !== null && stage !== null) { - content.setScene(currentScene); + const shown = scopedScene(); + if (shown !== null && stage !== null) { + content.setScene(shown); const f = content.framing; if (f !== null) stage.frameTo(f.center, f.radius); } @@ -203,10 +231,11 @@ export function createModelViewer(canvas: RenderTargetCanvas, options: ModelView // Last-wins: a newer setSource (or a dispose) superseded this one — discard without mutating. if (my !== sourceToken || disposed || stage === null) return info; - const total = parsed.objects.length; - progress({ stage: 'building-geometry', done: 0, total, unit: 'objects' }); - content.setScene(parsed, (done) => progress({ stage: 'building-geometry', done, total, unit: 'objects' })); currentScene = parsed; + const shown = scopedScene()!; // parsed just set → non-null + const total = shown.objects.length; + progress({ stage: 'building-geometry', done: 0, total, unit: 'objects' }); + content.setScene(shown, (done) => progress({ stage: 'building-geometry', done, total, unit: 'objects' })); const f = content.framing; if (f !== null) stage.frameTo(f.center, f.radius); progress({ stage: 'ready' }); @@ -247,6 +276,21 @@ export function createModelViewer(canvas: RenderTargetCanvas, options: ModelView const f = content.framing; if (f !== null) stage?.frameTo(f.center, f.radius); }, + setRenderScope(scope) { + currentScope = scope; + const shown = scopedScene(); + if (shown === null || stage === null) return; // no source yet — applied on the next setSource + content.setScene(shown); + const f = content.framing; + if (f !== null) stage.frameTo(f.center, f.radius); + stage.render(); + }, + capture(opts) { + if (stage === null) { + return Promise.reject(new CaptureUnsupportedError('model viewer renderer unavailable')); + } + return stage.capture(opts); + }, onEvent(cb) { listeners.add(cb); if (pending.length > 0) { diff --git a/packages/gcode-model-renderer/src/render-model-still.ts b/packages/gcode-model-renderer/src/render-model-still.ts index 38a6b6db..57a8c24d 100644 --- a/packages/gcode-model-renderer/src/render-model-still.ts +++ b/packages/gcode-model-renderer/src/render-model-still.ts @@ -14,7 +14,13 @@ import type { Confidence } from '@chestnutlabs/toolpath-core'; import type { GLRendererLike, RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three'; import { ModelRenderer, type ModelBackground, type PresentationView } from './model-renderer.js'; import { isModelScene, resolveModelScene } from './loaders.js'; -import { sceneInstanceCount, type ModelPlateSummary, type ModelScene } from './scene-model.js'; +import { + applyRenderScope, + sceneInstanceCount, + type ModelPlateSummary, + type ModelScene, + type RenderScope +} from './scene-model.js'; import { computeCacheKey } from './cache-key.js'; import type { ModelLimits } from './limits.js'; @@ -46,6 +52,13 @@ export interface RenderModelStillOptions { filamentPalette?: readonly (string | undefined)[]; /** Override the environment id folded into the cache key (default: the `three` revision). */ envId?: string; + /** + * Render only a subset of a multi-plate / multi-object source (DD-030 D2) — e.g. `{ plateId: 3 }` to + * render just plate 3 of a project, framed to that plate. Omit to render the whole project (default). + * `{ plateId }` is meaningful only when the source declares plates (`plates: 'known'`); on a source + * without plate structure it matches nothing and renders empty (the honest result). + */ + renderScope?: RenderScope; } export interface RenderModelStillResult { @@ -97,6 +110,17 @@ async function toScene( * (`DecompressionStream`); the STL and pre-built-`ModelScene` paths resolve promptly. Mirrors the * async `renderStill` on the toolpath side. */ +/** Counter that makes an opaque `instanceFilter` scope's cache key unique (never a false cache hit). */ +let scopeKeyCounter = 0; + +/** A stable, serializable cache-key fragment for a render scope (DD-030 D2). */ +function scopeCacheKey(scope?: RenderScope): unknown { + if (!scope) return undefined; + if ('plateId' in scope) return { p: scope.plateId }; + if ('objectIds' in scope) return { o: [...scope.objectIds].sort() }; + return { fn: ++scopeKeyCounter }; // opaque predicate → non-cacheable, unique each call +} + export async function renderModelStill( source: ModelSource, options: RenderModelStillOptions @@ -107,7 +131,9 @@ export async function renderModelStill( const background: ModelBackground = options.background ?? 'transparent'; const view: PresentationView = options.view ?? 'iso'; - const { scene, sourceBytes } = await toScene(source, options.limits, options.filamentPalette); + const { scene: fullScene, sourceBytes } = await toScene(source, options.limits, options.filamentPalette); + // DD-030 D2: narrow to the requested plate/object subset (framing follows the filtered bounds). + const scene = options.renderScope ? applyRenderScope(fullScene, options.renderScope) : fullScene; const renderer = new ModelRenderer({ canvas, @@ -120,7 +146,16 @@ export async function renderModelStill( renderer.setScene(scene); renderer.render(); - const optionsJson = JSON.stringify({ width, height, background, view, palette: options.filamentPalette }); + const optionsJson = JSON.stringify({ + width, + height, + background, + view, + palette: options.filamentPalette, + // Fold the scope so plate-1 and plate-2 thumbnails key distinctly. An opaque `instanceFilter` can't + // be serialized — such a still is marked non-cacheable (consumers should not reuse it by key). + scope: scopeCacheKey(options.renderScope) + }); const cacheKey = computeCacheKey(sourceBytes, optionsJson, options.envId); return { canvas, diff --git a/packages/gcode-model-renderer/src/scene-model.ts b/packages/gcode-model-renderer/src/scene-model.ts index 941a04cb..d5e31b3d 100644 --- a/packages/gcode-model-renderer/src/scene-model.ts +++ b/packages/gcode-model-renderer/src/scene-model.ts @@ -124,6 +124,96 @@ export interface ModelScene { }; } +/** + * A render-scope selector (DD-030 D2): which objects/placements of a scene to render. Generic and + * vendor-neutral — a plate is one way to derive the subset. `{plateId}` is sugar over the placement's + * {@link ModelObject.plateIds} (meaningful only when `capabilities.plates === 'known'`; on a scene with + * no plate structure it matches nothing → an empty scene, the honest result of selecting a plate that + * isn't declared). `{objectIds}` and `{instanceFilter}` need no capability (ids are always known). + */ +export type RenderScope = + | { objectIds: string[] } + | { plateId: number } + | { instanceFilter: (objectId: string, placementIndex: number) => boolean }; + +/** Local axis-aligned bounds of a geometry's positions. */ +function geometryAABB(positions: Float32Array): { min: [number, number, number]; max: [number, number, number] } { + const min: [number, number, number] = [Infinity, Infinity, Infinity]; + const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]; + for (let i = 0; i + 2 < positions.length; i += 3) { + for (let a = 0; a < 3; a++) { + const v = positions[i + a]; + if (v < min[a]) min[a] = v; + if (v > max[a]) max[a] = v; + } + } + return { min, max }; +} + +/** Transform a point by a column-major (three `Matrix4.elements`) affine Mat4. */ +function applyMat4(m: Mat4, x: number, y: number, z: number): [number, number, number] { + return [ + m[0] * x + m[4] * y + m[8] * z + m[12], + m[1] * x + m[5] * y + m[9] * z + m[13], + m[2] * x + m[6] * y + m[10] * z + m[14] + ]; +} + +/** Union of the world AABBs of the given placements of an object's geometry, into `acc`. */ +function accumulatePlacementBounds(geom: MeshGeometry, placements: readonly Mat4[], acc: ModelBounds): void { + const { min: lmin, max: lmax } = geometryAABB(geom.positions); + if (lmin[0] > lmax[0]) return; // empty geometry + const mut = acc as { min: [number, number, number]; max: [number, number, number] }; + for (const m of placements) { + for (let cx = 0; cx < 2; cx++) { + for (let cy = 0; cy < 2; cy++) { + for (let cz = 0; cz < 2; cz++) { + const w = applyMat4(m, cx ? lmax[0] : lmin[0], cy ? lmax[1] : lmin[1], cz ? lmax[2] : lmin[2]); + for (let a = 0; a < 3; a++) { + if (w[a] < mut.min[a]) mut.min[a] = w[a]; + if (w[a] > mut.max[a]) mut.max[a] = w[a]; + } + } + } + } + } +} + +/** + * Return a filtered copy of `scene` containing only the objects/placements the scope selects, with + * `bounds` recomputed from the kept placements (so a consumer frames just the selected plate/subset). + * Pure and three-free. Objects with no kept placement are dropped; an empty result (nothing matched) is + * a valid, honest scene — a `{plateId}` that matches nothing renders empty by design (gate on + * `capabilities.plates === 'known'`). `scene.plates`/`capabilities` are preserved (the source's declared + * structure is unchanged; only what is *rendered* is narrowed). + */ +export function applyRenderScope(scene: ModelScene, scope: RenderScope): ModelScene { + const matches = (o: ModelObject, i: number): boolean => { + if ('objectIds' in scope) return scope.objectIds.includes(o.id); + if ('plateId' in scope) return o.plateIds?.[i] === scope.plateId; + return scope.instanceFilter(o.id, i); + }; + const objects: ModelObject[] = []; + const bounds: ModelBounds = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }; + for (const o of scene.objects) { + const placements: readonly Mat4[] = o.instances ?? [o.transform]; + const kept: number[] = []; + for (let i = 0; i < placements.length; i++) if (matches(o, i)) kept.push(i); + if (kept.length === 0) continue; + const keptPlacements = kept.map((i) => placements[i]); + const next: ModelObject = { + ...o, + transform: keptPlacements[0], + instances: keptPlacements.length > 1 ? keptPlacements.slice() : undefined, + plateIds: o.plateIds ? kept.map((i) => o.plateIds![i]) : undefined + }; + objects.push(next); + accumulatePlacementBounds(o.geometry, keptPlacements, bounds); + } + const finalBounds: ModelBounds = objects.length === 0 ? { min: [0, 0, 0], max: [0, 0, 0] } : bounds; + return { ...scene, objects, bounds: finalBounds }; +} + /** The identity transform (column-major), for single-object sources. */ export const IDENTITY_MAT4: Mat4 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; diff --git a/packages/gcode-parser/CHANGELOG.md b/packages/gcode-parser/CHANGELOG.md index b8e912ce..fa0a7bb3 100644 --- a/packages/gcode-parser/CHANGELOG.md +++ b/packages/gcode-parser/CHANGELOG.md @@ -1,5 +1,99 @@ # @chestnutlabs/gcode-parser +## 0.18.0 + +### Minor Changes + +- [#410](https://github.com/ChestnutLabs/gcode-preview/pull/410) [`5144890`](https://github.com/ChestnutLabs/gcode-preview/commit/51448906f87ca09f3ed11f1286f7b79356110a83) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(parser): RS274NGC parameters + expressions (DD-017 Phase 1, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189) phase 7) + + The parser now understands the RS274NGC _programming_ layer that FDM slicers never use but CAM / + LinuxCNC output routinely does: numbered/named/global parameters (`[#100](https://github.com/ChestnutLabs/gcode-preview/issues/100)`, `#`, `#<_global>`), + assignment (`#n = `), and bracket expressions (`[ … ]`) usable wherever a numeric word value is + expected (`X[[#1](https://github.com/ChestnutLabs/gcode-preview/issues/1)+2]`, `F[#feed]`, `Z#`). Previously these were silently dropped, so parametric + programs rendered honest-but-**empty**; they now resolve to real geometry. + + A new self-contained module (`rs274.ts`) provides a pure recursive-descent evaluator — the RS274NGC + operator + function set with correct precedence (`**` > `*/MOD` > `+-` > comparisons > logical), + **degree**-based trig, LinuxCNC `MOD`/`EQ` semantics, `FIX`/`FUP`/`ROUND`, indirect `##n` and computed + `#[expr]` references, and a read-only system-parameter allow-list (`[#5420](https://github.com/ChestnutLabs/gcode-preview/issues/5420)–[#5422](https://github.com/ChestnutLabs/gcode-preview/issues/5422)` = current position). + It is bounded and safe: a depth guard on nested brackets, **no `eval`/`Function`**, and a capped + parameter store, so a hostile program can waste only bounded CPU. Malformed input never throws out of + the parse — the offending word is dropped with a specific disclosure (`rs274-bad-expression`, + `rs274-uninitialized-param`, `rs274-unsupported-sysparam`) and parsing continues. + + Honest and additive: a new `parametricProgram` capability (`known` when executed, `unavailable` + otherwise). Engaged only on lines that use `#`/`[` — every FDM and simple-CNC line takes the untouched + lexer and is **byte-identical** (native goldens regenerated for the additive capability key only; the + detection gate adds ~2.7% to a large FDM parse, geometry unchanged). + + **Scope: Phase 1 (parameters + expressions) only.** O-word control flow (`if`/`while`/`sub`/`call`) is a + later phase and is not yet supported — such constructs are ignored (and will be disclosed) for now. + +- [#413](https://github.com/ChestnutLabs/gcode-preview/pull/413) [`bcb4b78`](https://github.com/ChestnutLabs/gcode-preview/commit/bcb4b786fcfc6a2e967d8bfff1d4fd3a06731681) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(parser): RS274NGC O-word control flow (DD-017 Phase 2, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189) phase 7) + + Building on Phase 1's parameters + expressions, the parser now **executes** the RS274NGC O-word control + structures that CAM / LinuxCNC programs use to generate geometry: `if`/`elseif`/`else`/`endif`, + `while`/`endwhile`, `do`/`while`, `repeat`/`endrepeat`, and `break`/`continue`. A bolt-circle written as + a `while` loop over a computed angle, a pocket cut by a `repeat`, an `if` that selects a tool path — + these previously ran once (or not at all) because the parser is forward-only; they now resolve to the + full, correct toolpath. + + A new module (`rs274-flow.ts`) is the program-buffered interpreter of DD-017 D1: engaged **only** when a + program actually contains an O-word control line (a cheap detection scan), it buffers the program, builds + a block tree, and executes it — feeding every non-control line back through the same engine so + geometry/params/expressions stay on one code path. Loops evaluate their conditions/counts against the + same shared parameter store, so `# = [# + 1]` inside a loop body does what you expect. + + **Bounded and safe (this is now a small interpreter).** A new `maxProgramIterations` limit (default + `1_000_000`) bounds **total loop work** — charged per loop pass _and_ per statement executed inside a + loop body, so a large body cannot multiply the real work past the cap — and stops a runaway `o while [1]` + with a partial result and a `rs274-iteration-limit` disclosure. Structural nesting is capped; loops are + ordinary iteration, not recursion, so an adversarial program can waste only bounded CPU — it cannot hang, + blow the stack, or OOM the worker. There is no `eval` and no I/O. + + **Honest and additive.** Clean flow keeps `parametricProgram: 'known'`; a degraded run (a hit iteration + cap, an unbalanced or unsupported O-word, or a malformed condition) reports `'approximated'` and always + emits a specific disclosure — never silently wrong. Subroutines (`sub`/`call`/`return`) are a later phase + and are explicitly disclosed as unsupported and **not** executed inline. Non-parametric input never + enters the interpreter and stays **byte-identical** (both golden suites unchanged). Streaming input, which + cannot re-run a line range from a partial stream, discloses and runs linearly. + +- [#415](https://github.com/ChestnutLabs/gcode-preview/pull/415) [`dc89056`](https://github.com/ChestnutLabs/gcode-preview/commit/dc890568d45e59635b9994d8409ef6cdc0847f80) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(parser): RS274NGC subroutines (DD-017 Phase 3, [#189](https://github.com/ChestnutLabs/gcode-preview/issues/189) phase 7) + + Completes the RS274NGC programming layer: the parser now executes in-file O-word **subroutines** — + `o sub`/`endsub`, `o call [args]`, and `o return`. A `call` binds its arguments to `[#1](https://github.com/ChestnutLabs/gcode-preview/issues/1)`..`[#30](https://github.com/ChestnutLabs/gcode-preview/issues/30)` + in a fresh call frame, runs the named sub's body, and returns; `return` exits early. Subroutines may be + forward-referenced (a `call` before the `sub` that defines it) and may recurse. This is how CAM/LinuxCNC + programs factor repeated geometry (a "stamp" drilled at many positions, a parametric feature reused + across a part). + + **Correct scoping (recursion-safe).** Numbered parameters `[#1](https://github.com/ChestnutLabs/gcode-preview/issues/1)`..`[#30](https://github.com/ChestnutLabs/gcode-preview/issues/30)` and non-underscore named parameters + (`#`) are LOCAL to each call frame; `[#31](https://github.com/ChestnutLabs/gcode-preview/issues/31)`+ and underscore-named (`#<_global>`) are shared globals — + matching LinuxCNC. A recursive sub's `[#1](https://github.com/ChestnutLabs/gcode-preview/issues/1)` therefore cannot clobber its caller's, and a sub's locals do not + leak out. The main program runs in the base scope, so a program that uses no subroutines behaves exactly + as before (byte-identical; both golden suites unchanged). + + **Bounded and safe.** A new `maxCallDepth` limit (default `50`) caps subroutine recursion — an unbounded + or mutually-recursive `call` is disclosed (`rs274-call-depth`) and skipped, never a stack overflow. A + runtime execution-depth backstop additionally bounds the combined structural-nesting + call-depth JS + recursion. A sub called inside a loop still charges its body work against `maxProgramIterations`, so a huge + sub body cannot multiply work past the cap. `break`/`continue`/`return` are frame-local — they never jump + across a call boundary. + + **Honest.** Clean subroutine execution keeps `parametricProgram: 'known'`; a degraded run (unknown sub, + recursion-limit hit, duplicate definition, misplaced `return`) reports `'approximated'` with a specific + disclosure — never silently wrong. Subroutine return VALUES are not yet propagated to the caller (a + documented Phase-3 limitation; `return` acts as an early exit). External subroutine files (`M98`, + `o call` resolving to a separate `.ngc`) remain an explicit non-goal (no I/O in the parser). + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/gcode-bgcode@0.18.0 + - @chestnutlabs/gcode-containers@0.18.0 + - @chestnutlabs/gcode-dialects@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Minor Changes diff --git a/packages/gcode-parser/package.json b/packages/gcode-parser/package.json index a46df773..a4c7a7ec 100644 --- a/packages/gcode-parser/package.json +++ b/packages/gcode-parser/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-parser", - "version": "0.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/gcode-dialects": "0.17.0", - "@chestnutlabs/gcode-containers": "0.17.0", - "@chestnutlabs/gcode-bgcode": "0.17.0" + "@chestnutlabs/toolpath-core": "0.18.0", + "@chestnutlabs/gcode-dialects": "0.18.0", + "@chestnutlabs/gcode-containers": "0.18.0", + "@chestnutlabs/gcode-bgcode": "0.18.0" } } diff --git a/packages/gcode-parser/src/__tests__/rs274-flow.test.ts b/packages/gcode-parser/src/__tests__/rs274-flow.test.ts new file mode 100644 index 00000000..e439c5fc --- /dev/null +++ b/packages/gcode-parser/src/__tests__/rs274-flow.test.ts @@ -0,0 +1,497 @@ +/* + * DD-017 Phase 2 — RS274NGC O-word control flow. Detection + classification are unit-tested directly; + * conditionals, loops, break/continue, bounded execution, and the honesty tiers run real programs + * through `parseGcodeToIR` and assert on the resolved geometry / capability / disclosures. + */ +import { describe, expect, it } from 'vitest'; +import { parseGcodeToIR, parseGcodeToIRAsync } from '../parse'; +import { parseGcodeStreamToIR } from '../streaming'; +import { classifyOWord, isOWordControlLine, programUsesOWords, RS274_FLOW_WARN } from '../rs274-flow'; + +/** Parse a program (array of lines) and return the IR. */ +function run(lines: string[], limits?: Record) { + return parseGcodeToIR(lines.join('\n'), limits ? { limits } : {}).ir; +} + +/** Last emitted segment's absolute end position. */ +function lastEnd(ir: ReturnType): { x: number; y: number; z: number } { + const n = ir.segments.count; + const o = ir.header.originOffset; + return { x: o.x + ir.segments.x1[n - 1], y: o.y + ir.segments.y1[n - 1], z: o.z + ir.segments.z1[n - 1] }; +} + +describe('rs274-flow detection', () => { + it('programUsesOWords: true only for a recognized O-word control line', () => { + expect(programUsesOWords('G1 X10 Y20\nG1 X20')).toBe(false); + expect(programUsesOWords('# = 0\nG1 X#')).toBe(false); // params ≠ flow + expect(programUsesOWords('o100 while [#1 LT 5]\nG1 X1\no100 endwhile')).toBe(true); + expect(programUsesOWords('o1 if [1]\nG1 X1\no1 endif')).toBe(true); + expect(programUsesOWords('o repeat [3]\nG1 X1\no endrepeat')).toBe(true); + }); + + it('detection tolerates a leading N-number, a leading comment, and mixed case', () => { + expect(programUsesOWords('N40 o100 while [1]\no100 endwhile')).toBe(true); + expect(programUsesOWords('(setup) O100 REPEAT [2]\nG1 X1\nO100 ENDREPEAT')).toBe(true); + }); + + it('detection matches the classifier even with a comment BETWEEN the N-word and the o-word', () => { + // Regression: the cheap pre-filter must not be stricter than classifyOWord, or a real O-word program + // is silently run linearly (wrong geometry reported as a confident `known`). + const line = 'N10 (loop) o1 while [#1 LT 5]'; + expect(classifyOWord(line)).not.toBeNull(); + expect(isOWordControlLine(line)).toBe(true); + expect(programUsesOWords(line)).toBe(true); + }); + + it('an `o` with an unrecognized trailing word is NOT flow (never hijacks plain code)', () => { + expect(programUsesOWords('o5 G1 X10')).toBe(false); // 'g1' is not an O-word keyword + expect(isOWordControlLine('o5 G1 X10')).toBe(false); + expect(isOWordControlLine('G1 X10 Y20')).toBe(false); + expect(isOWordControlLine('o100 while [1]')).toBe(true); + }); + + it('classifyOWord parses keyword, id (numeric/named/computed), and the trailing expression', () => { + expect(classifyOWord('o100 while [#1 LT 5]')).toEqual({ keyword: 'while', id: '100', expr: '[#1 LT 5]' }); + expect(classifyOWord('o if [# GT 0]')).toEqual({ keyword: 'if', id: '', expr: '[# GT 0]' }); + expect(classifyOWord('o[#2] repeat [3]')).toEqual({ keyword: 'repeat', id: '[#2]', expr: '[3]' }); + expect(classifyOWord('o100 endwhile')).toEqual({ keyword: 'endwhile', id: '100', expr: '' }); + expect(classifyOWord('G1 X10')).toBeNull(); + expect(classifyOWord('o5 g1 x10')).toBeNull(); // unrecognized keyword → plain line + }); +}); + +describe('rs274-flow conditionals', () => { + it('if [true] runs the body; if [false] skips it', () => { + const t = run(['G90', 'o1 if [1]', 'G1 X5 Y0', 'o1 endif']); + expect(t.segments.count).toBe(1); + expect(lastEnd(t).x).toBeCloseTo(5); + const f = run(['G90', 'o1 if [0]', 'G1 X5 Y0', 'o1 endif']); + expect(f.segments.count).toBe(0); + }); + + it('if / else takes the correct arm', () => { + const ir = run(['G90', 'o1 if [2 GT 3]', 'G1 X5 Y0', 'o1 else', 'G1 X9 Y0', 'o1 endif']); + expect(ir.segments.count).toBe(1); + expect(lastEnd(ir).x).toBeCloseTo(9); + }); + + it('if / elseif / else — first true branch wins, later ones are skipped', () => { + const ir = run([ + 'G90', + '# = 2', + 'o1 if [# EQ 1]', + 'G1 X10 Y0', + 'o1 elseif [# EQ 2]', + 'G1 X20 Y0', + 'o1 elseif [# EQ 2]', // also true, but must NOT run (earlier arm already taken) + 'G1 X30 Y0', + 'o1 else', + 'G1 X40 Y0', + 'o1 endif' + ]); + expect(ir.segments.count).toBe(1); + expect(lastEnd(ir).x).toBeCloseTo(20); + }); +}); + +describe('rs274-flow loops', () => { + it('while: iterates while the condition holds (a counted move sweep)', () => { + const ir = run(['G90', '# = 0', 'o1 while [# LT 5]', 'G1 X# Y0', '# = [# + 1]', 'o1 endwhile']); + expect(ir.segments.count).toBe(5); // X = 0,1,2,3,4 + expect(ir.boundsWithTravel.max.x).toBeCloseTo(4); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + }); + + it('do / while: the body runs at least once even when the condition is false up front', () => { + const ir = run(['G90', '# = 10', 'o1 do', 'G1 X# Y0', '# = [# + 1]', 'o1 while [# LT 10]']); + expect(ir.segments.count).toBe(1); + expect(lastEnd(ir).x).toBeCloseTo(10); + }); + + it('repeat [n]: runs exactly n times', () => { + const ir = run(['G90', '# = 0', 'o1 repeat [3]', 'G1 X# Y0', '# = [# + 1]', 'o1 endrepeat']); + expect(ir.segments.count).toBe(3); + expect(ir.boundsWithTravel.max.x).toBeCloseTo(2); + }); + + it('repeat with a non-positive count runs zero times', () => { + const ir = run(['G90', 'o1 repeat [0]', 'G1 X5 Y0', 'o1 endrepeat']); + expect(ir.segments.count).toBe(0); + }); + + it('nested loops multiply (a 2×3 grid)', () => { + const ir = run([ + 'G90', + '# = 0', + 'o1 repeat [2]', + '# = 0', + 'o2 repeat [3]', + 'G1 X# Y#', + '# = [# + 1]', + 'o2 endrepeat', + '# = [# + 1]', + 'o1 endrepeat' + ]); + expect(ir.segments.count).toBe(6); + expect(ir.boundsWithTravel.max.x).toBeCloseTo(2); + expect(ir.boundsWithTravel.max.y).toBeCloseTo(1); + }); +}); + +describe('rs274-flow break / continue', () => { + it('break exits the innermost loop', () => { + const ir = run([ + 'G90', + '# = 0', + 'o1 while [1]', + 'G1 X# Y0', + '# = [# + 1]', + 'o2 if [# GE 3]', + 'o1 break', + 'o2 endif', + 'o1 endwhile' + ]); + expect(ir.segments.count).toBe(3); // X = 0,1,2 then break + expect(ir.boundsWithTravel.max.x).toBeCloseTo(2); + expect(ir.header.capabilities.parametricProgram).toBe('known'); // a clean break is not degraded + }); + + it('continue skips the rest of the body for that iteration', () => { + const ir = run([ + 'G90', + '# = 0', + 'o1 while [# LT 5]', + '# = [# + 1]', + 'o2 if [# EQ 3]', + 'o1 continue', + 'o2 endif', + 'G1 X# Y0', + 'o1 endwhile' + ]); + expect(ir.segments.count).toBe(4); // X = 1,2,4,5 (3 skipped) + expect(ir.boundsWithTravel.max.x).toBeCloseTo(5); + }); +}); + +describe('rs274-flow bounded execution (DD-017 §4.5 — must never hang)', () => { + it('an infinite loop with no geometry stops at maxProgramIterations and discloses', () => { + const ir = run(['# = 0', 'o1 while [1]', '# = [# + 1]', 'o1 endwhile'], { maxProgramIterations: 500 }); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.iterationLimit)).toBe(true); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); // degraded → honest + }); + + it('an infinite loop that emits geometry is still bounded (by segments or iterations)', () => { + const ir = run(['G90', '# = 0', 'o1 while [1]', 'G1 X# Y0', '# = [# + 1]', 'o1 endwhile'], { + maxProgramIterations: 200 + }); + // It terminated (the test itself completing proves no hang); geometry is bounded and disclosed. + expect(ir.segments.count).toBeLessThanOrEqual(200); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.iterationLimit)).toBe(true); + }); + + it('a huge repeat count is capped by maxProgramIterations', () => { + const ir = run(['G90', 'o1 repeat [100000000]', 'G1 X1 Y0', 'o1 endrepeat'], { maxProgramIterations: 300 }); + expect(ir.segments.count).toBeLessThanOrEqual(300); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.iterationLimit)).toBe(true); + }); + + it('the budget bounds TOTAL loop work, not just passes (a large body cannot multiply past the cap)', () => { + // 4 geometry statements per pass. A per-PASS-only charge would allow maxProgramIterations × 4 segments + // (160); charging every executed body statement bounds total body executions to the cap itself. + const ir = run(['G90', 'o1 while [1]', 'G1 X1 Y0', 'G1 X2 Y0', 'G1 X3 Y0', 'G1 X4 Y0', 'o1 endwhile'], { + maxProgramIterations: 40 + }); + expect(ir.segments.count).toBeLessThanOrEqual(40); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.iterationLimit)).toBe(true); + }); + + it('a malformed loop condition is disclosed and treated as false (no execution, no crash)', () => { + const ir = run(['G90', 'o1 while [#1 LT]', 'G1 X5 Y0', 'o1 endwhile']); + expect(ir.segments.count).toBe(0); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); + }); +}); + +describe('rs274-flow honesty & robustness', () => { + it('an unbalanced loop (no endwhile) is disclosed; the dangling body is not executed', () => { + const ir = run(['G90', '# = 0', 'o1 while [# LT 3]', 'G1 X# Y0', '# = [# + 1]']); + expect(ir.segments.count).toBe(0); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.unbalancedOword)).toBe(true); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); + }); + + it('a sub is not run inline but IS run on call (Phase 3 executes subroutines)', () => { + const ir = run(['G90', 'o100 sub', 'G1 X5 Y0', 'o100 endsub', 'G1 X1 Y0', 'o100 call']); + expect(ir.segments.count).toBe(2); // top-level G1 X1, then the call runs the sub body (G1 X5) + expect(lastEnd(ir).x).toBeCloseTo(5); + expect(ir.header.capabilities.parametricProgram).toBe('known'); // supported now — not degraded + }); + + it('a misplaced else (no enclosing if) is disclosed; following code still runs', () => { + const ir = run(['G90', 'o1 else', 'G1 X1 Y0']); + expect(ir.segments.count).toBe(1); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.misplacedControl)).toBe(true); + }); + + it('an elseif/else after an else is unreachable — disclosed and dropped', () => { + const ir = run([ + 'G90', + 'o1 if [0]', + 'G1 X1 Y0', + 'o1 else', + 'G1 X2 Y0', + 'o1 elseif [1]', // after else — can never run + 'G1 X3 Y0', + 'o1 endif' + ]); + expect(ir.segments.count).toBe(1); // the else arm ran + expect(lastEnd(ir).x).toBeCloseTo(2); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.misplacedControl)).toBe(true); + }); + + it('a geometry limit that truncates flow marks the capability approximated (not a clean known)', () => { + const ir = run(['G90', 'o1 repeat [100]', 'G1 X1 Y0', 'o1 endrepeat'], { maxSegments: 5 }); + expect(ir.header.complete).toBe(false); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); + }); + + it('detection catches a fully N-numbered, comment-annotated loop and executes it (not linear)', () => { + const ir = run([ + 'G90', + 'N5 (init) # = 0', + 'N10 (loop) o1 while [# LT 3]', + 'N20 G1 X# Y0', + 'N25 # = [# + 1]', + 'N30 (done) o1 endwhile' + ]); + expect(ir.segments.count).toBe(3); // ran the loop, did NOT fall to the linear one-pass path + expect(ir.boundsWithTravel.max.x).toBeCloseTo(2); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + }); + + it('clean control flow reports parametricProgram known; a non-parametric file reports unavailable', () => { + const clean = run(['G90', 'o1 repeat [2]', 'G1 X1 Y0', 'o1 endrepeat']); + expect(clean.header.capabilities.parametricProgram).toBe('known'); + const fdm = run(['G1 X10 Y10 E1', 'G1 X20 Y10 E2']); + expect(fdm.header.capabilities.parametricProgram).toBe('unavailable'); + }); + + it('a computed segment maps its srcByte to the executing body line, not the loop header (D7)', () => { + const src = ['G90', '# = 0', 'o1 repeat [1]', 'G1 X7 Y0', 'o1 endrepeat'].join('\n'); + const ir = parseGcodeToIR(src).ir; + const bodyOffset = src.indexOf('G1 X7'); + expect(ir.segments.srcByte[0]).toBe(bodyOffset); + }); +}); + +describe('rs274-flow across drivers', () => { + const loop = ['G90', '# = 0', 'o1 while [# LT 5]', 'G1 X# Y0', '# = [# + 1]', 'o1 endwhile']; + + it('the async driver interprets control flow identically to the sync driver', async () => { + const { ir } = await parseGcodeToIRAsync(loop.join('\n')); + expect(ir.segments.count).toBe(5); + expect(ir.boundsWithTravel.max.x).toBeCloseTo(4); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + }); + + it('the streaming driver discloses (control flow needs a buffered program) and runs linearly', async () => { + const { ir } = await parseGcodeStreamToIR(new Blob([loop.join('\n')])); + // Linear pass: the loop header/endwhile are inert, so the body executes exactly once (X = 0). + expect(ir.segments.count).toBe(1); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.streamingUnsupported)).toBe(true); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); + }); +}); + +describe('rs274-flow real LinuxCNC-idiom validation', () => { + it('a while-loop bolt circle drills the computed hole positions (params + degree trig + flow)', () => { + // 4 holes evenly on a radius-20 circle about (50,40): angles 0/90/180/270 → (70,40),(50,60),(30,40),(50,20). + const ir = run([ + 'G21 G90 G17', + '# = 50', + '# = 40', + '# = 20', + '# = 4', + '# = 0', + 'o100 while [# LT #]', + '# = [# * 360 / #]', + 'G0 X[# + # * COS[#]] Y[# + # * SIN[#]]', + '# = [# + 1]', + 'o100 endwhile' + ]); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + expect(ir.segments.count).toBe(4); // one rapid per hole + const o = ir.header.originOffset; + const hole = (i: number) => ({ x: o.x + ir.segments.x1[i], y: o.y + ir.segments.y1[i] }); + // Each iteration's rapid lands on the trig-derived hole position. + expect(hole(0)).toMatchObject({ x: expect.closeTo(70, 3), y: expect.closeTo(40, 3) }); // 0° + expect(hole(1)).toMatchObject({ x: expect.closeTo(50, 3), y: expect.closeTo(60, 3) }); // 90° + expect(hole(2)).toMatchObject({ x: expect.closeTo(30, 3), y: expect.closeTo(40, 3) }); // 180° + expect(hole(3)).toMatchObject({ x: expect.closeTo(50, 3), y: expect.closeTo(20, 3) }); // 270° + }); +}); + +describe('rs274-flow subroutines (Phase 3)', () => { + it('defines a sub and runs it only on call; args bind to #1..#30', () => { + const ir = run(['G90', 'o100 sub', 'G1 X#1 Y#2 Z#3', 'o100 endsub', 'o100 call [10] [20] [30]']); + expect(ir.segments.count).toBe(1); // the sub body runs once, on the call — not inline + expect(lastEnd(ir)).toMatchObject({ x: expect.closeTo(10), y: expect.closeTo(20), z: expect.closeTo(30) }); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + }); + + it('a sub body never runs where it is defined (only on call)', () => { + const ir = run(['G90', 'o100 sub', 'G1 X99 Y0', 'o100 endsub', 'G1 X1 Y0']); + expect(ir.segments.count).toBe(1); // only the top-level G1 X1 — the sub definition did not execute + expect(lastEnd(ir).x).toBeCloseTo(1); + }); + + it('numbered params #1..#30 are LOCAL to the call frame (recursion-safe)', () => { + // #1 is used AFTER the recursive call — proves the callee could not clobber the caller's #1. + const ir = run([ + 'G90', + 'o100 sub', + 'o1 if [#1 GT 0]', + 'o100 call [#1 - 1]', + 'o1 endif', + 'G1 X#1 Y0', // after the recursive call — must still see this frame's #1 + 'o100 endsub', + 'o100 call [3]' + ]); + expect(ir.segments.count).toBe(4); // X = 0,1,2,3 as the recursion unwinds + expect(lastEnd(ir).x).toBeCloseTo(3); // the OUTERMOST frame's #1 survived its recursive call + }); + + it('named params: # is per-frame, #<_global> is shared', () => { + const ir = run([ + 'G90', + 'o100 sub', + '# = 99', // local to the sub — must not leak to the caller + '#<_g> = 7', // global — visible to the caller after return + 'o100 endsub', + '# = 1', + '#<_g> = 1', + 'o100 call', + 'G1 X# Y#<_g>' + ]); + expect(lastEnd(ir)).toMatchObject({ x: expect.closeTo(1), y: expect.closeTo(7) }); + }); + + it('#31+ numbered params are global (shared across frames)', () => { + const ir = run(['G90', 'o100 sub', '#100 = 42', 'o100 endsub', 'o100 call', 'G1 X#100 Y0']); + expect(lastEnd(ir).x).toBeCloseTo(42); // the sub wrote a global numbered param the caller reads + }); + + it('return exits the subroutine early', () => { + const ir = run([ + 'G90', + 'o100 sub', + 'G1 X1 Y0', + 'o1 if [1]', + 'o100 return', + 'o1 endif', + 'G1 X99 Y0', // after the return — skipped + 'o100 endsub', + 'o100 call' + ]); + expect(ir.segments.count).toBe(1); + expect(lastEnd(ir).x).toBeCloseTo(1); + expect(ir.header.capabilities.parametricProgram).toBe('known'); // a clean return is not degraded + }); + + it('a call can forward-reference a sub defined later in the file', () => { + const ir = run(['G90', 'o100 call [5]', 'o100 sub', 'G1 X#1 Y0', 'o100 endsub']); + expect(ir.segments.count).toBe(1); + expect(lastEnd(ir).x).toBeCloseTo(5); + }); + + it('a "stamp" sub called at several positions (real CAM idiom)', () => { + const ir = run([ + 'G90 G21', + 'o sub', + 'G0 X#1 Y#2', + 'G1 Z-2', + 'G0 Z5', + 'o endsub', + 'G0 Z5', + 'o call [10] [10]', + 'o call [20] [10]', + 'o call [30] [10]' + ]); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + expect(ir.boundsWithTravel.max.x).toBeCloseTo(30); // last stamp + expect(ir.boundsWithTravel.min.z).toBeCloseTo(-2); // the plunge depth + }); +}); + +describe('rs274-flow subroutine safety & honesty (Phase 3)', () => { + it('unbounded recursion stops at maxCallDepth and discloses (no stack overflow)', () => { + const ir = run(['G90', 'o100 sub', 'o100 call', 'o100 endsub', 'o100 call'], { maxCallDepth: 10 }); + // The test completing at all proves no stack overflow escaped the parse. + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.callDepth)).toBe(true); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); + }); + + it('a bounded recursion within maxCallDepth runs cleanly (known)', () => { + const ir = run([ + 'G90', + 'o100 sub', + 'G1 X#1 Y0', + 'o1 if [#1 GT 0]', + 'o100 call [#1 - 1]', + 'o1 endif', + 'o100 endsub', + 'o100 call [5]' + ]); + expect(ir.segments.count).toBe(6); // X = 5,4,3,2,1,0 + expect(ir.header.capabilities.parametricProgram).toBe('known'); + }); + + it('calling an undefined subroutine is disclosed and skipped', () => { + const ir = run(['G90', 'o100 call', 'G1 X5 Y0']); + expect(ir.segments.count).toBe(1); + expect(lastEnd(ir).x).toBeCloseTo(5); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.unknownSub)).toBe(true); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); + }); + + it('a redefined subroutine keeps the first definition and discloses', () => { + const ir = run(['G90', 'o100 sub', 'G1 X1 Y0', 'o100 endsub', 'o100 sub', 'G1 X99 Y0', 'o100 endsub', 'o100 call']); + expect(lastEnd(ir).x).toBeCloseTo(1); // first definition won + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.duplicateSub)).toBe(true); + }); + + it('a return outside any subroutine is disclosed; following code still runs', () => { + const ir = run(['G90', 'o100 return', 'G1 X5 Y0']); + expect(ir.segments.count).toBe(1); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.misplacedControl)).toBe(true); + }); + + it('a loop-free recursive fan-out is bounded by maxProgramIterations (no hang, no stack overflow)', () => { + // The sub calls itself TWICE with no base case — the call-tree is exponential in depth. maxCallDepth + // bounds only the DEPTH; the per-call charge bounds total call-tree SIZE. The test completing proves it. + const ir = run(['o100 sub', 'o100 call', 'o100 call', 'o100 endsub', 'o100 call'], { + maxProgramIterations: 1000 + }); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.iterationLimit)).toBe(true); + expect(ir.header.capabilities.parametricProgram).toBe('approximated'); + }); + + it('mutual recursion is bounded too', () => { + const ir = run(['o1 sub', 'o2 call', 'o1 endsub', 'o2 sub', 'o1 call', 'o2 endsub', 'o1 call'], { + maxCallDepth: 20 + }); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.callDepth)).toBe(true); + }); + + it('an endsub whose id does not match its sub is disclosed', () => { + const ir = run(['G90', 'o100 sub', 'G1 X1 Y0', 'o200 endsub', 'o100 call']); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.unbalancedOword)).toBe(true); + expect(lastEnd(ir).x).toBeCloseTo(1); // still registered under the sub's id, still callable + }); + + it('a malformed call argument is disclosed and dropped', () => { + const ir = run(['G90', 'o100 sub', 'G1 X#1 Y0', 'o100 endsub', 'o100 call [1] [2']); + expect(ir.header.warnings.some((w) => w.code === RS274_FLOW_WARN.unsupportedOword)).toBe(true); + expect(lastEnd(ir).x).toBeCloseTo(1); // the well-formed first arg still bound to #1 + }); +}); diff --git a/packages/gcode-parser/src/__tests__/rs274.test.ts b/packages/gcode-parser/src/__tests__/rs274.test.ts new file mode 100644 index 00000000..a7f4a4e8 --- /dev/null +++ b/packages/gcode-parser/src/__tests__/rs274.test.ts @@ -0,0 +1,300 @@ +/* + * DD-017 Phase 1 — RS274NGC parameters + expressions. Unit tests drive the interpreter through its + * public `Rs274Context.lexLine` surface (resolving a word value proves the whole tokenize→eval→store + * path); integration tests run real parametric G-code through `parseGcodeToIR`. + */ +import { describe, expect, it } from 'vitest'; +import { Rs274Context, lineUsesParametric, RS274_WARN } from '../rs274'; +import { parseGcodeToIR } from '../parse'; + +/** Evaluate a single word value `X` and return the resolved number (or undefined if dropped). */ +function evalX(src: string, warn = () => {}, pos = () => ({ x: 0, y: 0, z: 0 })): number | undefined { + const ctx = new Rs274Context(warn, pos); + const r = ctx.lexLine(`X${src}`); + return r.kind === 'cmd' ? r.params.x : undefined; +} + +/** A context with a warning collector, for assign→read sequences. */ +function ctxWith(): { ctx: Rs274Context; warns: string[] } { + const warns: string[] = []; + const ctx = new Rs274Context( + (code) => warns.push(code), + () => ({ x: 11, y: 22, z: 33 }) + ); + return { ctx, warns }; +} + +describe('rs274 detection gate', () => { + it('flags only lines that use `#` or `[`', () => { + expect(lineUsesParametric('G1 X10 Y20')).toBe(false); + expect(lineUsesParametric('G1 X#100')).toBe(true); + expect(lineUsesParametric('G1 X[1+2]')).toBe(true); + }); +}); + +describe('rs274 expressions', () => { + it('basic arithmetic + grouping', () => { + expect(evalX('[2+3]')).toBe(5); + expect(evalX('[10-4]')).toBe(6); + expect(evalX('[3*4]')).toBe(12); + expect(evalX('[10/4]')).toBe(2.5); + expect(evalX('[[2+3]*4]')).toBe(20); + }); + + it('RS274NGC precedence: ** > * / MOD > + - > compare > logical', () => { + expect(evalX('[2+3*4]')).toBe(14); // * before + + expect(evalX('[2*3+4]')).toBe(10); + expect(evalX('[2**3]')).toBe(8); + expect(evalX('[2**3**2]')).toBe(512); // ** right-associative: 2**(3**2) + expect(evalX('[1+1 GT 1]')).toBe(1); // (1+1) GT 1 + expect(evalX('[1 AND 1 EQ 1]')).toBe(1); // 1 AND (1 EQ 1) + }); + + it('unary minus/plus', () => { + expect(evalX('[-5]')).toBe(-5); + expect(evalX('[-[2+3]]')).toBe(-5); + expect(evalX('[3 - -2]')).toBe(5); + expect(evalX('[2 ** -1]')).toBe(0.5); + }); + + it('MOD is floored (LinuxCNC, sign of divisor)', () => { + expect(evalX('[7 MOD 3]')).toBe(1); + expect(evalX('[-7 MOD 3]')).toBe(2); + expect(evalX('[7 MOD -3]')).toBe(-2); + }); + + it('comparisons + logical return 1/0; EQ/NE use a tolerance', () => { + expect(evalX('[2 GT 1]')).toBe(1); + expect(evalX('[1 GE 1]')).toBe(1); + expect(evalX('[1 LT 1]')).toBe(0); + expect(evalX('[1 AND 0]')).toBe(0); + expect(evalX('[1 OR 0]')).toBe(1); + expect(evalX('[1 XOR 1]')).toBe(0); + expect(evalX('[2 EQ 2.000001]')).toBe(1); // within tolerance + expect(evalX('[2 NE 3]')).toBe(1); + }); + + it('functions — trig in DEGREES', () => { + expect(evalX('[SIN[30]]')).toBeCloseTo(0.5, 9); + expect(evalX('[COS[60]]')).toBeCloseTo(0.5, 9); + expect(evalX('[TAN[45]]')).toBeCloseTo(1, 9); + expect(evalX('[ASIN[0.5]]')).toBeCloseTo(30, 9); + expect(evalX('[ACOS[0.5]]')).toBeCloseTo(60, 9); + expect(evalX('[ATAN[1]/[1]]')).toBeCloseTo(45, 9); + }); + + it('functions — ABS/SQRT/FIX/FUP/ROUND/LN/EXP', () => { + expect(evalX('[ABS[0-7]]')).toBe(7); + expect(evalX('[SQRT[16]]')).toBe(4); + expect(evalX('[FIX[2.7]]')).toBe(2); + expect(evalX('[FIX[-2.7]]')).toBe(-3); // toward -inf + expect(evalX('[FUP[2.1]]')).toBe(3); + expect(evalX('[FUP[-2.1]]')).toBe(-2); // toward +inf + expect(evalX('[ROUND[2.5]]')).toBe(3); + expect(evalX('[EXP[0]]')).toBe(1); + expect(evalX('[LN[1]]')).toBe(0); + }); +}); + +describe('rs274 parameters', () => { + it('numbered assign + read, and in expressions', () => { + const { ctx } = ctxWith(); + expect(ctx.lexLine('#100 = [2+3]').kind).toBe('assign'); + const a = ctx.lexLine('X#100'); + expect(a.kind === 'cmd' && a.params.x).toBe(5); + const b = ctx.lexLine('X[#100 + 1]'); + expect(b.kind === 'cmd' && b.params.x).toBe(6); + }); + + it('named (local) + global params', () => { + const { ctx } = ctxWith(); + ctx.lexLine('# = 10'); + ctx.lexLine('#<_depth> = [2*3]'); + const a = ctx.lexLine('X# Y#<_depth>'); + expect(a.kind === 'cmd' && a.params.x).toBe(10); + expect(a.kind === 'cmd' && a.params.y).toBe(6); + }); + + it('indirect reference ##n', () => { + const { ctx } = ctxWith(); + ctx.lexLine('#1 = 5'); + ctx.lexLine('#5 = 42'); + const a = ctx.lexLine('X##1'); // #1 → 5, #5 → 42 + expect(a.kind === 'cmd' && a.params.x).toBe(42); + }); + + it('computed parameter number #[expr]', () => { + const { ctx } = ctxWith(); + ctx.lexLine('#7 = 99'); + const a = ctx.lexLine('X#[3+4]'); + expect(a.kind === 'cmd' && a.params.x).toBe(99); + }); + + it('read-before-write returns 0 and discloses once', () => { + const { ctx, warns } = ctxWith(); + const a = ctx.lexLine('X#42'); + expect(a.kind === 'cmd' && a.params.x).toBe(0); + ctx.lexLine('Y#43'); // second uninitialized read + expect(warns.filter((w) => w === RS274_WARN.uninitializedParam).length).toBe(1); // once only + }); + + it('EXISTS tests a named param without triggering an uninitialized read', () => { + const { ctx, warns } = ctxWith(); + expect(evalXctx(ctx, '[EXISTS[#]]')).toBe(0); + ctx.lexLine('# = 1'); + expect(evalXctx(ctx, '[EXISTS[#]]')).toBe(1); + expect(warns).not.toContain(RS274_WARN.uninitializedParam); + }); + + it('system parameters #5420–#5422 read live position; others disclose', () => { + const { ctx, warns } = ctxWith(); // position = (11,22,33) + expect(evalXctx(ctx, '#5420')).toBe(11); + expect(evalXctx(ctx, '#5421')).toBe(22); + expect(evalXctx(ctx, '#5422')).toBe(33); + expect(evalXctx(ctx, '#5999')).toBe(0); + expect(warns).toContain(RS274_WARN.unsupportedSysParam); + }); +}); + +describe('rs274 robustness', () => { + it('a malformed expression drops that word + discloses, parse continues', () => { + const { ctx, warns } = ctxWith(); + const a = ctx.lexLine('X[2+] Y5'); // X malformed, Y fine + expect(a.kind === 'cmd' && a.params.x).toBeUndefined(); + expect(a.kind === 'cmd' && a.params.y).toBe(5); + expect(warns).toContain(RS274_WARN.badExpression); + }); + + it('a `#` inside a ( ) comment is not parametric — routed to the plain lexer', () => { + const { ctx } = ctxWith(); + expect(ctx.lexLine('G1 X10 (move to #home)').kind).toBe('plain'); + }); + + it('deeply nested brackets are bounded (no stack blow-up)', () => { + const { ctx, warns } = ctxWith(); + const deep = '['.repeat(200) + '1' + ']'.repeat(200); + const a = ctx.lexLine(`X${deep}`); + // dropped + disclosed, never a crash + expect(a.kind === 'cmd' && a.params.x).toBeUndefined(); + expect(warns).toContain(RS274_WARN.badExpression); + }); + + it('bounds adversarial UNARY chains and INDIRECT ref chains (security — must not overflow the stack)', () => { + const { ctx } = ctxWith(); + const unary = ctx.lexLine('X[' + '-'.repeat(50000) + '1]'); // ---…1 + expect(unary.kind === 'cmd' && unary.params.x).toBeUndefined(); + const indirect = ctx.lexLine('X' + '#'.repeat(50000) + '1'); // ##…#1 + expect(indirect.kind === 'cmd' && indirect.params.x).toBeUndefined(); + // The point is simply that neither threw a RangeError out of lexLine. + }); +}); + +describe('rs274 review fixes', () => { + it('a computed NaN stored in a param is NOT confused with uninitialized (BUG 2)', () => { + const { ctx, warns } = ctxWith(); + ctx.lexLine('#1 = SQRT[0-1]'); // NaN, but written + const a = ctx.lexLine('X#1'); + expect(a.kind === 'cmd' && a.params.x).toBeUndefined(); // non-finite → dropped + expect(warns).toContain(RS274_WARN.nonFiniteValue); + expect(warns).not.toContain(RS274_WARN.uninitializedParam); // it WAS written — no false uninit + }); + + it('a well-formed expression that evaluates to non-finite is dropped WITH a disclosure (BUG 3)', () => { + const { ctx, warns } = ctxWith(); + const a = ctx.lexLine('X[1/0] Y5'); + expect(a.kind === 'cmd' && a.params.x).toBeUndefined(); + expect(a.kind === 'cmd' && a.params.y).toBe(5); + expect(warns).toContain(RS274_WARN.nonFiniteValue); + }); + + it('a `;` inside a ( ) comment does not truncate the line (NIT 4)', () => { + const { ctx } = ctxWith(); + ctx.lexLine('#1 = 3'); + ctx.lexLine('#2 = 7'); + const a = ctx.lexLine('X[#1] (a;b) Y[#2]'); // the `;` is inside the comment + expect(a.kind === 'cmd' && a.params.x).toBe(3); + expect(a.kind === 'cmd' && a.params.y).toBe(7); + }); + + it('EXISTS of a non-named argument returns 0 without an uninitialized read (NIT 5)', () => { + const { ctx, warns } = ctxWith(); + const a = ctx.lexLine('X[EXISTS[#100]]'); // numbered, not named + expect(a.kind === 'cmd' && a.params.x).toBe(0); + expect(warns).not.toContain(RS274_WARN.uninitializedParam); + }); + + it('a leading N line-number before an assignment is honored (NIT 6)', () => { + const { ctx } = ctxWith(); + expect(ctx.lexLine('N10 #1 = 5').kind).toBe('assign'); + const a = ctx.lexLine('X#1'); + expect(a.kind === 'cmd' && a.params.x).toBe(5); + }); +}); + +/** Evaluate an expression via a shared context (for stateful EXISTS/system-param tests). */ +function evalXctx(ctx: Rs274Context, expr: string): number | undefined { + const r = ctx.lexLine(`X${expr}`); + return r.kind === 'cmd' ? r.params.x : undefined; +} + +describe('rs274 integration — parseGcodeToIR', () => { + it('resolves a parametric program to real geometry; capability known', () => { + const gcode = [ + 'G21', + 'G90', + '# = 10', + 'G1 X0 Y0', + 'G1 X# Y0', // → X10 + 'G1 X# Y[#/2]', // → X10 Y5 + 'G1 X[#*2] Y#' // → X20 Y10 + ].join('\n'); + const { ir } = parseGcodeToIR(gcode); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + // Travel-inclusive bounds cover the resolved extremes (X 0..20, Y 0..10) — these G1s carry no E. + expect(ir.boundsWithTravel.max.x).toBeCloseTo(20); + expect(ir.boundsWithTravel.max.y).toBeCloseTo(10); + expect(ir.segments.count).toBeGreaterThan(0); + }); + + it('a bolt-hole angle computed with degree trig lands where expected', () => { + // One point on a radius-10 circle at 30° → (cos30*10, sin30*10) ≈ (8.66, 5). + const gcode = ['G90', 'G1 X0 Y0', 'G1 X[10*COS[30]] Y[10*SIN[30]]'].join('\n'); + const { ir } = parseGcodeToIR(gcode); + const n = ir.segments.count; + const o = ir.header.originOffset; + expect(o.x + ir.segments.x1[n - 1]).toBeCloseTo(8.6602, 3); + expect(o.y + ir.segments.y1[n - 1]).toBeCloseTo(5, 3); + }); + + it('a non-parametric FDM line still reports parametricProgram unavailable', () => { + const { ir } = parseGcodeToIR(['G1 X10 Y10 E1', 'G1 X20 Y10 E2'].join('\n')); + expect(ir.header.capabilities.parametricProgram).toBe('unavailable'); + }); + + it('resolves a realistic LinuxCNC/CAM-idiom parametric drill pattern (params + trig + canned cycle)', () => { + // A bolt-circle drilling pattern the way a CAM post emits it (Phase-1 constructs only — no O-word + // loop yet; each hole written out). center (50,40), radius 20, holes at 0° and 90°. + const gcode = [ + 'G21 G90 G17', + '# = 50 (pattern center X)', + '# = 40 (pattern center Y)', + '# = 20 (bolt-circle radius)', + '# = 5', + '# = -3', + 'G0 Z#', + 'G0 X[# + # * COS[0]] Y[# + # * SIN[0]]', // hole 1 → (70, 40) + 'G81 X[# + # * COS[0]] Y[# + # * SIN[0]] Z# R#', + 'G0 X[# + # * COS[90]] Y[# + # * SIN[90]]', // hole 2 → (50, 60) + 'G81 X[# + # * COS[90]] Y[# + # * SIN[90]] Z# R#' + ].join('\n'); + const { ir } = parseGcodeToIR(gcode); + expect(ir.header.capabilities.parametricProgram).toBe('known'); + // The computed hole positions land at their trig-derived extremes (hole 1 X=70, hole 2 Y=60), and + // the canned cycle drills to the parametric depth Z=-3. (min X/Y are the origin start at 0,0.) + expect(ir.boundsWithTravel.max.x).toBeCloseTo(70, 3); + expect(ir.boundsWithTravel.max.y).toBeCloseTo(60, 3); + expect(ir.boundsWithTravel.min.z).toBeCloseTo(-3, 3); + // The canned cycles expanded to real geometry (DD-012) using the resolved coordinates. + expect(ir.header.capabilities.cannedCycles).toBe('known'); + }); +}); diff --git a/packages/gcode-parser/src/parse.ts b/packages/gcode-parser/src/parse.ts index 461db052..e5acf433 100644 --- a/packages/gcode-parser/src/parse.ts +++ b/packages/gcode-parser/src/parse.ts @@ -42,6 +42,8 @@ import { type Warning } from '@chestnutlabs/toolpath-core'; import { BudgetExceededError, SegmentWriter } from './growable.js'; +import { Rs274Context, lineUsesParametric } from './rs274.js'; +import { RS274_FLOW_WARN, interpretOWordProgram, programUsesOWords, type FlowLine } from './rs274-flow.js'; export interface ParseLimits { maxInputBytes: number; @@ -49,6 +51,19 @@ export interface ParseLimits { maxBufferBytes: number; maxLineLength: number; maxWarnings: number; + /** + * Total loop work across an RS274NGC O-word program (DD-017 §4.5, Phase 2). Charged per loop pass and + * per statement executed inside a loop body; exceeding it stops the program with a partial IR and a + * `rs274-iteration-limit` disclosure, so a hostile `o while [1]` wastes only bounded CPU. Irrelevant to + * FDM / non-parametric input, which never enters the interpreter. + */ + maxProgramIterations: number; + /** + * Subroutine call-recursion depth for an RS274NGC O-word program (DD-017 §4.5, Phase 3). A `call` that + * would exceed this depth is disclosed (`rs274-call-depth`) and skipped, so unbounded recursion cannot + * exhaust the stack. Irrelevant to FDM / non-parametric input. + */ + maxCallDepth: number; } export const DEFAULT_LIMITS: ParseLimits = { @@ -56,7 +71,9 @@ export const DEFAULT_LIMITS: ParseLimits = { maxSegments: 20_000_000, maxBufferBytes: 1536 * 1024 * 1024, maxLineLength: 64 * 1024, - maxWarnings: 10_000 + maxWarnings: 10_000, + maxProgramIterations: 1_000_000, + maxCallDepth: 50 }; /** @@ -254,6 +271,18 @@ export interface Engine { processLine(rawLine: string, offset: number): void; stopped(): boolean; markInputTooBig(): void; + /** + * Interpret the buffered `text` as an RS274NGC O-word program (DD-017 Phase 2). Called by the + * in-memory drivers INSTEAD of the forward-only line loop when `programUsesOWords(text)` is true; + * the interpreter re-feeds every plain line through `processLine`, so geometry stays on one path. + */ + runOWordFlow(): void; + /** + * Streaming only: record that an O-word control line was seen but NOT interpreted (control flow needs a + * fully-buffered program — DD-017 §4.1). Discloses once and marks the parametric capability degraded; + * the line still runs linearly (the honest pre-Phase-2 behaviour), never silently as if flow had run. + */ + noteUninterpretedOWord(srcByte: number): void; /** Streaming driver: record the true source byte count as it becomes known. */ setSourceBytes(bytes: number): void; /** @@ -363,6 +392,10 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En 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' + // RS274NGC parametric programs (DD-017 Phase 1, #189 phase 7). The store + expression evaluator resolve + // `#params`/`[expr]` on any line that uses them; non-parametric lines never touch it (byte-identical). + // The system-parameter allow-list (#5420–#5422 = current position) reads live engine position. + const rs274 = new Rs274Context(warn, () => ({ x: sx, y: sy, z: sz })); // 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 @@ -761,7 +794,20 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En const ci = rawLine.indexOf(';'); if (ci !== -1) onComment(rawLine.slice(ci + 1), offset); } - const cmd = lexLine(rawLine); + // RS274NGC parametric programs (DD-017 Phase 1): a line using `#`/`[` is resolved by the parameter + + // expression interpreter; a `#n = ` assignment executes and the line ends there. Every other + // line (all FDM, all simple CNC) takes the untouched lexer below — byte-identical. + let cmd: Cmd; + // Hot-path gate: a cheap `#`/`[` scan of the RAW line — no allocation. Every FDM line fails this and + // takes the untouched lexer (byte-identical). A `#`/`[` that turns out to be only inside a comment + // routes through the resolver, which strips comments and returns 'plain' → the normal lexer. + if (lineUsesParametric(rawLine)) { + const r = rs274.lexLine(rawLine, offset); + if (r.kind === 'assign' || r.kind === 'empty') return; // assignment applied / nothing to do + cmd = r.kind === 'cmd' ? { codes: r.codes, params: r.params } : lexLine(rawLine); // 'plain' → normal + } else { + cmd = lexLine(rawLine); + } // 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. @@ -1015,6 +1061,49 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En } }; + // RS274NGC O-word control flow (DD-017 Phase 2). Engaged only when the buffered program actually + // contains a control line; it buffers the lines with their byte offsets and hands them to the tree + // interpreter, which re-feeds each plain line through `processLine` (one geometry path) and evaluates + // conditions/counts against the SAME parameter store. `used`/`degraded` tier the capability. + const runOWordFlow = (): void => { + rs274.used = true; + const flowLines: FlowLine[] = []; + const len = text.length; + let offset = 0; + while (offset < len) { + let nl = text.indexOf('\n', offset); + if (nl === -1) nl = len; + flowLines.push({ text: text.slice(offset, nl), offset }); + offset = nl + 1; + } + const { degraded } = interpretOWordProgram(flowLines, { + processLine, + stopped: () => stopReason !== undefined, + evalExpression: (t, b) => rs274.evalExpression(t, b), + pushScope: (args) => rs274.pushScope(args), + popScope: () => rs274.popScope(), + warn, + maxProgramIterations: limits.maxProgramIterations, + maxCallDepth: limits.maxCallDepth + }); + if (degraded) rs274.degraded = true; + }; + + // Streaming can't interpret control flow from a partial stream (a loop needs its whole body — DD-017 + // §4.1); disclose once and let the program run linearly (control lines are inert). Honest, bounded. + let streamingOWordWarned = false; + const noteUninterpretedOWord = (srcByte: number): void => { + if (streamingOWordWarned) return; + streamingOWordWarned = true; + rs274.used = true; + rs274.degraded = true; + warn( + RS274_FLOW_WARN.streamingUnsupported, + 'O-word control flow is only interpreted for fully-buffered input; this streamed program ran linearly (control flow not executed).', + srcByte + ); + }; + const finish = (cancelledAtByte?: number): ParseResult => { if (stopReason !== undefined && truncatedAtByte === undefined) { truncatedAtByte = currentSrcByte; @@ -1076,6 +1165,11 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En // 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', + // RS274NGC parametric programs (DD-017, #189 phase 7). 'known' once a `#param`/`[expr]`/O-word was + // executed deterministically within limits (the interpreter computes geometry — it does not infer); + // 'approximated' when Phase 2 flow was degraded (a hit iteration cap, an unbalanced/unsupported + // O-word, or a malformed condition — always disclosed); 'unavailable' for non-parametric input. + parametricProgram: rs274.parametricConfidence(), // 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', @@ -1198,6 +1292,7 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En 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) + parametricProgram: 'unavailable', // parametric resolution resolves on the final IR (DD-017) extrusionMode: 'inferred', // motion modes resolve fully on the final IR (E10) positioningMode: 'inferred', arcPlanes: 'inferred', @@ -1224,6 +1319,8 @@ export function createEngine(input: string | Uint8Array, opts: ParseOptions): En limits, processLine, stopped: () => stopReason !== undefined, + runOWordFlow, + noteUninterpretedOWord, snapshot, markInputTooBig: () => { stopReason = { @@ -1250,6 +1347,13 @@ export function parseGcodeToIR(input: string | Uint8Array, opts: ParseOptions = return engine.finish(); } const text = engine.text; + // RS274NGC O-word program (DD-017 Phase 2): interpret with control flow instead of the forward-only + // loop. The cheap detection scan fails for every FDM / non-parametric file, which takes the untouched + // loop below (byte-identical). + if (programUsesOWords(text)) { + engine.runOWordFlow(); + return engine.finish(); + } const len = text.length; let offset = 0; while (offset < len && !engine.stopped()) { @@ -1294,6 +1398,14 @@ export async function parseGcodeToIRAsync( const yieldEvery = hooks.yieldIntervalMs ?? 50; const partial = makePartialEmitter(engine, hooks); const text = engine.text; + // RS274NGC O-word program (DD-017 Phase 2): a bounded synchronous interpretation section (loops are + // capped by `maxProgramIterations`, and these programs are small — hand/CAM-written). Non-parametric + // input takes the cooperative line loop below unchanged. + if (programUsesOWords(text)) { + engine.runOWordFlow(); + hooks.onProgress?.(text.length, byteLength, 0); + return { ...engine.finish(), cancelled: false }; + } const len = text.length; let offset = 0; let sliceStart = Date.now(); diff --git a/packages/gcode-parser/src/rs274-flow.ts b/packages/gcode-parser/src/rs274-flow.ts new file mode 100644 index 00000000..0688adab --- /dev/null +++ b/packages/gcode-parser/src/rs274-flow.ts @@ -0,0 +1,764 @@ +/* + * RS274NGC parametric programs — Phase 2: O-word control flow (DD-017 §4.4/§4.5, phase 7 of #189). + * + * Phase 1 (`rs274.ts`) added the parameter store + expression evaluator on a forward-only line model. + * Control flow breaks that model: `while`/`repeat`/`do` RE-RUN a line range and `if`/`elseif`/`else` + * SKIP one, which a once-per-line driver cannot express (DD-017 §1). This module is the program-buffered + * interpreter of DD-017 D1 Option A: engaged only when a program actually contains an O-word control line + * (`programUsesOWords`), it buffers the whole program (small — hand/CAM-written, not million-line FDM), + * builds a block tree, and executes it, feeding every non-control line back through the SAME engine + * `processLine` (so geometry/params/expressions stay on one code path). Non-O-word input never reaches + * here and stays byte-identical. + * + * SUPPORTED: `if`/`elseif`/`else`/`endif`, `while`/`endwhile`, `do`/`while`, `repeat`/`endrepeat`, + * `break`/`continue` (Phase 2), and in-file subroutines `sub`/`endsub`/`call`/`return` (Phase 3 — a + * `call` binds args to `#1`..`#30` in a fresh scope, runs the named sub's body, and returns; subs may + * recurse and be forward-referenced). External subroutine files (`M98`) stay a non-goal (no I/O). + * + * BOUNDED EXECUTION (DD-017 §4.5 — this is a small interpreter, so a hostile `o while [1]` or an + * exponential recursive fan-out must not hang/OOM the worker): one shared counter is charged per loop + * pass AND per statement executed inside any loop OR subroutine, stopping the program at + * `maxProgramIterations` (disclosed) — this bounds both a large loop body and the total call-tree size. + * Subroutine recursion depth is separately bounded by `maxCallDepth`, and combined nesting + call-depth + * JS recursion by `MAX_EXEC_DEPTH`; the engine's geometry limits still halt emission. There is no `eval` + * and no I/O, so an adversarial program can waste only bounded CPU. + * + * We interpret a BLOCK TREE rather than a raw program-counter + jump table (both satisfy DD-017 D4's + * "match each opener to its closer"): the tree makes `if`/`elseif`/`else` and nested loops correct by + * construction, makes `break`/`continue` a local signal, and — critically — makes the bounded-execution + * proof a single charge point. Semantics follow RS274NGC / LinuxCNC behaviour (no spec text copied). + */ +import { stripParenComments } from './rs274.js'; + +/** Warning codes surfaced to the IR by the flow interpreter (DD-017 §4.6). */ +export const RS274_FLOW_WARN = { + iterationLimit: 'rs274-iteration-limit', + unbalancedOword: 'rs274-unbalanced-oword', + unsupportedOword: 'rs274-unsupported-oword', + controlNesting: 'rs274-control-nesting', + misplacedControl: 'rs274-misplaced-control', + streamingUnsupported: 'rs274-streaming-flow-unsupported', + callDepth: 'rs274-call-depth', // subroutine recursion exceeded maxCallDepth (Phase 3) + unknownSub: 'rs274-unknown-sub', // `call` of a subroutine that was never defined (Phase 3) + duplicateSub: 'rs274-duplicate-sub' // a subroutine id defined more than once (first wins, Phase 3) +} as const; + +/** Structural nesting cap (adversarial `o if`/`o if`/… stacks). Real programs nest < 10; 256 is + * unambiguously hostile and bounds the block BUILD's structural depth (per opener frame). */ +const MAX_CONTROL_NESTING = 256; + +/** + * Runtime execution-recursion backstop (DD-017 Phase 3). With subroutines, JS `execBlock` recursion + * grows with BOTH structural nesting AND `call` depth combined, so neither `MAX_CONTROL_NESTING` nor + * `maxCallDepth` alone bounds the JS stack. This directly caps live `execBlock` nesting — real programs + * stay well under 50; 512 is an unambiguously adversarial combination that halts (disclosed) rather than + * risking a stack overflow escaping the parse. + */ +const MAX_EXEC_DEPTH = 512; + +/** The recognized O-word keywords. An `o` line with any OTHER trailing word is NOT a control line + * (e.g. a stray `o5 g1`) — it falls through to the normal lexer, so detection never hijacks plain code. */ +export const KNOWN_OWORD_KEYWORDS: ReadonlySet = new Set([ + 'sub', + 'endsub', + 'call', + 'return', + 'if', + 'elseif', + 'else', + 'endif', + 'while', + 'endwhile', + 'do', + 'repeat', + 'endrepeat', + 'break', + 'continue' +]); + +export type WarnFn = (code: string, message: string, srcByte?: number) => void; + +/** A classified O-word control line: its keyword, its (opaque, normalized) id token, and any trailing + * expression (an `if`/`while` condition or a `repeat` count). */ +export interface OwInfo { + keyword: string; + /** The id token verbatim-normalized: `100`, ``, or a literal `[expr]` — matched, never evaluated. */ + id: string; + expr: string; +} + +/** + * Classify one raw line as an O-word control line, or `null` when it is not one. `null` covers every + * plain line (all FDM, all geometry/param lines) AND an `o` whose trailing word is not a recognized + * keyword (e.g. `o5 g1 x10`) — those fall through to the normal lexer, so classification never hijacks + * ordinary code. The id is read as an opaque token (numeric / `` / balanced `[expr]`) and is used + * only to pair an opener with its closer — it is deliberately NOT evaluated here. + */ +export function classifyOWord(rawLine: string): OwInfo | null { + let body = stripParenComments(rawLine); + const semi = body.indexOf(';'); + if (semi !== -1) body = body.slice(0, semi); + body = body.trim(); + if (body === '') return null; + body = body.replace(/^n\d+\s*/i, ''); // an optional leading N line-number (LinuxCNC allows it) + if (body[0] !== 'o' && body[0] !== 'O') return null; + + let i = 1; + while (i < body.length && /\s/.test(body[i])) i++; + let id: string; + const c = body[i]; + if (c === '<') { + const e = body.indexOf('>', i); + if (e === -1) return null; + id = body.slice(i, e + 1); + i = e + 1; + } else if (c === '[') { + let depth = 0; + let j = i; + for (; j < body.length; j++) { + if (body[j] === '[') depth++; + else if (body[j] === ']') { + depth--; + if (depth === 0) { + j++; + break; + } + } + } + if (depth !== 0) return null; + id = body.slice(i, j); + i = j; + } else if (c >= '0' && c <= '9') { + let j = i; + while (j < body.length && /[0-9.]/.test(body[j])) j++; + id = body.slice(i, j); + i = j; + } else { + return null; + } + + while (i < body.length && /\s/.test(body[i])) i++; + let kw = ''; + while (i < body.length && /[a-zA-Z]/.test(body[i])) kw += body[i++]; + kw = kw.toLowerCase(); + if (!KNOWN_OWORD_KEYWORDS.has(kw)) return null; + + return { keyword: kw, id: id.trim().toLowerCase(), expr: body.slice(i).trim() }; +} + +/** One buffered source line with its byte offset (for `srcByte` on any geometry it emits). */ +export interface FlowLine { + text: string; + offset: number; +} + +/** The engine surface the interpreter drives — supplied by `createEngine` with its closures. */ +export interface FlowHost { + /** Execute one plain (non-control) source line through the normal engine path. */ + processLine(text: string, offset: number): void; + /** True once a geometry limit (segments/buffer) has stopped emission — the interpreter then halts. */ + stopped(): boolean; + /** Evaluate a control expression against the shared parameter store; `null` = malformed/non-finite. */ + evalExpression(text: string, srcByte: number): number | null; + /** Push a subroutine call-frame scope with `args` bound to `#1`..`#30` (Phase 3). */ + pushScope(args: readonly number[]): void; + /** Pop the current call-frame scope (Phase 3). */ + popScope(): void; + warn: WarnFn; + maxProgramIterations: number; + /** Subroutine recursion depth cap (Phase 3); exceeding it discloses and skips the call. */ + maxCallDepth: number; +} + +export interface FlowOutcome { + /** True when execution was degraded (limit hit, unbalanced/unsupported O-word, malformed condition). */ + degraded: boolean; +} + +// --- block tree --------------------------------------------------------------------------------- + +type IfBranch = { expr: string | null; offset: number; body: Node[] }; + +type Node = + | { t: 'line'; text: string; offset: number } + | { t: 'if'; branches: IfBranch[] } + | { t: 'loop'; kind: 'while' | 'do' | 'repeat'; expr: string; offset: number; body: Node[] } + | { t: 'call'; id: string; args: string[]; offset: number } // Phase 3: invoke a subroutine by id + | { t: 'return'; offset: number } // Phase 3: early-exit the current subroutine + | { t: 'break'; offset: number } + | { t: 'continue'; offset: number } + | { t: 'noop' }; // an unsupported construct, already disclosed at build — a no-op at execution + +/** A collected subroutine definition (Phase 3): its body runs only on `call`, never inline. */ +interface SubDef { + body: Node[]; + offset: number; +} + +// --- build-time frames -------------------------------------------------------------------------- + +interface Frame { + kind: 'root' | 'if' | 'while' | 'do' | 'repeat' | 'sub'; + id: string; + offset: number; + /** Body accumulator (for `if`, `branches` holds the bodies instead). */ + body: Node[]; + /** For `if`: the ordered branches; `body` points at the current branch's body. */ + branches?: IfBranch[]; + /** For a pre-tested `while` / `repeat`: the condition / count expression. */ + expr?: string; +} + +/** True once the built tree used any recognized control construct (drives the capability). */ +export function programUsesOWords(text: string): boolean { + const len = text.length; + let i = 0; + while (i < len) { + let nl = text.indexOf('\n', i); + if (nl === -1) nl = len; + if (lineStartsOWord(text, i, nl)) return true; + i = nl + 1; + } + return false; +} + +/** True when a single line is a recognized O-word control line (used by the streaming disclosure). */ +export function isOWordControlLine(line: string): boolean { + return lineStartsOWord(line, 0, line.length); +} + +/** + * Cheap gate: does `text[start,end)` begin (after leading `( )` comments, whitespace, and an optional + * `N` line-number) with an `o` whose trailing word is a recognized keyword? The peek costs a few + * char comparisons for a normal FDM line (first non-space is `G`/`M`/`;`/digit → immediate reject, no + * allocation); only a genuine `o…`-leading candidate is sliced and fully classified. + */ +function lineStartsOWord(s: string, start: number, end: number): boolean { + let i = start; + let sawN = false; + // Whitespace `classifyOWord` would strip (via .trim()/`\s`): ASCII plus the common non-breaking space. + const isWs = (c: string): boolean => + c === ' ' || c === '\t' || c === '\r' || c === '\n' || c === '\f' || c === '\v' || c === ' '; + // Skip anything `classifyOWord` removes before the O-word — whitespace, `( )` comment groups, and one + // optional `N` line-number — in ANY order, so the pre-filter is a strict SUPERSET of the classifier's + // leading region (never a false negative: a missed O-word would be silent wrong geometry). A false + // positive is harmless — `classifyOWord` below is the authority and rejects it. + for (;;) { + while (i < end && isWs(s[i])) i++; + if (i < end && s[i] === '(') { + let depth = 1; + i++; + while (i < end && depth > 0) { + if (s[i] === '(') depth++; + else if (s[i] === ')') depth--; + i++; + } + continue; + } + if (!sawN && i + 1 < end && (s[i] === 'n' || s[i] === 'N') && s[i + 1] >= '0' && s[i + 1] <= '9') { + i += 2; + while (i < end && s[i] >= '0' && s[i] <= '9') i++; + sawN = true; + continue; + } + break; + } + if (i >= end || (s[i] !== 'o' && s[i] !== 'O')) return false; + // A genuine `o…`-leading candidate — defer entirely to the authoritative classifier (which strips + // comments globally and confirms a recognized keyword), so the two never diverge. + return classifyOWord(s.slice(start, end)) !== null; +} + +// --- interpreter -------------------------------------------------------------------------------- + +/** Internal control signals for `break` / `continue` — caught by the nearest enclosing loop. Carry the + * source offset so a misplaced (loop-less) `break`/`continue` can be disclosed with a location. */ +class BreakSignal { + constructor(readonly offset?: number) {} +} +class ContinueSignal { + constructor(readonly offset?: number) {} +} +/** `return` control signal (Phase 3) — caught by the nearest enclosing subroutine `call`. */ +class ReturnSignal { + constructor(readonly offset?: number) {} +} + +/** Split a `call`'s argument text into its top-level bracket expressions, e.g. `[1] [2+3] [#5]`. + * `malformed` is true when an unbalanced trailing `[` was found (and dropped) — the caller discloses it. */ +function splitBracketArgs(s: string): { args: string[]; malformed: boolean } { + const args: string[] = []; + let i = 0; + let malformed = false; + while (i < s.length) { + if (s[i] === '[') { + let depth = 0; + const start = i; + for (; i < s.length; i++) { + if (s[i] === '[') depth++; + else if (s[i] === ']') { + depth--; + if (depth === 0) { + i++; + break; + } + } + } + if (depth !== 0) { + malformed = true; // unbalanced trailing `[` — drop it, disclosed by the caller + break; + } + args.push(s.slice(start, i)); + } else { + i++; + } + } + return { args, malformed }; +} + +/** + * Interpret a fully-buffered O-word program. Builds the block tree (tolerant of unbalanced O-words — + * disclosed, never thrown out), then executes it with bounded iteration. Returns whether execution was + * degraded so the caller can tier the `parametricProgram` capability. + */ +export function interpretOWordProgram(lines: readonly FlowLine[], host: FlowHost): FlowOutcome { + let degraded = false; + const disclose = (code: string, message: string, offset?: number): void => { + degraded = true; + host.warn(code, message, offset); + }; + + // --- build the block tree ------------------------------------------------------------------- + const root: Frame = { kind: 'root', id: '', offset: 0, body: [] }; + // Subroutine definitions (Phase 3), collected during the build so a `call` can forward-reference a + // `sub` defined later in the file. A sub body is captured here and executed only on `call`, never inline. + const subs = new Map(); + const stack: Frame[] = [root]; + const top = (): Frame => stack[stack.length - 1]; + const pushChild = (n: Node): void => { + top().body.push(n); + }; + const controlDepth = (): number => stack.length - 1; // frames beyond root + + let buildOk = true; + + for (const line of lines) { + if (!buildOk) break; + const info = classifyOWord(line.text); + if (info === null) { + pushChild({ t: 'line', text: line.text, offset: line.offset }); + continue; + } + const { keyword, id, expr } = info; + switch (keyword) { + case 'if': { + if (!openFrame({ kind: 'if', id, offset: line.offset, body: [], branches: [] })) break; + const f = top(); + const branch: IfBranch = { expr, offset: line.offset, body: [] }; + f.branches!.push(branch); + f.body = branch.body; + break; + } + case 'elseif': + case 'else': { + const f = top(); + if (f.kind !== 'if') { + disclose(RS274_FLOW_WARN.misplacedControl, `'o${id} ${keyword}' without an enclosing if`, line.offset); + break; + } + const branches = f.branches!; + if (branches.length > 0 && branches[branches.length - 1].expr === null) { + // A branch already followed `else` — it can never run. Disclose and route its body to a + // DETACHED sink (not one of `branches`) so the dead lines are dropped, not leaked into `else`. + disclose(RS274_FLOW_WARN.misplacedControl, `'o${id} ${keyword}' after else is unreachable`, line.offset); + f.body = []; + break; + } + const branch: IfBranch = { expr: keyword === 'else' ? null : expr, offset: line.offset, body: [] }; + branches.push(branch); + f.body = branch.body; + break; + } + case 'endif': { + closeFrame('if', 'endif', id, line.offset, (f) => ({ t: 'if', branches: f.branches! })); + break; + } + case 'while': { + // `while` closes a matching `do` (post-tested loop); otherwise it opens a pre-tested loop. + if (top().kind === 'do' && idMatches(top().id, id)) { + closeFrame('do', 'while', id, line.offset, (f) => ({ + t: 'loop', + kind: 'do', + expr, + offset: f.offset, + body: f.body + })); + } else { + openFrame({ kind: 'while', id, offset: line.offset, body: [], expr }); + } + break; + } + case 'endwhile': { + closeFrame('while', 'endwhile', id, line.offset, (f) => ({ + t: 'loop', + kind: 'while', + expr: f.expr ?? '', + offset: f.offset, + body: f.body + })); + break; + } + case 'do': { + openFrame({ kind: 'do', id, offset: line.offset, body: [] }); + break; + } + case 'repeat': { + openFrame({ kind: 'repeat', id, offset: line.offset, body: [], expr }); + break; + } + case 'endrepeat': { + closeFrame('repeat', 'endrepeat', id, line.offset, (f) => ({ + t: 'loop', + kind: 'repeat', + expr: f.expr ?? '', + offset: f.offset, + body: f.body + })); + break; + } + case 'sub': { + // Phase 3: open a frame so the body is captured; it is stored (not inlined) at the matching + // `endsub` and runs only on `call`. A sub definition must never execute where it is written. + openFrame({ kind: 'sub', id, offset: line.offset, body: [] }); + break; + } + case 'endsub': { + const f = top(); + if (f.kind !== 'sub') { + disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} endsub' without a matching sub`, line.offset); + break; + } + if (!idMatches(f.id, id)) { + disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} endsub' does not match 'o${f.id} sub'`, line.offset); + } + stack.pop(); + if (subs.has(f.id)) { + disclose(RS274_FLOW_WARN.duplicateSub, `subroutine 'o${f.id}' redefined; keeping the first`, f.offset); + } else { + subs.set(f.id, { body: f.body, offset: f.offset }); + } + break; + } + case 'call': { + const { args, malformed } = splitBracketArgs(expr); + if (malformed) { + disclose( + RS274_FLOW_WARN.unsupportedOword, + `'o${id} call' has a malformed argument; it was dropped`, + line.offset + ); + } + pushChild({ t: 'call', id, args, offset: line.offset }); + break; + } + case 'return': { + pushChild({ t: 'return', offset: line.offset }); + break; + } + case 'break': { + pushChild({ t: 'break', offset: line.offset }); + break; + } + case 'continue': { + pushChild({ t: 'continue', offset: line.offset }); + break; + } + default: { + disclose(RS274_FLOW_WARN.unsupportedOword, `unsupported O-word '${keyword}'; ignored`, line.offset); + pushChild({ t: 'noop' }); + } + } + } + + // Any frames still open at EOF are unterminated (missing `endif`/`endwhile`/…). Disclose each and drop + // it (its captured body is undefined-in-extent, so executing it would be a guess) — root siblings that + // closed cleanly still run. + while (stack.length > 1) { + const f = stack.pop()!; + disclose(RS274_FLOW_WARN.unbalancedOword, `unterminated 'o${f.id} ${f.kind}' (missing its end)`, f.offset); + } + + /** Push a new control frame, enforcing the nesting cap. Returns false if the cap aborted the build. */ + function openFrame(f: Frame): boolean { + if (controlDepth() + 1 > MAX_CONTROL_NESTING) { + disclose( + RS274_FLOW_WARN.controlNesting, + `control nesting exceeds ${MAX_CONTROL_NESTING}; program not executed`, + f.offset + ); + buildOk = false; + return false; + } + stack.push(f); + return true; + } + + /** Close the top frame, verifying it matches the expected opener kind + id, and append its node. */ + function closeFrame( + expectKind: Frame['kind'], + closer: string, + id: string, + offset: number, + make: (f: Frame) => Node + ): void { + const f = top(); + if (f.kind !== expectKind) { + disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} ${closer}' without a matching opener`, offset); + return; + } + if (!idMatches(f.id, id)) { + // Structurally it closes the top frame; the id label just disagrees — close it but disclose. + disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} ${closer}' does not match 'o${f.id}'`, offset); + } + stack.pop(); + top().body.push(make(f)); + } + + if (!buildOk) return { degraded: true }; // nesting cap tripped — nothing executed (adversarial input) + + // --- execute the tree ----------------------------------------------------------------------- + let iterations = 0; + let halted = false; // set by the iteration cap or a geometry stop; unwinds cleanly + let iterationWarned = false; + let loopDepth = 0; // > 0 anywhere inside a loop (stays elevated across calls) — gates the charge + let frameLoopDepth = 0; // loops in the CURRENT call frame (reset per call) — gates break/continue locality + let callDepth = 0; // active subroutine call frames (Phase 3) — bounded by host.maxCallDepth + let execDepth = 0; // live execBlock recursion — bounded by MAX_EXEC_DEPTH (JS-stack backstop) + let execDepthWarned = false; + + /** + * Charge ONE unit of loop work against the shared budget; returns false (and halts) when exhausted. + * Charged both per loop-body *pass* (so an empty-bodied `o while [1]` is bounded) AND per statement + * *inside* a loop body (so a loop with a huge body cannot multiply the real work past the limit — + * the bound is total loop work, `maxProgramIterations`, independent of body size). Root-level (non-loop) + * statements are bounded by input size and are not charged, so a large straight-line program is not + * truncated. + */ + const charge = (offset?: number): boolean => { + if (++iterations > host.maxProgramIterations) { + if (!iterationWarned) { + iterationWarned = true; + disclose( + RS274_FLOW_WARN.iterationLimit, + `program iteration limit ${host.maxProgramIterations} reached; execution stopped`, + offset + ); + } + halted = true; + return false; + } + return true; + }; + + const truthy = (expr: string, offset: number): boolean => { + const v = host.evalExpression(expr, offset); + if (v === null) { + degraded = true; // malformed/non-finite condition — already disclosed by evalExpression + return false; + } + return v !== 0; + }; + + const execBlock = (nodes: readonly Node[]): void => { + // Runtime recursion backstop (Phase 3): structural nesting + call depth combine here, so cap live + // execBlock depth directly to keep the JS stack safe regardless of how they multiply. + if (execDepth >= MAX_EXEC_DEPTH) { + if (!execDepthWarned) { + execDepthWarned = true; + disclose(RS274_FLOW_WARN.controlNesting, `execution nesting exceeded ${MAX_EXEC_DEPTH}; program stopped`); + } + halted = true; + return; + } + execDepth++; + try { + for (const n of nodes) { + if (halted || host.stopped()) { + halted = true; + return; + } + // Charge every executed statement inside a loop OR a subroutine. Loops bound `bodySize × + // iterations`; subroutines bound the CALL-TREE SIZE — each `call` node is itself charged once + // callDepth > 0, so a loop-free fan-out (`o100 sub` calling `o100` twice) that maxCallDepth + // bounds only in DEPTH is bounded in total work here. Root-level statements (both depths 0) run + // once and stay bounded by input size, so a large straight-line program is not truncated. + if ((loopDepth > 0 || callDepth > 0) && n.t !== 'if' && n.t !== 'loop') { + if (!charge(n.t === 'line' || n.t === 'call' ? n.offset : undefined)) return; + } + switch (n.t) { + case 'line': + host.processLine(n.text, n.offset); + break; + case 'noop': + break; + case 'break': + case 'continue': + // Valid only inside a loop IN THE CURRENT frame. Outside one (top level, or a sub with no + // loop of its own) it is a disclosed no-op — never a cross-frame jump, never a program abort. + if (frameLoopDepth === 0) { + disclose(RS274_FLOW_WARN.misplacedControl, `'${n.t}' outside a loop; ignored`, n.offset); + } else if (n.t === 'break') { + throw new BreakSignal(n.offset); + } else { + throw new ContinueSignal(n.offset); + } + break; + case 'return': + // Valid only inside a subroutine. Outside one it is a disclosed no-op, not a program abort. + if (callDepth === 0) { + disclose(RS274_FLOW_WARN.misplacedControl, 'return outside a subroutine; ignored', n.offset); + } else { + throw new ReturnSignal(n.offset); + } + break; + case 'call': + execCall(n); + break; + case 'if': { + for (const br of n.branches) { + if (br.expr === null || truthy(br.expr, br.offset)) { + execBlock(br.body); + break; + } + } + break; + } + case 'loop': + execLoop(n); + break; + } + } + } finally { + execDepth--; + } + }; + + /** Invoke a subroutine (Phase 3): bind args → a fresh scope, run its body, honor `return`, bound depth. */ + const execCall = (n: { id: string; args: string[]; offset: number }): void => { + const sub = subs.get(n.id); + if (sub === undefined) { + disclose(RS274_FLOW_WARN.unknownSub, `call of undefined subroutine 'o${n.id}'; ignored`, n.offset); + return; + } + if (callDepth >= host.maxCallDepth) { + disclose(RS274_FLOW_WARN.callDepth, `subroutine recursion exceeded maxCallDepth ${host.maxCallDepth}`, n.offset); + return; + } + // Args evaluate in the CALLER's scope, then bind to `#1`..`#30` in the fresh callee scope. + const args = n.args.map((a) => { + const v = host.evalExpression(a, n.offset); + if (v === null) degraded = true; // malformed arg — disclosed by evalExpression; passes 0 + return v ?? 0; + }); + host.pushScope(args); + callDepth++; + // A subroutine's break/continue see only loops WITHIN the sub — reset frame-loop depth for the body + // (but NOT `loopDepth`, which stays elevated so a sub called inside a loop still charges its work). + const savedFrameLoopDepth = frameLoopDepth; + frameLoopDepth = 0; + try { + execBlock(sub.body); + } catch (e) { + if (!(e instanceof ReturnSignal)) throw e; // `return` = normal early exit; anything else propagates + } finally { + frameLoopDepth = savedFrameLoopDepth; + callDepth--; + host.popScope(); + } + }; + + const execLoop = (n: { kind: 'while' | 'do' | 'repeat'; expr: string; offset: number; body: Node[] }): void => { + loopDepth++; // gate the per-statement charge in execBlock for this loop's body + frameLoopDepth++; // this loop can catch a break/continue in the current call frame + try { + if (n.kind === 'repeat') { + const cv = host.evalExpression(n.expr, n.offset); + if (cv === null) { + degraded = true; + return; + } + const count = Math.floor(cv); + for (let k = 0; k < count; k++) { + if (halted || host.stopped()) { + halted = true; + return; + } + if (!charge(n.offset)) return; + try { + execBlock(n.body); + } catch (e) { + if (e instanceof BreakSignal) return; + if (e instanceof ContinueSignal) continue; + throw e; + } + } + return; + } + if (n.kind === 'while') { + while (!halted && !host.stopped() && truthy(n.expr, n.offset)) { + if (!charge(n.offset)) return; + try { + execBlock(n.body); + } catch (e) { + if (e instanceof BreakSignal) return; + if (e instanceof ContinueSignal) continue; + throw e; + } + } + return; + } + // post-tested `do … while [cond]` — the body runs at least once, then the condition is tested. + do { + if (halted || host.stopped()) { + halted = true; + return; + } + if (!charge(n.offset)) return; + try { + execBlock(n.body); + } catch (e) { + if (e instanceof BreakSignal) return; + if (e instanceof ContinueSignal) continue; // fall through to the condition test + throw e; + } + } while (!halted && !host.stopped() && truthy(n.expr, n.offset)); + } finally { + loopDepth--; + frameLoopDepth--; + } + }; + + try { + execBlock(root.body); + } catch (e) { + if (e instanceof BreakSignal || e instanceof ContinueSignal) { + disclose(RS274_FLOW_WARN.misplacedControl, 'break/continue outside a loop; ignored', e.offset); + } else if (e instanceof ReturnSignal) { + disclose(RS274_FLOW_WARN.misplacedControl, 'return outside a subroutine; ignored', e.offset); + } else { + throw e; + } + } + + // A geometry limit (maxSegments/maxBufferBytes) that halted interpretation mid-program is a degraded + // run — the parametric program did not fully execute (Finding 4a). Global `complete=false` also covers + // it, but the per-capability tier should not claim a clean `known`. + if (host.stopped()) degraded = true; + + return { degraded }; +} + +/** O-word ids match on their normalized token (numeric, ``, or a literal `[expr]` text). */ +function idMatches(a: string, b: string): boolean { + return a === b; +} diff --git a/packages/gcode-parser/src/rs274.ts b/packages/gcode-parser/src/rs274.ts new file mode 100644 index 00000000..6b40f67b --- /dev/null +++ b/packages/gcode-parser/src/rs274.ts @@ -0,0 +1,735 @@ +/* + * RS274NGC parametric programs — Phase 1: parameters + expressions (DD-017, phase 7 of #189). + * + * A pure, self-contained interpreter for the RS274NGC *programming* layer that FDM slicers never use: + * numbered/named parameters (`#100`, `#`, `#<_global>`), assignment (`#n = `), and bracket + * expressions (`[ ... ]`) usable wherever a numeric word value is expected (`X[#1+2]`, `F[#feed]`). + * + * SCOPE (Phase 1): parameters + expressions ONLY. O-word control flow (`if`/`while`/`sub`/`call`) is + * Phase 2 — this module executes straight-line, in source order. There is **no I/O and no `eval`**: a + * fixed-grammar recursive-descent evaluator over `f64`, with a depth guard, so a hostile program can + * waste only bounded CPU (DD-017 §4.5). Malformed input never throws out of the parse — the offending + * word is dropped with a disclosure and parsing continues (DD-017 §4.3/§4.6). + * + * Semantics follow RS274NGC / LinuxCNC *behaviour* (NIST IR 6556; behavioural parity, no spec text + * copied — RR-004 §6). Notably: trig is in **degrees**; `MOD` is floored (LinuxCNC, sign of divisor); + * `EQ`/`NE` compare within a small tolerance; `FIX` = floor, `FUP` = ceil; booleans are `1`/`0`. + * + * This module is imported only by the parser engine and depends on nothing outside the package. + */ + +/** Warning codes surfaced to the IR (DD-017 §4.6). */ +export const RS274_WARN = { + uninitializedParam: 'rs274-uninitialized-param', + badExpression: 'rs274-bad-expression', + nonFiniteValue: 'rs274-non-finite-value', + unsupportedSysParam: 'rs274-unsupported-sysparam', + paramLimit: 'rs274-param-limit' +} as const; + +const MAX_EXPR_DEPTH = 64; // recursive-descent nesting guard for adversarial `[[[[…]]]]` +const MAX_NAMED_PARAMS = 5000; // bound the named-parameter store +const NUMBERED_LEN = 5400; // `#1`..`#5399` (index 0 unused); `#5400`+ are system params +const EQ_TOLERANCE = 1e-4; // LinuxCNC compares EQ/NE within a small tolerance +const DEG = Math.PI / 180; + +/** A thrown control signal used internally to abort a malformed expression; never escapes the module. */ +class Rs274EvalError extends Error {} + +export type WarnFn = (code: string, message: string, srcByte?: number) => void; +export type PositionProvider = () => { x: number; y: number; z: number }; + +/** + * True when a line's code section (comment already stripped) uses a parametric construct. A cheap + * character scan — the parser runs this per line so a non-parametric line (every FDM line) takes the + * untouched fast path and stays byte-identical. + */ +export function lineUsesParametric(codeSection: string): boolean { + return codeSection.indexOf('#') !== -1 || codeSection.indexOf('[') !== -1; +} + +/** Strip RS274NGC `( … )` comments from a code section (they are comments, never expressions). */ +export function stripParenComments(s: string): string { + let out = ''; + let depth = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '(') depth++; + else if (c === ')') { + if (depth > 0) depth--; + } else if (depth === 0) out += c; + } + return out; +} + +/** The result of lexing one parametric line. */ +export type ParametricLine = + | { kind: 'empty' } + | { kind: 'plain' } // not actually parametric (e.g. `#` only in a comment) — use the normal lexer + | { kind: 'assign' } // already applied to the store + | { kind: 'cmd'; codes: string[]; params: Record }; + +/** + * One call-frame scope (DD-017 Phase 3). RS274NGC/LinuxCNC scoping: numbered parameters `#1`–`#30` and + * non-underscore named parameters (`#`) are LOCAL to the current subroutine invocation; `#31`+ + * and underscore-named (`#<_global>`) are GLOBAL. A fresh scope per `call` is what makes recursion + * correct (a callee's `#1` cannot clobber its caller's). The main program runs in the base scope. + */ +interface Rs274Scope { + localNumbered: Float64Array; // `#1`..`#30` (index 0 unused) + localWritten: Uint8Array; // 1 = assigned (so a computed NaN is distinguishable from never-written) + named: Map; // non-underscore named params local to this frame +} + +const LOCAL_NUMBERED_MAX = 30; // `#1`..`#30` are call-local (args); `#31`+ are global (LinuxCNC) + +/** + * The parameter store + expression evaluator for one program. A scope STACK (DD-017 Phase 3): the base + * scope is the main program; each subroutine `call` pushes a frame (`#1`–`#30` args + `#` names) + * and `return`/`endsub` pops it. `#31`+ numbered and `#<_global>` names are shared across frames. + * Numbered/named params default to `0` on read-before-write (with a one-time disclosure). A read-only + * allow-list resolves a few system parameters (current position) from live engine state; any other + * system parameter reads `0` and is disclosed. With no subroutines the base scope reduces to the single + * flat store of Phases 1–2 (byte-identical). + */ +export class Rs274Context { + // Global numbered store `#31`..`#5399` (indices ≤30 live per-scope; slots 1..30 here are unused). + private readonly numbered = new Float64Array(NUMBERED_LEN); + // 1 = the slot was assigned. Separate from the value so a legitimately computed NaN/Inf (e.g. + // `#31 = SQRT[-1]`) is distinguishable from "never written" (which reads 0 + discloses). + private readonly numberedWritten = new Uint8Array(NUMBERED_LEN); + private readonly globalNamed = new Map(); // underscore-named `#<_global>` + // The scope stack. The base scope (index 0) is the main program; `call` pushes, `return`/endsub pops. + private readonly scopes: Rs274Scope[] = [ + { + localNumbered: new Float64Array(LOCAL_NUMBERED_MAX + 1), + localWritten: new Uint8Array(LOCAL_NUMBERED_MAX + 1), + named: new Map() + } + ]; + private uninitWarned = false; + private paramLimitWarned = false; + /** True once any parametric construct was executed — drives the `parametricProgram` capability. */ + used = false; + /** + * True once execution was degraded relative to a clean run: a hit resource limit (iteration cap), + * an unbalanced/unsupported O-word, or a malformed control expression (DD-017 §4.6). The + * O-word flow interpreter (Phase 2) sets this; it downgrades `parametricProgram` from `known` to + * `approximated` (still disclosed via a specific warning — never silently wrong). + */ + degraded = false; + + constructor( + private readonly warn: WarnFn, + private readonly position: PositionProvider + ) {} + + private warnUninit(srcByte?: number): void { + if (this.uninitWarned) return; + this.uninitWarned = true; + this.warn(RS274_WARN.uninitializedParam, 'read of an uninitialized parameter (RS274NGC default 0)', srcByte); + } + + private get scope(): Rs274Scope { + return this.scopes[this.scopes.length - 1]; + } + + /** + * Push a fresh call-frame scope with `args` bound to `#1`..`#30` (DD-017 Phase 3 `call`). Args beyond + * 30 are ignored (RS274NGC passes at most 30). Balanced by `popScope` on `return`/`endsub`. + */ + pushScope(args: readonly number[]): void { + const localNumbered = new Float64Array(LOCAL_NUMBERED_MAX + 1); + const localWritten = new Uint8Array(LOCAL_NUMBERED_MAX + 1); + for (let i = 0; i < args.length && i < LOCAL_NUMBERED_MAX; i++) { + localNumbered[i + 1] = args[i]; + localWritten[i + 1] = 1; + } + this.scopes.push({ localNumbered, localWritten, named: new Map() }); + } + + /** Pop the current call-frame scope (never the base/main scope). */ + popScope(): void { + if (this.scopes.length > 1) this.scopes.pop(); + } + + private readNumbered(n: number, srcByte?: number): number { + const idx = Math.round(n); + if (idx >= NUMBERED_LEN) return this.readSystem(idx, srcByte); + if (idx < 1) { + this.warnUninit(srcByte); + return 0; + } + if (idx <= LOCAL_NUMBERED_MAX) { + const s = this.scope; + if (s.localWritten[idx] === 0) { + this.warnUninit(srcByte); + return 0; + } + return s.localNumbered[idx]; + } + if (this.numberedWritten[idx] === 0) { + this.warnUninit(srcByte); + return 0; + } + return this.numbered[idx]; + } + + private readSystem(n: number, srcByte?: number): number { + // Read-only allow-list (DD-017 §4.2): current commanded position, origin-relative to match how the + // engine tracks it. Anything else is honestly unsupported → 0 + disclosure. + const p = this.position(); + if (n === 5420) return p.x; + if (n === 5421) return p.y; + if (n === 5422) return p.z; + this.warn(RS274_WARN.unsupportedSysParam, `system parameter #${n} is not supported; read as 0`, srcByte); + return 0; + } + + /** The map a named parameter lives in: underscore-prefixed (`#<_global>`) → global, else scope-local. */ + private namedMap(name: string): Map { + return name[0] === '_' ? this.globalNamed : this.scope.named; + } + + private readNamed(name: string, srcByte?: number): number { + const v = this.namedMap(name).get(name); + if (v === undefined) { + this.warnUninit(srcByte); + return 0; + } + return v; + } + + private writeNumbered(n: number, value: number): void { + const idx = Math.round(n); + if (idx < 1 || idx >= NUMBERED_LEN) return; + if (idx <= LOCAL_NUMBERED_MAX) { + const s = this.scope; + s.localNumbered[idx] = value; + s.localWritten[idx] = 1; + } else { + this.numbered[idx] = value; + this.numberedWritten[idx] = 1; + } + } + + private writeNamed(name: string, value: number): void { + const map = this.namedMap(name); + if (map.has(name) || map.size < MAX_NAMED_PARAMS) map.set(name, value); + else if (!this.paramLimitWarned) { + this.paramLimitWarned = true; + this.warn( + RS274_WARN.paramLimit, + `named-parameter limit (${MAX_NAMED_PARAMS}) reached; further new names ignored` + ); + } + } + + /** `EXISTS[#]` → 1 if the named parameter has been assigned in its scope, else 0. */ + private exists(name: string): number { + return this.namedMap(name).has(name) ? 1 : 0; + } + + /** + * Lex one RAW parametric line — either apply a `#… = …` assignment, or resolve every word value into a + * plain-number `Cmd` for the engine's existing dispatch. Strips `( … )` comments BEFORE the `;` comment + * (RS274NGC order, so a `;` inside parens doesn't truncate the line). Malformed constructs are dropped + + * disclosed; the line still parses as far as it can. + */ + lexLine(rawLine: string, srcByte?: number): ParametricLine { + const body = stripParenComments(rawLine).split(';')[0].trim(); + if (body === '') return { kind: 'empty' }; + // The `#`/`[` was only inside a comment — this is a plain line; let the normal lexer own it. + if (!lineUsesParametric(body)) return { kind: 'plain' }; + this.used = true; + + // Assignment: `[N] # = ` (a leading N line-number is allowed, LinuxCNC). + const assignBody = body.replace(/^n\d+\s*/i, ''); + if (assignBody[0] === '#') { + const eq = topLevelEqIndex(assignBody); + if (eq !== -1) { + try { + const p = new Evaluator(assignBody, this, srcByte); + p.skipWs(); + const target = p.readParamTarget(); // where to store + p.skipWs(); + p.expect('='); + const value = p.parseExpr(0); + p.skipWsToEnd(); // trailing garbage → malformed + target(value); + return { kind: 'assign' }; + } catch (e) { + if (e instanceof Rs274EvalError) { + this.warn(RS274_WARN.badExpression, `malformed parameter assignment dropped: ${e.message}`, srcByte); + return { kind: 'assign' }; // consumed the line; nothing stored + } + throw e; + } + } + } + + // Motion/command line: walk words, resolving `#ref` / `[expr]` values to numbers. + const codes: string[] = []; + const params: Record = {}; + let sawCommand = false; + const ev = new Evaluator(body, this, srcByte); + ev.skipWs(); + while (!ev.atEnd()) { + const key = ev.readWordLetter(); + if (key === null) { + ev.dropToNextLetter(); // skip an unexpected char, keep going + continue; + } + let value: number; + try { + value = ev.readWordValue(); + } catch (e) { + if (e instanceof Rs274EvalError) { + this.warn(RS274_WARN.badExpression, `malformed word value for '${key.toUpperCase()}' dropped`, srcByte); + ev.dropToNextLetter(); + continue; + } + throw e; + } + if (!Number.isFinite(value)) { + // A well-formed expression that evaluated to NaN/Infinity (e.g. `X[1/0]`, `X[SQRT[-1]]`) — the + // word is dropped, but disclosed (DD-017 §4.6), never silent. + this.warn( + RS274_WARN.nonFiniteValue, + `word '${key.toUpperCase()}' evaluated to a non-finite value; dropped`, + srcByte + ); + ev.skipWs(); + continue; + } + if (key === 'g' || key === 'm') { + codes.push(`${key}${value}`); + sawCommand = true; + } else if (key === 't' && !sawCommand) { + codes.push(`t${value}`); + sawCommand = true; + } else if (key === 'n') { + // line number — ignored + } else { + params[key] = value; + } + ev.skipWs(); + } + return { kind: 'cmd', codes, params }; + } + + /** + * The `parametricProgram` capability tier (DD-017 §4.6). `unavailable` for a non-parametric file + * (all FDM); `known` when params/expressions/O-word flow ran deterministically within limits; + * `approximated` when execution was degraded (a hit iteration cap, an unbalanced/unsupported O-word, + * or a malformed control expression) — always paired with a specific disclosure, never silently wrong. + */ + parametricConfidence(): 'known' | 'approximated' | 'unavailable' { + if (!this.used) return 'unavailable'; + return this.degraded ? 'approximated' : 'known'; + } + + /** + * Evaluate a standalone expression (an O-word condition `[#1 GT 2]` or a `repeat` count `[5]`) against + * the shared parameter store, for the Phase 2 flow interpreter. Returns the numeric result, or `null` + * when the expression is malformed or evaluates to a non-finite value — disclosed either way (the + * caller treats `null` as false / zero iterations). Accepts a bracketed expression, a bare `#ref`, or a + * literal; trailing garbage is a malformed expression. No `eval`, depth-guarded like every other path. + */ + evalExpression(text: string, srcByte?: number): number | null { + this.used = true; + try { + const ev = new Evaluator(text, this, srcByte); + ev.skipWs(); + const v = ev.readWordValue(); // a leading `[expr]`, `#ref`, or number — exactly a condition/count + ev.skipWsToEnd(); + if (!Number.isFinite(v)) { + this.warn(RS274_WARN.nonFiniteValue, 'control expression evaluated to a non-finite value', srcByte); + return null; + } + return v; + } catch (e) { + if (e instanceof Rs274EvalError) { + this.warn(RS274_WARN.badExpression, `malformed control expression dropped: ${e.message}`, srcByte); + return null; + } + throw e; + } + } + + // --- operand hooks used by the Evaluator --- + _readParam(kind: 'numbered' | 'named', key: number | string, srcByte?: number): number { + return kind === 'numbered' ? this.readNumbered(key as number, srcByte) : this.readNamed(key as string, srcByte); + } + _writeParam(kind: 'numbered' | 'named', key: number | string, value: number): void { + if (kind === 'numbered') this.writeNumbered(key as number, value); + else this.writeNamed(key as string, value); + } + _exists(name: string): number { + return this.exists(name); + } +} + +/** Index of a top-level `=` (not inside brackets), or -1. */ +function topLevelEqIndex(s: string): number { + let depth = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '[') depth++; + else if (c === ']') depth--; + else if (c === '=' && depth === 0) return i; + } + return -1; +} + +const FUNCS = new Set([ + 'abs', + 'acos', + 'asin', + 'cos', + 'sin', + 'tan', + 'exp', + 'fix', + 'fup', + 'ln', + 'round', + 'sqrt', + 'atan', + 'exists' +]); + +/** A cursor-based recursive-descent evaluator over one line's body. */ +class Evaluator { + private i = 0; + private depth = 0; + constructor( + private readonly s: string, + private readonly ctx: Rs274Context, + private readonly srcByte?: number + ) {} + + atEnd(): boolean { + return this.i >= this.s.length; + } + skipWs(): void { + while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++; + } + skipWsToEnd(): void { + this.skipWs(); + if (!this.atEnd()) throw new Rs274EvalError(`trailing '${this.s.slice(this.i)}'`); + } + expect(ch: string): void { + if (this.s[this.i] !== ch) throw new Rs274EvalError(`expected '${ch}'`); + this.i++; + } + dropToNextLetter(): void { + // Recover from a malformed word: advance to the next a-z letter (or end). + if (this.i < this.s.length) this.i++; + while (this.i < this.s.length && !isLetter(this.s[this.i])) this.i++; + } + + /** Read a single command/axis letter (a–z), or null if the cursor isn't on a letter. */ + readWordLetter(): string | null { + this.skipWs(); + const c = this.s[this.i]; + if (c !== undefined && isLetter(c)) { + this.i++; + return c.toLowerCase(); + } + return null; + } + + /** Read a word's value: a `#`param-ref, a `[expr]`, or a numeric literal. */ + readWordValue(): number { + this.skipWs(); + const c = this.s[this.i]; + if (c === '#') return this.readParamRef(); + if (c === '[') return this.parseBracket(); + return this.readNumber(); + } + + /** Read a parameter *reference* as an operand and return its value. Depth-guarded (bounds `##…` chains). */ + private readParamRef(): number { + if (++this.depth > MAX_EXPR_DEPTH) throw new Rs274EvalError('reference nested too deep'); + try { + this.expect('#'); + this.skipWs(); + const c = this.s[this.i]; + if (c === '[') return this.ctx._readParam('numbered', this.parseBracket(), this.srcByte); // `#[expr]` + if (c === '#') return this.ctx._readParam('numbered', this.readParamRef(), this.srcByte); // `##ref` + if (c === '<') return this.ctx._readParam('named', this.readNamedRef(), this.srcByte); + return this.ctx._readParam('numbered', this.readNumber(), this.srcByte); // `#N` + } finally { + this.depth--; + } + } + + /** Read the assignment *target* and return a setter that stores a value there. */ + readParamTarget(): (value: number) => void { + this.expect('#'); + this.skipWs(); + const c = this.s[this.i]; + if (c === '[') { + const n = this.parseBracket(); + return (v) => this.ctx._writeParam('numbered', n, v); + } + if (c === '#') { + const n = this.readParamRef(); + return (v) => this.ctx._writeParam('numbered', n, v); + } + if (c === '<') { + const name = this.readNamedRef(); + return (v) => this.ctx._writeParam('named', name, v); + } + const n = this.readNumber(); + return (v) => this.ctx._writeParam('numbered', n, v); + } + + private readNamedRef(): string { + this.expect('<'); + let name = ''; + while (this.i < this.s.length && this.s[this.i] !== '>') name += this.s[this.i++]; + this.expect('>'); + return name.trim().toLowerCase(); + } + + private readNumber(): number { + this.skipWs(); + const start = this.i; + if (this.s[this.i] === '+' || this.s[this.i] === '-') this.i++; + let sawDigit = false; + while (this.i < this.s.length && /[0-9]/.test(this.s[this.i])) { + this.i++; + sawDigit = true; + } + if (this.s[this.i] === '.') { + this.i++; + while (this.i < this.s.length && /[0-9]/.test(this.s[this.i])) { + this.i++; + sawDigit = true; + } + } + if (!sawDigit) throw new Rs274EvalError(`expected a number at '${this.s.slice(start, start + 8)}'`); + return Number(this.s.slice(start, this.i)); + } + + private parseBracket(): number { + if (++this.depth > MAX_EXPR_DEPTH) throw new Rs274EvalError('expression nested too deep'); + this.expect('['); + const v = this.parseExpr(0); + this.skipWs(); + this.expect(']'); + this.depth--; + return v; + } + + /** + * Precedence-climbing expression parser. RS274NGC precedence, tightest → loosest: + * `**` > `* / MOD` > `+ -` > comparisons > logical. Implemented as levels 0..4 (0 = loosest). + */ + parseExpr(level: number): number { + if (level >= LEVELS.length) return this.parseUnary(); + let left = this.parseExpr(level + 1); + for (;;) { + this.skipWs(); + const op = this.peekOperator(LEVELS[level]); + if (op === null) return left; + this.i += op.length; + const right = this.parseExpr(level + 1); + left = applyBinary(op, left, right); + } + } + + private parseUnary(): number { + this.skipWs(); + const c = this.s[this.i]; + if (c === '+' || c === '-') { + if (++this.depth > MAX_EXPR_DEPTH) throw new Rs274EvalError('expression nested too deep'); // bounds `----…` chains + this.i++; + const v = c === '-' ? -this.parseUnary() : this.parseUnary(); + this.depth--; + return v; + } + return this.parsePower(); + } + + /** `**` binds tighter than everything and is right-associative (`2**3**2` = `2**9`). */ + private parsePower(): number { + const base = this.parseAtom(); + this.skipWs(); + if (this.s[this.i] === '*' && this.s[this.i + 1] === '*') { + this.i += 2; + const exp = this.parseUnary(); // right-assoc + allows `2 ** -1` + return Math.pow(base, exp); + } + return base; + } + + private parseAtom(): number { + this.skipWs(); + const c = this.s[this.i]; + if (c === '#') return this.readParamRef(); + if (c === '[') return this.parseBracket(); + if (isLetter(c)) { + const name = this.readIdentifier(); + if (FUNCS.has(name)) return this.callFunction(name); + throw new Rs274EvalError(`unexpected '${name}'`); + } + return this.readNumber(); + } + + private readIdentifier(): string { + let id = ''; + while (this.i < this.s.length && isLetter(this.s[this.i])) id += this.s[this.i++]; + return id.toLowerCase(); + } + + private callFunction(name: string): number { + if (name === 'exists') return this.parseExistsArg(); + if (name === 'atan') { + // `ATAN[y]/[x]` — degrees. + const y = this.parseBracket(); + this.skipWs(); + this.expect('/'); + const x = this.parseBracket(); + return Math.atan2(y, x) / DEG; + } + return applyFunction(name, this.parseBracket()); + } + + /** + * `EXISTS[#]` — test a named parameter's existence WITHOUT reading it (no uninitialized read). + * RS274NGC/LinuxCNC defines EXISTS only for a named parameter; any other argument is not an existence + * query and returns 0 (its content is skipped, not evaluated — so it triggers no read/side effect). + */ + private parseExistsArg(): number { + if (++this.depth > MAX_EXPR_DEPTH) throw new Rs274EvalError('expression nested too deep'); + this.expect('['); + this.skipWs(); + let result: number; + if (this.s[this.i] === '#' && this.s[this.i + 1] === '<') { + this.i++; // consume '#' + result = this.ctx._exists(this.readNamedRef()); + this.skipWs(); + this.expect(']'); + } else { + this.skipToCloseBracket(); + result = 0; + } + this.depth--; + return result; + } + + /** Consume up to and including the matching `]` (for a non-evaluated EXISTS argument). */ + private skipToCloseBracket(): void { + let d = 1; + while (this.i < this.s.length && d > 0) { + const c = this.s[this.i++]; + if (c === '[') d++; + else if (c === ']') d--; + } + if (d !== 0) throw new Rs274EvalError("unbalanced '[' in EXISTS"); + } + + /** Peek an operator belonging to `ops`; return its lexeme or null. Word ops must be word-bounded. */ + private peekOperator(ops: readonly string[]): string | null { + for (const op of ops) { + if (op === '**') continue; // power handled in parsePower + const isWord = /^[a-z]+$/.test(op); + if (isWord) { + const seg = this.s.slice(this.i, this.i + op.length).toLowerCase(); + if (seg === op) { + const after = this.s[this.i + op.length]; + if (after === undefined || !isLetter(after)) return op; + } + } else if (this.s.startsWith(op, this.i)) { + if (op === '*' && this.s[this.i + 1] === '*') continue; // don't eat `**` as `*` + return op; + } + } + return null; + } +} + +// Operator levels, loosest (0) → tightest. `**` lives at the power level handled specially. +const LEVELS: readonly (readonly string[])[] = [ + ['and', 'or', 'xor'], + ['eq', 'ne', 'gt', 'ge', 'lt', 'le'], + ['+', '-'], + ['*', '/', 'mod'] +]; + +function isLetter(c: string | undefined): boolean { + if (c === undefined) return false; + const cc = c.charCodeAt(0); + return (cc >= 65 && cc <= 90) || (cc >= 97 && cc <= 122); +} + +const bool = (b: boolean): number => (b ? 1 : 0); + +function applyBinary(op: string, a: number, b: number): number { + switch (op) { + case '+': + return a + b; + case '-': + return a - b; + case '*': + return a * b; + case '/': + return a / b; + case 'mod': + return a - b * Math.floor(a / b); // LinuxCNC: floored, sign of divisor + case 'and': + return bool(a !== 0 && b !== 0); + case 'or': + return bool(a !== 0 || b !== 0); + case 'xor': + return bool((a !== 0) !== (b !== 0)); + case 'eq': + return bool(Math.abs(a - b) < EQ_TOLERANCE); + case 'ne': + return bool(Math.abs(a - b) >= EQ_TOLERANCE); + case 'gt': + return bool(a > b); + case 'ge': + return bool(a >= b); + case 'lt': + return bool(a < b); + case 'le': + return bool(a <= b); + default: + throw new Rs274EvalError(`unknown operator '${op}'`); + } +} + +function applyFunction(name: string, x: number): number { + switch (name) { + case 'abs': + return Math.abs(x); + case 'acos': + return Math.acos(x) / DEG; + case 'asin': + return Math.asin(x) / DEG; + case 'cos': + return Math.cos(x * DEG); + case 'sin': + return Math.sin(x * DEG); + case 'tan': + return Math.tan(x * DEG); + case 'exp': + return Math.exp(x); + case 'fix': + return Math.floor(x); // round toward -inf + case 'fup': + return Math.ceil(x); // round toward +inf + case 'ln': + return Math.log(x); + case 'round': + return Math.round(x); + case 'sqrt': + return Math.sqrt(x); + default: + throw new Rs274EvalError(`unknown function '${name}'`); + } +} diff --git a/packages/gcode-parser/src/streaming.ts b/packages/gcode-parser/src/streaming.ts index 40698fe8..01dbeec3 100644 --- a/packages/gcode-parser/src/streaming.ts +++ b/packages/gcode-parser/src/streaming.ts @@ -22,6 +22,7 @@ import { type AsyncParseResult, type ParseOptions } from './parse.js'; +import { isOWordControlLine } from './rs274-flow.js'; /** Anything that can hand us chunks of bytes. */ export type StreamInput = BlobLike | ReadableStreamLike; @@ -108,6 +109,9 @@ export async function parseGcodeStreamToIR( lineStartOffset += line.length + 1; continue; } + // O-word control flow can't be interpreted from a partial stream (DD-017 §4.1): disclose once and + // run linearly. The cheap peek rejects every FDM line before any allocation. + if (isOWordControlLine(line)) engine.noteUninterpretedOWord(lineStartOffset); engine.processLine(line, lineStartOffset); lineStartOffset += line.length + 1; if (engine.stopped()) return; diff --git a/packages/gcode-preview-core/CHANGELOG.md b/packages/gcode-preview-core/CHANGELOG.md index f7db19ff..59cddf00 100644 --- a/packages/gcode-preview-core/CHANGELOG.md +++ b/packages/gcode-preview-core/CHANGELOG.md @@ -1,5 +1,43 @@ # @chestnutlabs/gcode-preview-core +## 0.18.0 + +### Minor Changes + +- [#418](https://github.com/ChestnutLabs/gcode-preview/pull/418) [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(renderer): interactive view capture() → Blob (DD-030 D1) + + The interactive viewer can now hand back **what is on screen right now** as an image `Blob` — for a + user-selected thumbnail, a large-file thumbnail fallback, or a screenshot. New `capture(opts?)` where + `opts` is `{ width?, height?, format?, quality?, background? }` (all optional; defaults match the live + view). + + Available on every interactive surface: `GcodePreviewControls.capture()` (so the Vue, React, Svelte, and + Web-Component adapters all inherit it — the Web Component also exposes an imperative `capture()` method), + and `ModelViewer.capture()` on the model-viewer handle. The toolpath `ToolpathRenderer` and the shared + `InteractiveStage` carry the implementation. + + **Mechanism (render-to-target).** Capture renders the current scene + active camera into an off-screen + `WebGLRenderTarget` at the requested size and reads it back, rather than flipping the interactive + context's `preserveDrawingBuffer` (which would tax every interactive frame). That gives an arbitrary + output size and an independent/transparent background **without** disturbing the live view, and reuses the + headless still path's "single render, then read pixels" recipe. The thumbnail is framed at its own aspect + so it isn't distorted; the live view is repainted afterward. The library returns the `Blob` and **never** + triggers a download — the caller owns the pixels (same contract as `renderStill`). + + **Honest.** When the renderer cannot render-to-target (the 2D renderer, a stub GL / no WebGL) or the stage + is disposed / its context is lost, `capture()` rejects with a typed `CaptureUnsupportedError` + (`code: 'E_CAPTURE_UNSUPPORTED'`) — never fabricated output. Purely additive (a new optional method on the + renderer contract; no existing signature changed). Final increment of the DD-030 renderer/viewer + interoperability batch (bed + per-plate scope + capture). + +### Patch Changes + +- Updated dependencies [[`5144890`](https://github.com/ChestnutLabs/gcode-preview/commit/51448906f87ca09f3ed11f1286f7b79356110a83), [`bcb4b78`](https://github.com/ChestnutLabs/gcode-preview/commit/bcb4b786fcfc6a2e967d8bfff1d4fd3a06731681), [`dc89056`](https://github.com/ChestnutLabs/gcode-preview/commit/dc890568d45e59635b9994d8409ef6cdc0847f80), [`0ebeadf`](https://github.com/ChestnutLabs/gcode-preview/commit/0ebeadfa839e52baf243ef07b5807f3974bbac7e), [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0)]: + - @chestnutlabs/gcode-parser@0.18.0 + - @chestnutlabs/gcode-renderer-three@0.18.0 + - @chestnutlabs/gcode-renderer-2d@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Minor Changes diff --git a/packages/gcode-preview-core/package.json b/packages/gcode-preview-core/package.json index 39272890..79f867b0 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.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/gcode-renderer-2d": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-renderer-2d": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" } } diff --git a/packages/gcode-preview-core/src/controller.ts b/packages/gcode-preview-core/src/controller.ts index 8546f782..e6ec5034 100644 --- a/packages/gcode-preview-core/src/controller.ts +++ b/packages/gcode-preview-core/src/controller.ts @@ -23,6 +23,7 @@ import type { CameraMode, CameraState, CameraView, + CaptureOptions, ColorMode, GLRendererLike, ProgressPresentationMode, @@ -33,6 +34,7 @@ import type { Theme, TubeOptions } from '@chestnutlabs/gcode-renderer-three'; +import { CaptureUnsupportedError } from '@chestnutlabs/gcode-renderer-three'; import { LayerView2DRenderer } from './renderer-2d-adapter.js'; import type { MoveKindToggle, PreviewRenderer, PreviewRendererEvent, RendererMode } from './renderer-interface.js'; import { @@ -208,6 +210,12 @@ export interface GcodePreviewControls { /** Interaction-aware quality: 'auto' reduces detail while the camera moves (#306/2, DD-020). */ setInteractionQuality(mode: 'off' | 'auto'): void; frame(): void; + /** + * Capture the currently displayed view as an image `Blob` (DD-030 D1) — for a user-selected thumbnail, + * a large-file fallback, or a screenshot. Rejects with `E_CAPTURE_UNSUPPORTED` before the 3D renderer + * is ready or on the 2D renderer (which cannot render-to-target). The caller owns the `Blob`. + */ + capture(opts?: CaptureOptions): Promise; } export interface PreviewController { @@ -556,6 +564,10 @@ export function createPreviewController(options: PreviewControllerOptions = {}): setView: (v) => withRenderer((r) => r.setView(v)), // Returns a value, so it can't queue: before the renderer is ready (or on 2D) there is no pose → null. getCameraState: () => (renderer !== null ? renderer.getCameraState() : null), + capture: (opts) => + renderer !== null && typeof renderer.capture === 'function' + ? renderer.capture(opts) + : Promise.reject(new CaptureUnsupportedError('capture unavailable: renderer not ready or unsupported')), setCameraState: (s) => withRenderer((r) => r.setCameraState(s)), setTheme: (t) => withRenderer((r) => r.setTheme(t)), setBuildVolume: (def) => { diff --git a/packages/gcode-preview-core/src/index.ts b/packages/gcode-preview-core/src/index.ts index ba58bc05..56709ff5 100644 --- a/packages/gcode-preview-core/src/index.ts +++ b/packages/gcode-preview-core/src/index.ts @@ -13,3 +13,6 @@ export { renderStill, type RenderStillOptions, type RenderStillResult, type Stil export type { Theme, MaterialPreset, ThemeColor } from '@chestnutlabs/gcode-renderer-three'; // Camera contracts (three-free type re-exports): projection + preset views + serializable state (#268). export type { CameraMode, CameraView, CameraState } from '@chestnutlabs/gcode-renderer-three'; +// Interactive view capture (DD-030 D1): the options type + the typed unsupported error for consumers. +export type { CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; +export { CaptureUnsupportedError } from '@chestnutlabs/gcode-renderer-three'; diff --git a/packages/gcode-preview-core/src/renderer-interface.ts b/packages/gcode-preview-core/src/renderer-interface.ts index 04a7704b..4d6a7555 100644 --- a/packages/gcode-preview-core/src/renderer-interface.ts +++ b/packages/gcode-preview-core/src/renderer-interface.ts @@ -13,6 +13,7 @@ import type { CameraMode, CameraState, CameraView, + CaptureOptions, ColorMode, ProgressivePreview, QualityMode, @@ -21,7 +22,7 @@ import type { Theme } from '@chestnutlabs/gcode-renderer-three'; -export type { CameraState, CameraView } from '@chestnutlabs/gcode-renderer-three'; +export type { CameraState, CameraView, CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; import type { MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; /** Which renderer implementation backs the preview. `'3d'` is the default (Three.js). */ @@ -87,5 +88,11 @@ export interface PreviewRenderer { */ pickSegment(ndcX: number, ndcY: number, threshold?: number): number | null; frame(): void; + /** + * Capture the current view as an image `Blob` (DD-030 D1). Optional: the 3D renderer implements it via + * render-to-target; a renderer that cannot (the 2D renderer) omits it, and the controller reports + * `renderer-unsupported` / rejects. Never fabricated output. + */ + capture?(opts?: CaptureOptions): Promise; dispose(): void; } diff --git a/packages/gcode-preview-element/CHANGELOG.md b/packages/gcode-preview-element/CHANGELOG.md index dccd4982..33ea7409 100644 --- a/packages/gcode-preview-element/CHANGELOG.md +++ b/packages/gcode-preview-element/CHANGELOG.md @@ -1,5 +1,43 @@ # @chestnutlabs/gcode-preview-element +## 0.18.0 + +### Minor Changes + +- [#418](https://github.com/ChestnutLabs/gcode-preview/pull/418) [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(renderer): interactive view capture() → Blob (DD-030 D1) + + The interactive viewer can now hand back **what is on screen right now** as an image `Blob` — for a + user-selected thumbnail, a large-file thumbnail fallback, or a screenshot. New `capture(opts?)` where + `opts` is `{ width?, height?, format?, quality?, background? }` (all optional; defaults match the live + view). + + Available on every interactive surface: `GcodePreviewControls.capture()` (so the Vue, React, Svelte, and + Web-Component adapters all inherit it — the Web Component also exposes an imperative `capture()` method), + and `ModelViewer.capture()` on the model-viewer handle. The toolpath `ToolpathRenderer` and the shared + `InteractiveStage` carry the implementation. + + **Mechanism (render-to-target).** Capture renders the current scene + active camera into an off-screen + `WebGLRenderTarget` at the requested size and reads it back, rather than flipping the interactive + context's `preserveDrawingBuffer` (which would tax every interactive frame). That gives an arbitrary + output size and an independent/transparent background **without** disturbing the live view, and reuses the + headless still path's "single render, then read pixels" recipe. The thumbnail is framed at its own aspect + so it isn't distorted; the live view is repainted afterward. The library returns the `Blob` and **never** + triggers a download — the caller owns the pixels (same contract as `renderStill`). + + **Honest.** When the renderer cannot render-to-target (the 2D renderer, a stub GL / no WebGL) or the stage + is disposed / its context is lost, `capture()` rejects with a typed `CaptureUnsupportedError` + (`code: 'E_CAPTURE_UNSUPPORTED'`) — never fabricated output. Purely additive (a new optional method on the + renderer contract; no existing signature changed). Final increment of the DD-030 renderer/viewer + interoperability batch (bed + per-plate scope + capture). + +### Patch Changes + +- Updated dependencies [[`5144890`](https://github.com/ChestnutLabs/gcode-preview/commit/51448906f87ca09f3ed11f1286f7b79356110a83), [`bcb4b78`](https://github.com/ChestnutLabs/gcode-preview/commit/bcb4b786fcfc6a2e967d8bfff1d4fd3a06731681), [`dc89056`](https://github.com/ChestnutLabs/gcode-preview/commit/dc890568d45e59635b9994d8409ef6cdc0847f80), [`0ebeadf`](https://github.com/ChestnutLabs/gcode-preview/commit/0ebeadfa839e52baf243ef07b5807f3974bbac7e), [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0)]: + - @chestnutlabs/gcode-parser@0.18.0 + - @chestnutlabs/gcode-renderer-three@0.18.0 + - @chestnutlabs/gcode-preview-core@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-preview-element/package.json b/packages/gcode-preview-element/package.json index cbeb2906..5b1af563 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.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" } } diff --git a/packages/gcode-preview-element/src/gcode-preview-element.ts b/packages/gcode-preview-element/src/gcode-preview-element.ts index af5bc07d..9c5adae3 100644 --- a/packages/gcode-preview-element/src/gcode-preview-element.ts +++ b/packages/gcode-preview-element/src/gcode-preview-element.ts @@ -12,6 +12,7 @@ */ import { createPreviewController, + type CaptureOptions, type GcodePreviewControls, type GcodePreviewState, type ParseOutcome, @@ -269,6 +270,10 @@ export class GcodePreviewElement extends HTMLElement { parse(input: Uint8Array | ArrayBuffer | File, opts?: WireParseOptions): Promise { return this.requireController().parse(input, opts); } + /** Capture the current view as an image `Blob` (DD-030 D1). Delegates to `controls.capture`. */ + capture(opts?: CaptureOptions): Promise { + return this.requireController().controls.capture(opts); + } observeProgress(obs: ProgressObservation): MappedProgress | null { return this.controller?.observeProgress(obs) ?? null; } diff --git a/packages/gcode-preview-react/CHANGELOG.md b/packages/gcode-preview-react/CHANGELOG.md index b5474797..ad5cc244 100644 --- a/packages/gcode-preview-react/CHANGELOG.md +++ b/packages/gcode-preview-react/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-react +## 0.18.0 + +### Patch Changes + +- Updated dependencies [[`5144890`](https://github.com/ChestnutLabs/gcode-preview/commit/51448906f87ca09f3ed11f1286f7b79356110a83), [`bcb4b78`](https://github.com/ChestnutLabs/gcode-preview/commit/bcb4b786fcfc6a2e967d8bfff1d4fd3a06731681), [`dc89056`](https://github.com/ChestnutLabs/gcode-preview/commit/dc890568d45e59635b9994d8409ef6cdc0847f80), [`0ebeadf`](https://github.com/ChestnutLabs/gcode-preview/commit/0ebeadfa839e52baf243ef07b5807f3974bbac7e), [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0)]: + - @chestnutlabs/gcode-parser@0.18.0 + - @chestnutlabs/gcode-renderer-three@0.18.0 + - @chestnutlabs/gcode-preview-core@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-preview-react/package.json b/packages/gcode-preview-react/package.json index cdb75633..e94aa10d 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.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.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 91539685..100b9a6c 100644 --- a/packages/gcode-preview-svelte/CHANGELOG.md +++ b/packages/gcode-preview-svelte/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-svelte +## 0.18.0 + +### Patch Changes + +- Updated dependencies [[`5144890`](https://github.com/ChestnutLabs/gcode-preview/commit/51448906f87ca09f3ed11f1286f7b79356110a83), [`bcb4b78`](https://github.com/ChestnutLabs/gcode-preview/commit/bcb4b786fcfc6a2e967d8bfff1d4fd3a06731681), [`dc89056`](https://github.com/ChestnutLabs/gcode-preview/commit/dc890568d45e59635b9994d8409ef6cdc0847f80), [`0ebeadf`](https://github.com/ChestnutLabs/gcode-preview/commit/0ebeadfa839e52baf243ef07b5807f3974bbac7e), [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0)]: + - @chestnutlabs/gcode-parser@0.18.0 + - @chestnutlabs/gcode-renderer-three@0.18.0 + - @chestnutlabs/gcode-preview-core@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-preview-svelte/package.json b/packages/gcode-preview-svelte/package.json index e06d6614..e7e45b18 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.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.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 7cdade39..e7dfa91c 100644 --- a/packages/gcode-preview-vue/CHANGELOG.md +++ b/packages/gcode-preview-vue/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-preview-vue +## 0.18.0 + +### Patch Changes + +- Updated dependencies [[`5144890`](https://github.com/ChestnutLabs/gcode-preview/commit/51448906f87ca09f3ed11f1286f7b79356110a83), [`bcb4b78`](https://github.com/ChestnutLabs/gcode-preview/commit/bcb4b786fcfc6a2e967d8bfff1d4fd3a06731681), [`dc89056`](https://github.com/ChestnutLabs/gcode-preview/commit/dc890568d45e59635b9994d8409ef6cdc0847f80), [`0ebeadf`](https://github.com/ChestnutLabs/gcode-preview/commit/0ebeadfa839e52baf243ef07b5807f3974bbac7e), [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0)]: + - @chestnutlabs/gcode-parser@0.18.0 + - @chestnutlabs/gcode-renderer-three@0.18.0 + - @chestnutlabs/gcode-preview-core@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-preview-vue/package.json b/packages/gcode-preview-vue/package.json index c33305fb..fe5475a4 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.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/gcode-renderer-three": "0.17.0", - "@chestnutlabs/toolpath-core": "0.17.0", - "@chestnutlabs/gcode-preview-core": "0.17.0" + "@chestnutlabs/gcode-parser": "0.18.0", + "@chestnutlabs/gcode-renderer-three": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0", + "@chestnutlabs/gcode-preview-core": "0.18.0" }, "peerDependencies": { "vue": "^3.4.0" diff --git a/packages/gcode-renderer-2d/CHANGELOG.md b/packages/gcode-renderer-2d/CHANGELOG.md index 007daab3..afd44607 100644 --- a/packages/gcode-renderer-2d/CHANGELOG.md +++ b/packages/gcode-renderer-2d/CHANGELOG.md @@ -1,5 +1,13 @@ # @chestnutlabs/gcode-renderer-2d +## 0.18.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/gcode-colors@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Patch Changes diff --git a/packages/gcode-renderer-2d/package.json b/packages/gcode-renderer-2d/package.json index 1479d4f8..aff7863b 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.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-colors": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" } } diff --git a/packages/gcode-renderer-three/CHANGELOG.md b/packages/gcode-renderer-three/CHANGELOG.md index c9bc7c8c..8d4c1d38 100644 --- a/packages/gcode-renderer-three/CHANGELOG.md +++ b/packages/gcode-renderer-three/CHANGELOG.md @@ -1,5 +1,61 @@ # @chestnutlabs/gcode-renderer-three +## 0.18.0 + +### Minor Changes + +- [#416](https://github.com/ChestnutLabs/gcode-preview/pull/416) [`0ebeadf`](https://github.com/ChestnutLabs/gcode-preview/commit/0ebeadfa839e52baf243ef07b5807f3974bbac7e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(renderer): non-rectangular build-bed geometry (DD-030 D3) + + The build volume can now draw an **honest non-rectangular bed** — delta, round, or irregular — instead of + only a rectangle. `BuildVolumeDef` gains an optional `shape?: BedShape` + (`{kind:'rect'} | {kind:'circular', center, diameter} | {kind:'polygon', points}`): a circle is + polygonized, the outline is filled and drawn, and the floor grid is **clipped to the outline** so the + printable area reads correctly (a round bed is no longer a square with a circle floating over it). The + `mesh` escape hatch for a fully custom bed mesh is reserved for a later phase. + + `machineToVolume()` now maps a discovered `MachineGeometry.bed` of kind `circular`/`polygon` onto that + `shape`, so a **discovered** round/delta bed renders as its true outline instead of being collapsed to its + bounding rectangle (a visible improvement for delta/round printers). Callers supply the shape (from a + machine profile, a config, wherever); the renderer just draws the polygon — no profile parsing in the + library, no vendor semantics baked in. + + Additive and safe: a bed with no `shape` (or `{kind:'rect'}`) takes the original rectangular path and is + **byte-identical** — the rectangular grid, plate, cage, excluded-region outlines, and origin tripod are + unchanged. The volume cage stays a bounding box (the bed outline carries the shape). First increment of + the DD-030 renderer/viewer interoperability batch. + +- [#418](https://github.com/ChestnutLabs/gcode-preview/pull/418) [`c950339`](https://github.com/ChestnutLabs/gcode-preview/commit/c95033908e3ebaeb07b6f7e6f2487672dcc463f0) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - feat(renderer): interactive view capture() → Blob (DD-030 D1) + + The interactive viewer can now hand back **what is on screen right now** as an image `Blob` — for a + user-selected thumbnail, a large-file thumbnail fallback, or a screenshot. New `capture(opts?)` where + `opts` is `{ width?, height?, format?, quality?, background? }` (all optional; defaults match the live + view). + + Available on every interactive surface: `GcodePreviewControls.capture()` (so the Vue, React, Svelte, and + Web-Component adapters all inherit it — the Web Component also exposes an imperative `capture()` method), + and `ModelViewer.capture()` on the model-viewer handle. The toolpath `ToolpathRenderer` and the shared + `InteractiveStage` carry the implementation. + + **Mechanism (render-to-target).** Capture renders the current scene + active camera into an off-screen + `WebGLRenderTarget` at the requested size and reads it back, rather than flipping the interactive + context's `preserveDrawingBuffer` (which would tax every interactive frame). That gives an arbitrary + output size and an independent/transparent background **without** disturbing the live view, and reuses the + headless still path's "single render, then read pixels" recipe. The thumbnail is framed at its own aspect + so it isn't distorted; the live view is repainted afterward. The library returns the `Blob` and **never** + triggers a download — the caller owns the pixels (same contract as `renderStill`). + + **Honest.** When the renderer cannot render-to-target (the 2D renderer, a stub GL / no WebGL) or the stage + is disposed / its context is lost, `capture()` rejects with a typed `CaptureUnsupportedError` + (`code: 'E_CAPTURE_UNSUPPORTED'`) — never fabricated output. Purely additive (a new optional method on the + renderer contract; no existing signature changed). Final increment of the DD-030 renderer/viewer + interoperability batch (bed + per-plate scope + capture). + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/gcode-colors@0.18.0 + - @chestnutlabs/toolpath-core@0.18.0 + ## 0.17.0 ### Minor Changes diff --git a/packages/gcode-renderer-three/package.json b/packages/gcode-renderer-three/package.json index 9b734a87..90b6a740 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.17.0", + "version": "0.18.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.17.0", - "@chestnutlabs/toolpath-core": "0.17.0" + "@chestnutlabs/gcode-colors": "0.18.0", + "@chestnutlabs/toolpath-core": "0.18.0" }, "devDependencies": { "@types/three": "0.178.0" diff --git a/packages/gcode-renderer-three/src/__tests__/capture.test.ts b/packages/gcode-renderer-three/src/__tests__/capture.test.ts new file mode 100644 index 00000000..4b9f94e5 --- /dev/null +++ b/packages/gcode-renderer-three/src/__tests__/capture.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +/** + * DD-030 D1 — interactive capture. The GL-free halves (row flip, size resolution) are unit-tested here; + * the unsupported path is verified through `ToolpathRenderer.capture()` with a stub GL that cannot + * render-to-target. The full pixel path (render-target readback + canvas encode) is browser-only and is + * validated in the browser, not in Node. + */ +import { describe, expect, it } from 'vitest'; +import { ToolpathRenderer, type GLRendererLike } from '../index.js'; +import { flipRowsRGBA, resolveCaptureSize } from '../capture.js'; + +describe('capture — pure helpers', () => { + it('flipRowsRGBA flips rows top↔bottom (2 rows, 1px each)', () => { + const src = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); // row0 = 1..4, row1 = 5..8 + const out = flipRowsRGBA(src, 1, 2); + expect(Array.from(out)).toEqual([5, 6, 7, 8, 1, 2, 3, 4]); // rows swapped + }); + + it('flipRowsRGBA is a no-op for a single row', () => { + const src = new Uint8Array([9, 8, 7, 6]); + expect(Array.from(flipRowsRGBA(src, 1, 1))).toEqual([9, 8, 7, 6]); + }); + + it('resolveCaptureSize defaults to the buffer size, honors overrides, rounds and clamps to ≥1', () => { + expect(resolveCaptureSize({}, 800, 600)).toEqual({ w: 800, h: 600 }); + expect(resolveCaptureSize({ width: 256 }, 800, 600)).toEqual({ w: 256, h: 600 }); + expect(resolveCaptureSize({ width: 100.6, height: 50.2 }, 800, 600)).toEqual({ w: 101, h: 50 }); + expect(resolveCaptureSize({ width: 0, height: -5 }, 800, 600)).toEqual({ w: 1, h: 1 }); + }); +}); + +describe('capture — unsupported path', () => { + it('rejects with E_CAPTURE_UNSUPPORTED when the renderer cannot render-to-target', async () => { + const canvas = document.createElement('canvas'); + const stub: GLRendererLike = { + render: () => undefined, + setSize: () => undefined, + dispose: () => undefined, + domElement: canvas + // no setRenderTarget / readRenderTargetPixels — a stub GL cannot capture + }; + const renderer = new ToolpathRenderer({ + canvas, + quality: 'lines', + createRenderer: () => stub, + scheduleFrame: () => undefined + }); + await expect(renderer.capture()).rejects.toMatchObject({ code: 'E_CAPTURE_UNSUPPORTED' }); + renderer.dispose(); + }); +}); diff --git a/packages/gcode-renderer-three/src/__tests__/scene.test.ts b/packages/gcode-renderer-three/src/__tests__/scene.test.ts index 942330b4..51f16a81 100644 --- a/packages/gcode-renderer-three/src/__tests__/scene.test.ts +++ b/packages/gcode-renderer-three/src/__tests__/scene.test.ts @@ -20,6 +20,7 @@ import { buildTubeChunk, chooseQuality, createBuildVolume, + machineToVolume, ToolpathRenderer, type GLRendererLike, type QualityMode, @@ -592,6 +593,73 @@ describe('ToolpathRenderer clipping/scrub/visibility/coloring (phase 3)', () => expect(bb.min.z).toBeLessThan(0); }); + it('a rectangular bed (no shape) is byte-identical: no bedOutline, a full rectangular grid (DD-030)', () => { + const rect = createBuildVolume({ x: 200, y: 200, z: 250 }); + expect(rect.children.find((c) => c.name === 'bedOutline')).toBeUndefined(); + // The rectangular grid spans the full bounding box (a vertex reaches the corner). + const grid = rect.children.find((c) => c.type === 'LineSegments' && c.name !== 'volumeCage') as LineSegments; + grid.geometry.computeBoundingBox(); + const bb = grid.geometry.boundingBox!; + expect(bb.max.x).toBeCloseTo(200); + expect(bb.max.y).toBeCloseTo(200); + }); + + it('a circular bed draws a round outline and clips the grid inside it (DD-030)', () => { + const vol = createBuildVolume({ + x: 200, + y: 200, + z: 250, + min: { x: 0, y: 0 }, + shape: { kind: 'circular', center: { x: 100, y: 100 }, diameter: 200 } + }); + const outline = vol.children.find((c) => c.name === 'bedOutline') as LineLoop | undefined; + expect(outline).toBeDefined(); + // Every grid vertex lies within the disc (radius 100 about the center) — the grid was clipped. + const grid = vol.children.find((c) => c.type === 'LineSegments' && c.name !== 'volumeCage') as LineSegments; + const pos = grid.geometry.getAttribute('position'); + expect(pos.count).toBeGreaterThan(0); + for (let i = 0; i < pos.count; i++) { + const dx = pos.getX(i) - 100; + const dy = pos.getY(i) - 100; + expect(Math.hypot(dx, dy)).toBeLessThanOrEqual(100 + 1e-6); + } + }); + + it('a solid surface on a non-rect bed fills the outline (a ShapeGeometry, not a full plane) (DD-030)', () => { + const vol = createBuildVolume( + { x: 200, y: 200, z: 250, shape: { kind: 'circular', center: { x: 100, y: 100 }, diameter: 200 } }, + { bedSurface: { mode: 'solid' } } + ); + const plate = vol.children.find((c) => c.name === 'bedSurface') as Mesh | undefined; + expect(plate).toBeDefined(); + // A round fill's area is ~π·r² (≈31416), well under the 200×200 bounding square (40000). + (plate as Mesh).geometry.computeBoundingBox(); + const bb = (plate as Mesh).geometry.boundingBox!; + expect(bb.min.x).toBeCloseTo(0); + expect(bb.max.x).toBeCloseTo(200); + expect((plate as Mesh).renderOrder).toBe(-1); + }); + + it('machineToVolume maps bed kind to a shape: rect→none, circular/polygon→outline (DD-030)', () => { + const base = { origin: { x: 0, y: 0 }, confidence: 'known' as const, source: { adapterId: 't', evidence: 'e' } }; + const rect = machineToVolume({ ...base, bed: { kind: 'rect', min: { x: 0, y: 0 }, max: { x: 200, y: 200 } } }); + expect(rect.shape).toBeUndefined(); // rectangular stays shapeless → byte-identical + const circ = machineToVolume({ ...base, bed: { kind: 'circular', center: { x: 90, y: 90 }, diameter: 180 } }); + expect(circ.shape).toEqual({ kind: 'circular', center: { x: 90, y: 90 }, diameter: 180 }); + const poly = machineToVolume({ + ...base, + bed: { + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 50, y: 100 } + ] + } + }); + expect(poly.shape?.kind).toBe('polygon'); + }); + it('excludedRegions render keep-out outlines above the plate (#185)', () => { const volume = createBuildVolume( { diff --git a/packages/gcode-renderer-three/src/build-volume.ts b/packages/gcode-renderer-three/src/build-volume.ts index 436215b1..551f34d2 100644 --- a/packages/gcode-renderer-three/src/build-volume.ts +++ b/packages/gcode-renderer-three/src/build-volume.ts @@ -17,11 +17,24 @@ import { LineSegments, Mesh, MeshBasicMaterial, - PlaneGeometry + PlaneGeometry, + Shape, + ShapeGeometry } from 'three'; -import type { Region2 } from '@chestnutlabs/toolpath-core'; +import type { Point2, Region2 } from '@chestnutlabs/toolpath-core'; import type { BedSurface, ThemeColor } from './theme.js'; +/** + * The bed's true outline (DD-030 D3). Omitted (or `'rect'`) ⇒ the rectangular `[min .. min+size]` bed — + * byte-identical to the pre-DD-030 renderer. `circular`/`polygon` draw an honest non-rectangular bed + * (delta/round/irregular): the renderer polygonizes a circle, fills the outline, and clips the grid to + * it. `MachineGeometry.bed` maps straight onto this (a `mesh` escape hatch is reserved, not yet shipped). + */ +export type BedShape = + | { kind: 'rect' } + | { kind: 'circular'; center: Point2; diameter: number } + | { kind: 'polygon'; points: Point2[] }; + export interface BuildVolumeDef { x: number; y: number; @@ -32,6 +45,8 @@ export interface BuildVolumeDef { min?: { x: number; y: number }; /** Excluded regions to outline on the plate (#185), when known from `MachineGeometry`. */ excludedRegions?: Region2[]; + /** Non-rectangular bed outline (DD-030 D3). Omit for a rectangular bed (default, byte-identical). */ + shape?: BedShape; } /** Themed styling (DD-009 D4, #153). Omitted fields keep the original constants; the @@ -72,24 +87,35 @@ export function createBuildVolume(def: BuildVolumeDef, style?: BuildVolumeStyle) const x1 = x0 + def.x; const y1 = y0 + def.y; + // The bed outline (DD-030 D3). `null` ⇒ rectangular: every branch below takes its original, + // byte-identical path. A non-null polygon ⇒ a delta/round/irregular bed to fill, outline, and clip to. + const outline = bedOutlinePoints(def); + // Filled build-plate surface (#185), UNDER the grid so grid lines read on top. A single unlit - // plane spanning the bed; drawn slightly below z=0 to avoid z-fighting the grid and the first + // plane/shape spanning the bed; drawn slightly below z=0 to avoid z-fighting the grid and the first // layer. `mode:'none'` (default) skips it entirely — the bare-grid look is unchanged. const surface = style?.bedSurface; if (surface && surface.mode === 'solid') { - group.add(bedPlate(x0, y0, x1, y1, surface)); + group.add(outline ? bedShapeMesh(outline, surface) : bedPlate(x0, y0, x1, y1, surface)); } - // Floor grid at z=0 spanning [min..min+size]: the bed occupies the same - // coordinates the G-code (and the toolpath) uses. - const grid: number[] = []; - for (let gx = x0; gx <= x1 + 1e-6; gx += pitch) { - grid.push(gx, y0, 0, gx, y1, 0); - } - for (let gy = y0; gy <= y1 + 1e-6; gy += pitch) { - grid.push(x0, gy, 0, x1, gy, 0); + if (outline) { + // Non-rectangular bed: draw only the grid lines inside the outline, plus the outline itself, so the + // printable area reads honestly (a round bed is not a square with a circle drawn on it). + group.add(lines(clipGridToPolygon(x0, y0, x1, y1, pitch, outline), gridColor, gridOpacity)); + group.add(bedOutlineLoop(outline, gridColor, Math.min(1, gridOpacity + 0.3))); + } else { + // Rectangular bed at z=0 spanning [min..min+size]: the bed occupies the same coordinates the G-code + // (and the toolpath) uses. Unchanged from before DD-030 — byte-identical. + const grid: number[] = []; + for (let gx = x0; gx <= x1 + 1e-6; gx += pitch) { + grid.push(gx, y0, 0, gx, y1, 0); + } + for (let gy = y0; gy <= y1 + 1e-6; gy += pitch) { + grid.push(x0, gy, 0, x1, gy, 0); + } + group.add(lines(grid, gridColor, gridOpacity)); } - group.add(lines(grid, gridColor, gridOpacity)); // Volume wireframe box. const z = def.z; @@ -154,6 +180,134 @@ function bedPlate(x0: number, y0: number, x1: number, y1: number, surface: BedSu return mesh; } +/** Number of segments a circular bed is polygonized into (smooth enough at plate scale). */ +const CIRCLE_SEGMENTS = 96; + +/** + * The bed outline as a closed polygon in printer XY, or `null` for a rectangular bed (the caller then + * takes the original rectangular path — byte-identical). A circle is polygonized; a polygon bed is used + * as-is (needs ≥3 points, else `null` → falls back to the rectangle honestly). + */ +function bedOutlinePoints(def: BuildVolumeDef): Point2[] | null { + const shape = def.shape; + if (!shape || shape.kind === 'rect') return null; + if (shape.kind === 'circular') { + const r = shape.diameter / 2; + const pts: Point2[] = []; + for (let i = 0; i < CIRCLE_SEGMENTS; i++) { + const a = (i / CIRCLE_SEGMENTS) * Math.PI * 2; + pts.push({ x: shape.center.x + r * Math.cos(a), y: shape.center.y + r * Math.sin(a) }); + } + return pts; + } + // polygon + if (shape.points.length < 3) return null; + return shape.points.map((p) => ({ x: p.x, y: p.y })); +} + +/** Ray-cast point-in-polygon (handles convex and concave outlines). */ +function pointInPolygon(x: number, y: number, poly: Point2[]): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i].x; + const yi = poly[i].y; + const xj = poly[j].x; + const yj = poly[j].y; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** + * Clip the rectangular grid to the bed outline: for each grid line, keep only the sub-segments whose + * midpoint lies inside the polygon. Handles concave outlines. Returns flat `LineSegments` positions. + */ +function clipGridToPolygon(x0: number, y0: number, x1: number, y1: number, pitch: number, poly: Point2[]): number[] { + const out: number[] = []; + const addClipped = (ax: number, ay: number, bx: number, by: number): void => { + // Parameter values (0..1 along A→B) where the segment crosses a polygon edge, plus the endpoints. + const ts = [0, 1]; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const t = segmentEdgeT(ax, ay, bx, by, poly[j].x, poly[j].y, poly[i].x, poly[i].y); + if (t !== null) ts.push(t); + } + ts.sort((p, q) => p - q); + for (let k = 0; k + 1 < ts.length; k++) { + const t0 = ts[k]; + const t1 = ts[k + 1]; + if (t1 - t0 < 1e-9) continue; + const mt = (t0 + t1) / 2; + const mx = ax + (bx - ax) * mt; + const my = ay + (by - ay) * mt; + if (pointInPolygon(mx, my, poly)) { + out.push(ax + (bx - ax) * t0, ay + (by - ay) * t0, 0, ax + (bx - ax) * t1, ay + (by - ay) * t1, 0); + } + } + }; + for (let gx = x0; gx <= x1 + 1e-6; gx += pitch) addClipped(gx, y0, gx, y1); + for (let gy = y0; gy <= y1 + 1e-6; gy += pitch) addClipped(x0, gy, x1, gy); + return out; +} + +/** The A→B parameter where segment AB crosses segment CD, or null if they don't cross in-range. */ +function segmentEdgeT( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + dx: number, + dy: number +): number | null { + const rx = bx - ax; + const ry = by - ay; + const sx = dx - cx; + const sy = dy - cy; + const denom = rx * sy - ry * sx; + if (Math.abs(denom) < 1e-12) return null; // parallel + const t = ((cx - ax) * sy - (cy - ay) * sx) / denom; + const u = ((cx - ax) * ry - (cy - ay) * rx) / denom; + if (t <= 0 || t >= 1 || u < 0 || u > 1) return null; + return t; +} + +/** A filled, unlit shape spanning a non-rectangular bed outline, drawn just below z=0. Named 'bedSurface'. */ +function bedShapeMesh(poly: Point2[], surface: BedSurface): Mesh { + const shape = new Shape(); + shape.moveTo(poly[0].x, poly[0].y); + for (let i = 1; i < poly.length; i++) shape.lineTo(poly[i].x, poly[i].y); + shape.closePath(); + const geom = new ShapeGeometry(shape); + geom.translate(0, 0, -0.05); + const material = new MeshBasicMaterial({ + color: surface.color ?? 0x2a2f3a, + opacity: surface.opacity ?? 1, + transparent: (surface.opacity ?? 1) < 1, + side: DoubleSide, + depthWrite: false + }); + if (surface.texture) { + material.map = new CanvasTexture(surface.texture); + material.needsUpdate = true; + } + const mesh = new Mesh(geom, material); + mesh.name = 'bedSurface'; + mesh.renderOrder = -1; + return mesh; +} + +/** The bed boundary as a closed loop at z≈0. Named 'bedOutline'. */ +function bedOutlineLoop(poly: Point2[], color: ThemeColor, opacity: number): LineLoop { + const pts: number[] = []; + for (const p of poly) pts.push(p.x, p.y, 0.01); + const geom = new BufferGeometry(); + geom.setAttribute('position', new Float32BufferAttribute(pts, 3)); + const loop = new LineLoop(geom, new LineBasicMaterial({ color, transparent: opacity < 1, opacity })); + loop.name = 'bedOutline'; + return loop; +} + /** An amber outline of a keep-out region at z≈0, or null for a degenerate region. */ function excludedRegionOutline(region: Region2): LineLoop | null { const pts: number[] = []; diff --git a/packages/gcode-renderer-three/src/capture.ts b/packages/gcode-renderer-three/src/capture.ts new file mode 100644 index 00000000..9d42f554 --- /dev/null +++ b/packages/gcode-renderer-three/src/capture.ts @@ -0,0 +1,94 @@ +/** + * Interactive view capture (DD-030 D1). Turn "what is on screen right now" into an image `Blob` — + * for a user-selected thumbnail, a large-file fallback, a screenshot, any consumer that needs the + * currently displayed view. The real capture (render-to-target + readback) lives on `InteractiveStage` + * where the GL/scene/camera are; this module holds the vendor-neutral option type and the pure, + * GL-free helpers (row flip, RGBA→Blob encode) so they are unit-testable without a WebGL context. + * + * Design (DD-030 D1): capture renders the current scene + active camera into an **off-screen render + * target** and reads it back, rather than flipping the interactive context's `preserveDrawingBuffer` + * (which taxes every interactive frame). That gives an arbitrary output size and an independent / + * transparent background without disturbing the live view, and reuses the headless still path's + * "single render, then read pixels" recipe. The library returns the `Blob`; it never triggers a + * download (the caller owns the pixels, same contract as `renderStill`). + */ +import type { ThemeColor } from './theme.js'; + +/** Thrown when `capture()` cannot run — no render-to-target support (stub GL / no WebGL), or the + * stage is disposed / its context is lost. Carries a stable `code` for consumer branching. */ +export class CaptureUnsupportedError extends Error { + readonly code = 'E_CAPTURE_UNSUPPORTED'; + constructor(message: string) { + super(message); + this.name = 'CaptureUnsupportedError'; + } +} + +/** Options for an interactive view capture. All optional; sensible defaults match the live view. */ +export interface CaptureOptions { + /** Output width in pixels (default: the current drawing-buffer width). */ + width?: number; + /** Output height in pixels (default: the current drawing-buffer height). */ + height?: number; + /** Encoded image type (default `'image/png'`). */ + format?: 'image/png' | 'image/jpeg' | 'image/webp'; + /** Quality 0..1 for lossy formats (`jpeg`/`webp`); ignored for `png`. */ + quality?: number; + /** Background for the capture: `'transparent'`, or a solid color. Default: the live scene background. */ + background?: 'transparent' | ThemeColor; +} + +/** Resolve the capture pixel size from options + the live drawing-buffer size (both clamped to ≥1). */ +export function resolveCaptureSize(opts: CaptureOptions, bufferW: number, bufferH: number): { w: number; h: number } { + const w = Math.max(1, Math.round(opts.width ?? bufferW)); + const h = Math.max(1, Math.round(opts.height ?? bufferH)); + return { w, h }; +} + +/** + * WebGL `readRenderTargetPixels` returns rows bottom-to-top; canvas `ImageData` is top-to-bottom. + * Return a vertically-flipped copy of the RGBA buffer. Pure — the load-bearing, testable half of capture. + */ +export function flipRowsRGBA(src: Uint8Array, width: number, height: number): Uint8Array { + const stride = width * 4; + const out = new Uint8Array(src.length); + for (let y = 0; y < height; y++) { + const from = y * stride; + const to = (height - 1 - y) * stride; + out.set(src.subarray(from, from + stride), to); + } + return out; +} + +/** + * Encode a top-down RGBA buffer into an image `Blob` via a 2D canvas. Uses `OffscreenCanvas` when + * available (workers + modern browsers), else a DOM ``. Browser/worker only — there is no + * canvas 2D encoder in bare Node, so this is exercised in the browser, not unit tests. + */ +export async function encodeRGBAToBlob( + rgba: Uint8Array, + width: number, + height: number, + format: string, + quality?: number +): Promise { + const canvas = makeCanvas2D(width, height); + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; + if (ctx === null) throw new Error('capture: 2D canvas context unavailable'); + const clamped = new Uint8ClampedArray(rgba.buffer, rgba.byteOffset, rgba.byteLength); + ctx.putImageData(new ImageData(clamped, width, height), 0, 0); + if ('convertToBlob' in canvas) { + return canvas.convertToBlob({ type: format, quality }); + } + return new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('capture: toBlob returned null'))), format, quality); + }); +} + +function makeCanvas2D(width: number, height: number): OffscreenCanvas | HTMLCanvasElement { + if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(width, height); + const c = document.createElement('canvas'); + c.width = width; + c.height = height; + return c; +} diff --git a/packages/gcode-renderer-three/src/index.ts b/packages/gcode-renderer-three/src/index.ts index e22c3471..414c3ad5 100644 --- a/packages/gcode-renderer-three/src/index.ts +++ b/packages/gcode-renderer-three/src/index.ts @@ -35,7 +35,9 @@ export type { CameraState } from './scene.js'; export { createBuildVolume } from './build-volume.js'; -export type { BuildVolumeDef, BuildVolumeStyle } from './build-volume.js'; +export type { BuildVolumeDef, BuildVolumeStyle, BedShape } from './build-volume.js'; +export { CaptureUnsupportedError } from './capture.js'; +export type { CaptureOptions } from './capture.js'; export { framingFromCenterRadius, createDefaultGLRenderer } from './stage.js'; export type { Framing, DefaultGLOptions } from './stage.js'; export { InteractionQualityController } from './interaction-quality.js'; diff --git a/packages/gcode-renderer-three/src/interactive-stage.ts b/packages/gcode-renderer-three/src/interactive-stage.ts index 06e12ff0..825f92d6 100644 --- a/packages/gcode-renderer-three/src/interactive-stage.ts +++ b/packages/gcode-renderer-three/src/interactive-stage.ts @@ -9,15 +9,23 @@ * The camera types live here (not in the toolpath renderer) so the stage has no dependency on * toolpath concepts; the toolpath renderer re-exports them for import-path stability. */ -import { OrthographicCamera, PerspectiveCamera, Scene, Vector3 } from 'three'; +import { Color, OrthographicCamera, PerspectiveCamera, Scene, Vector3, WebGLRenderTarget } from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { Vec3 } from '@chestnutlabs/toolpath-core'; import { createDefaultGLRenderer, framingFromCenterRadius, + supportsCapture, type GLRendererLike, type RenderTargetCanvas } from './stage.js'; +import { + CaptureUnsupportedError, + encodeRGBAToBlob, + flipRowsRGBA, + resolveCaptureSize, + type CaptureOptions +} from './capture.js'; import { InteractionQualityController } from './interaction-quality.js'; /** @@ -334,6 +342,52 @@ export class InteractiveStage { this.gl.render(this.scene, this.activeCameraRef); } + /** + * Capture the currently displayed view as an image `Blob` (DD-030 D1). Renders the current scene + + * active camera into an off-screen target at the requested size (so it never disturbs the live view + * nor requires `preserveDrawingBuffer`), reads it back, and encodes it. Rejects with an + * `E_CAPTURE_UNSUPPORTED` error when the renderer cannot render-to-target (a stub GL / no WebGL) or the + * stage is disposed / context-lost. The caller owns the returned `Blob` — the library never downloads. + */ + async capture(opts: CaptureOptions = {}): Promise { + if (this.disposedFlag || this.contextLost || !supportsCapture(this.gl)) { + const why = this.disposedFlag + ? 'stage disposed' + : this.contextLost + ? 'WebGL context lost' + : 'renderer cannot render-to-target'; + throw new CaptureUnsupportedError(`capture unavailable: ${why}`); + } + const gl = this.gl; + const { w, h } = resolveCaptureSize(opts, this.canvas.width, this.canvas.height); + const target = new WebGLRenderTarget(w, h); + // Frame the capture at its OWN aspect (not the viewport's) so a differently-shaped thumbnail is not + // distorted; reuse the stage's projection machinery, then restore the live aspect. + const prevAspect = this.aspect; + const prevBackground = this.scene.background; + const overrideBackground = opts.background !== undefined; + try { + if (overrideBackground) { + this.scene.background = + opts.background === 'transparent' ? null : new Color(opts.background as string | number); + } + this.aspect = w / h; + this.updateCameraProjection(); + gl.setRenderTarget(target); + gl.render(this.scene, this.activeCameraRef); + const buffer = new Uint8Array(w * h * 4); + gl.readRenderTargetPixels(target, 0, 0, w, h, buffer); + gl.setRenderTarget(null); + return await encodeRGBAToBlob(flipRowsRGBA(buffer, w, h), w, h, opts.format ?? 'image/png', opts.quality); + } finally { + target.dispose(); + if (overrideBackground) this.scene.background = prevBackground; + this.aspect = prevAspect; + this.updateCameraProjection(); + this.render(); // repaint the live canvas (we redirected the renderer to a target) + } + } + dispose(): void { if (this.disposedFlag) return; this.disposedFlag = true; diff --git a/packages/gcode-renderer-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index 96220f55..e46402dc 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -63,6 +63,7 @@ import type { GeometryBuildRequest, GeometryBuildResponse } from './geometry-wor import { estimateTubeBuildMs, POOL_ENGAGE_MS, SINGLE_REVEAL_MS } from './render-cost.js'; import { resolveTheme, type Theme, type ResolvedTheme } from './theme.js'; import { InteractiveStage, type CameraMode, type CameraView, type CameraState } from './interactive-stage.js'; +import type { CaptureOptions } from './capture.js'; /** §4.3 quality tiers. `auto` picks by segment count (chooseQuality). */ export type QualityMode = 'lines' | 'tubes'; @@ -258,7 +259,9 @@ export function machineToVolume(m: MachineGeometry): BuildVolumeDef { y: bed.diameter, z: m.heightMm ?? 250, min: { x: bed.center.x - r, y: bed.center.y - r }, - excludedRegions + excludedRegions, + // DD-030 D3: draw the true round outline instead of collapsing it to the bounding square. + shape: { kind: 'circular', center: { ...bed.center }, diameter: bed.diameter } }; } let minX = Infinity; @@ -271,7 +274,15 @@ export function machineToVolume(m: MachineGeometry): BuildVolumeDef { maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } - return { x: maxX - minX, y: maxY - minY, z: m.heightMm ?? 250, min: { x: minX, y: minY }, excludedRegions }; + return { + x: maxX - minX, + y: maxY - minY, + z: m.heightMm ?? 250, + min: { x: minX, y: minY }, + excludedRegions, + // DD-030 D3: draw the true (delta/irregular) outline instead of its bounding rectangle. + shape: { kind: 'polygon', points: bed.points.map((p) => ({ x: p.x, y: p.y })) } + }; } /** §8-derived tick budget: half the 16 ms stall budget, leaving frame headroom. */ @@ -1432,6 +1443,11 @@ export class ToolpathRenderer { this.stage.setCameraState(state); } + /** Capture the current view as an image `Blob` (DD-030 D1) — delegates to the shared stage. */ + capture(opts?: CaptureOptions): Promise { + return this.stage.capture(opts); + } + /** * Choose the bounds to frame to (#306/#6, DD-026 D5). `frameContent: 'object'` frames the printed * **model**, excluding skirt/prime/purge/tower, using the classifier's `modelBounds` when available, diff --git a/packages/gcode-renderer-three/src/stage.ts b/packages/gcode-renderer-three/src/stage.ts index e7372d94..6e682a4d 100644 --- a/packages/gcode-renderer-three/src/stage.ts +++ b/packages/gcode-renderer-three/src/stage.ts @@ -9,7 +9,7 @@ * It is grown incrementally and each move is gated by the toolpath renderer's existing golden/visual * parity (the extraction must be behavior-preserving for the toolpath side). */ -import { Camera, Scene, Vector3, WebGLRenderer } from 'three'; +import { Camera, Scene, Vector3, WebGLRenderer, type WebGLRenderTarget } from 'three'; /** * Render target: a DOM canvas (interactive hosts) or an OffscreenCanvas (workers / headless @@ -26,6 +26,24 @@ export interface GLRendererLike { setPixelRatio?(ratio: number): void; dispose(): void; domElement: RenderTargetCanvas; + // Render-target readback for interactive `capture()` (DD-030 D1). Optional: a real `WebGLRenderer` + // provides both; a stub GL (tests / no WebGL) provides neither, so `capture()` reports unsupported. + setRenderTarget?(target: WebGLRenderTarget | null): void; + readRenderTargetPixels?( + target: WebGLRenderTarget, + x: number, + y: number, + width: number, + height: number, + buffer: Uint8Array + ): void; +} + +/** True when the renderer can render-to-target + read back — the requirement for `capture()`. */ +export function supportsCapture( + gl: GLRendererLike +): gl is GLRendererLike & Required> { + return typeof gl.setRenderTarget === 'function' && typeof gl.readRenderTargetPixels === 'function'; } /** Options for {@link createDefaultGLRenderer}. */ diff --git a/packages/toolpath-core/CHANGELOG.md b/packages/toolpath-core/CHANGELOG.md index 7470e41c..ebc482a1 100644 --- a/packages/toolpath-core/CHANGELOG.md +++ b/packages/toolpath-core/CHANGELOG.md @@ -1,5 +1,7 @@ # @chestnutlabs/toolpath-core +## 0.18.0 + ## 0.17.0 ### Minor Changes diff --git a/packages/toolpath-core/package.json b/packages/toolpath-core/package.json index 66011c5d..7e8414ee 100644 --- a/packages/toolpath-core/package.json +++ b/packages/toolpath-core/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/toolpath-core", - "version": "0.17.0", + "version": "0.18.0", "description": "Neutral ToolpathIR and capability model for the Chestnut Labs G-code Preview toolpath stack.", "keywords": [ "gcode", diff --git a/test-data/golden-native/container-adv-bad-crc.json b/test-data/golden-native/container-adv-bad-crc.json index 40f194b4..95af5a91 100644 --- a/test-data/golden-native/container-adv-bad-crc.json +++ b/test-data/golden-native/container-adv-bad-crc.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/container-adv-duplicate-plate.json b/test-data/golden-native/container-adv-duplicate-plate.json index 40f194b4..95af5a91 100644 --- a/test-data/golden-native/container-adv-duplicate-plate.json +++ b/test-data/golden-native/container-adv-duplicate-plate.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/container-adv-encrypted.json b/test-data/golden-native/container-adv-encrypted.json index 40f194b4..95af5a91 100644 --- a/test-data/golden-native/container-adv-encrypted.json +++ b/test-data/golden-native/container-adv-encrypted.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/container-adv-header-mismatch.json b/test-data/golden-native/container-adv-header-mismatch.json index 40f194b4..95af5a91 100644 --- a/test-data/golden-native/container-adv-header-mismatch.json +++ b/test-data/golden-native/container-adv-header-mismatch.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/container-adv-size-lie.json b/test-data/golden-native/container-adv-size-lie.json index 40f194b4..95af5a91 100644 --- a/test-data/golden-native/container-adv-size-lie.json +++ b/test-data/golden-native/container-adv-size-lie.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/container-adv-traversal-names.json b/test-data/golden-native/container-adv-traversal-names.json index d84b56d9..6dba043c 100644 --- a/test-data/golden-native/container-adv-traversal-names.json +++ b/test-data/golden-native/container-adv-traversal-names.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/container-adv-truncated.json b/test-data/golden-native/container-adv-truncated.json index 40f194b4..28f11c2c 100644 --- a/test-data/golden-native/container-adv-truncated.json +++ b/test-data/golden-native/container-adv-truncated.json @@ -21,13 +21,15 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "known", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable" + "layers-unavailable", + "rs274-bad-expression" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-mini-project.json b/test-data/golden-native/container-mini-project.json index 40f194b4..28f11c2c 100644 --- a/test-data/golden-native/container-mini-project.json +++ b/test-data/golden-native/container-mini-project.json @@ -21,13 +21,15 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "known", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable" + "layers-unavailable", + "rs274-bad-expression" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/container-model-project.json b/test-data/golden-native/container-model-project.json index 40f194b4..95af5a91 100644 --- a/test-data/golden-native/container-model-project.json +++ b/test-data/golden-native/container-model-project.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/demo-3dbenchy.json b/test-data/golden-native/demo-3dbenchy.json index 5f5d28af..a697c724 100644 --- a/test-data/golden-native/demo-3dbenchy.json +++ b/test-data/golden-native/demo-3dbenchy.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 8a3c7803..b995bbda 100644 --- a/test-data/golden-native/demo-calicat.json +++ b/test-data/golden-native/demo-calicat.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 0ce6293a..5039271f 100644 --- a/test-data/golden-native/demo-easel.json +++ b/test-data/golden-native/demo-easel.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "known", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "known", "arcPlanes": "inferred", diff --git a/test-data/golden-native/demo-mach3.json b/test-data/golden-native/demo-mach3.json index ae252351..c0470680 100644 --- a/test-data/golden-native/demo-mach3.json +++ b/test-data/golden-native/demo-mach3.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "known", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "known", "arcPlanes": "inferred", diff --git a/test-data/golden-native/demo-plant-sign.json b/test-data/golden-native/demo-plant-sign.json index 92229bab..2ade8042 100644 --- a/test-data/golden-native/demo-plant-sign.json +++ b/test-data/golden-native/demo-plant-sign.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 a970f573..7f5aef67 100644 --- a/test-data/golden-native/demo-screw.json +++ b/test-data/golden-native/demo-screw.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 7cce8867..9dfda995 100644 --- a/test-data/golden-native/demo-vase.json +++ b/test-data/golden-native/demo-vase.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 cc285a04..47ca56b1 100644 --- a/test-data/golden-native/dialect-cura-style.json +++ b/test-data/golden-native/dialect-cura-style.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 811408f4..f8b87f59 100644 --- a/test-data/golden-native/dialect-klipper-prusa.json +++ b/test-data/golden-native/dialect-klipper-prusa.json @@ -21,13 +21,15 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "known", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-partially-inferred" + "layers-partially-inferred", + "rs274-bad-expression" ], "bufferDigests": { "x0": "63aaf591", diff --git a/test-data/golden-native/dialect-marlin-m486.json b/test-data/golden-native/dialect-marlin-m486.json index a5097e08..bdb70ea1 100644 --- a/test-data/golden-native/dialect-marlin-m486.json +++ b/test-data/golden-native/dialect-marlin-m486.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 d093a9c9..bec16bd9 100644 --- a/test-data/golden-native/dialect-multitool-ams.json +++ b/test-data/golden-native/dialect-multitool-ams.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 692cecb6..757ec7b6 100644 --- a/test-data/golden-native/dialect-orca-bambu-style.json +++ b/test-data/golden-native/dialect-orca-bambu-style.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 35c208d8..50504fdb 100644 --- a/test-data/golden-native/dialect-prusa-style.json +++ b/test-data/golden-native/dialect-prusa-style.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 f9845565..f9d48f23 100644 --- a/test-data/golden-native/dialect-reprap-style.json +++ b/test-data/golden-native/dialect-reprap-style.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "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 40f194b4..95af5a91 100644 --- a/test-data/golden-native/fuzz-deflate-corrupt-stream.json +++ b/test-data/golden-native/fuzz-deflate-corrupt-stream.json @@ -21,6 +21,7 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "unavailable", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", diff --git a/test-data/golden-native/progress-anycubic-percent.json b/test-data/golden-native/progress-anycubic-percent.json index 40f194b4..28f11c2c 100644 --- a/test-data/golden-native/progress-anycubic-percent.json +++ b/test-data/golden-native/progress-anycubic-percent.json @@ -21,13 +21,15 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "known", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable" + "layers-unavailable", + "rs274-bad-expression" ], "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 40f194b4..28f11c2c 100644 --- a/test-data/golden-native/progress-bambu-percent-layer.json +++ b/test-data/golden-native/progress-bambu-percent-layer.json @@ -21,13 +21,15 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "known", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable" + "layers-unavailable", + "rs274-bad-expression" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/test-data/golden-native/progress-byte-exact.json b/test-data/golden-native/progress-byte-exact.json index 40f194b4..28f11c2c 100644 --- a/test-data/golden-native/progress-byte-exact.json +++ b/test-data/golden-native/progress-byte-exact.json @@ -21,13 +21,15 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "known", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable" + "layers-unavailable", + "rs274-bad-expression" ], "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 40f194b4..28f11c2c 100644 --- a/test-data/golden-native/progress-klipper-byte-fraction.json +++ b/test-data/golden-native/progress-klipper-byte-fraction.json @@ -21,13 +21,15 @@ "seamMoves": "unavailable", "cutMoves": "unavailable", "cannedCycles": "unavailable", + "parametricProgram": "known", "extrusionMode": "inferred", "positioningMode": "inferred", "arcPlanes": "inferred", "coordinateSystem": "inferred" }, "warningCodes": [ - "layers-unavailable" + "layers-unavailable", + "rs274-bad-expression" ], "bufferDigests": { "x0": "811c9dc5", diff --git a/tools/pack-check-snapshots.json b/tools/pack-check-snapshots.json index b8c89bbd..e7242db2 100644 --- a/tools/pack-check-snapshots.json +++ b/tools/pack-check-snapshots.json @@ -132,6 +132,12 @@ "dist/protocol.d.ts", "dist/protocol.d.ts.map", "dist/protocol.js", + "dist/rs274-flow.d.ts", + "dist/rs274-flow.d.ts.map", + "dist/rs274-flow.js", + "dist/rs274.d.ts", + "dist/rs274.d.ts.map", + "dist/rs274.js", "dist/session.d.ts", "dist/session.d.ts.map", "dist/session.js", @@ -157,6 +163,9 @@ "dist/capability.d.ts", "dist/capability.d.ts.map", "dist/capability.js", + "dist/capture.d.ts", + "dist/capture.d.ts.map", + "dist/capture.js", "dist/chunks.d.ts", "dist/chunks.d.ts.map", "dist/chunks.js",