diff --git a/README.md b/README.md index cbe7f170..9becbdbc 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ A worker-based, cross-vendor **G-code toolpath stack** for the browser: parse `.gcode`, `.gcode.3mf`, and Prusa binary `.bgcode` off the main thread, normalize them into a versioned intermediate representation (`ToolpathIR`), and render an interactive Three.js (or low-resource -Canvas 2D) preview — with first-class **Vue, React, and Svelte** integrations that are thin adapters -over one shared, framework-neutral engine. +Canvas 2D) preview — with first-class **Vue, React, Svelte, and a framework-neutral Web Component** +integrations that are thin adapters over one shared engine. > **Status: published.** Thirteen `@chestnutlabs/*` packages are on npm (latest **`v0.4.0`**, > lockstep-versioned with npm provenance). E0–E11 of the @@ -49,9 +49,10 @@ over one shared, framework-neutral engine. mapped onto the toolpath with tiered confidence: a precise cut + marker when the source position is known, an uncertainty band when it is approximated, stale-signal handling, and user scrub always winning. -- **Three equal framework adapters** — each ships a ready-to-use `` component *and* +- **Four equal framework adapters** — Vue, React, Svelte, and a framework-neutral `` + Web Component (`@chestnutlabs/gcode-preview-element`) — each ships a ready-to-use component *and* a lower-level surface, with the same capabilities, options, events, and TypeScript contracts, - enforced by a shared behavioral suite that runs against all three in CI. + enforced by a shared behavioral suite that runs against all four in CI. | Honest live progress | Layer clipping & scrub | |---|---| @@ -143,9 +144,10 @@ Ships as raw `.svelte` (your bundler's Svelte plugin compiles it). Lower level: [`createGcodePreview()`](packages/gcode-preview-svelte/README.md) — store contract + `use:` canvas action. -All three components share the same defaulted prop surface — `source`, `parseOptions`, +All four adapters share the same defaulted option surface — `source`, `parseOptions`, `buildVolume`, `quality`, `colorMode`, `layerRange`, `scrub`, `showTravel`, `progress`, -`createWorker` — with matching events/callbacks. `` is the whole +`createWorker` — with matching events/callbacks (the Web Component exposes them as attributes / +properties). `` is the whole thin path; the full viewer is reachable without switching APIs. ## Workers: batteries included, escape hatch provided diff --git a/docs/compatibility/dialects-and-containers.md b/docs/compatibility/dialects-and-containers.md index 5fd1a00c..fd909804 100644 --- a/docs/compatibility/dialects-and-containers.md +++ b/docs/compatibility/dialects-and-containers.md @@ -31,14 +31,15 @@ annotation) · **unsupported** (generic parse only — geometry always works; me ## Dialects (non-extrusion — CNC / laser / plotter, DD-012 #189) Non-extrusion controllers. Each declares a **validation tier** (DD-012 D6): until a controller is -confirmed on real hardware, its non-extrusion claims are reported **`inferred`** (experimental), never -`known` — the tier *is* the honesty mechanism. Geometry (positions, arcs, drilled holes) always parses +confirmed on real hardware, its non-extrusion claims are reported **`inferred`** (experimental); once +validated on a real machine they are promoted to **`known`** and the disclosure warning drops — the tier +*is* the honesty mechanism. Geometry (positions, arcs, drilled holes) always parses regardless of tier; the tier governs only how much to trust the *semantic* classification. The underlying capabilities are in [Cross-cutting coverage](#cross-cutting-coverage) below. | Controller | Detection | Machine class | Validation tier | Non-extrusion claims | Fixtures | Evidence date | |---|---|---|---|---|---|---| -| GRBL laser | LightBurn header / `$32=1` laser mode / `M4`+`S`, no extrusion | laser | **experimental** | `cutMoves` · `toolPower` (laser power) · `cannedCycles` — reported **`inferred`** until hardware-validated | synthetic | 2026-07-28 | +| GRBL laser | LightBurn header / `$32=1` laser mode / `M4`+`S`, no extrusion | laser | **validated** | `cutMoves` · `toolPower` (laser power) · `cannedCycles` — reported **`known`** (hardware-validated, [DD-012 log](../design/DD-012-hardware-validation-log.md)) | synthetic + real GRBL/LightBurn run | 2026-07-29 | | GRBL mill | `Grbl` banner + `M3` spindle, no extrusion | mill | **experimental** | `cutMoves` · `toolPower` (spindle RPM) · `cannedCycles` — **`inferred`** | synthetic | 2026-07-28 | | LinuxCNC / EMC | `LinuxCNC`/`EMC` header / `%`-program + `M3` | mill | **experimental** | as above — **`inferred`** | synthetic | 2026-07-28 | | Marlin-laser / Mach / Smoothieware | _reserved_ | laser/mill | _pending_ | generic parse only until added | — | — | @@ -54,8 +55,8 @@ are proprietary binary formats, not G-code. | Container | Discovery | Plates | Machine metadata | Integrity checks | Security review | Fixtures | Evidence date | |---|---|---|---|---|---|---|---| -| `.gcode.3mf` (Orca/Bambu) | **full** (magic sniff + CD walk) | **full** (multi-plate lifecycle, `{plate}` select, default-0 + warning) | **full** (`printable_area`/`printable_height`/printer/filaments → `MachineGeometry`, `known`) | **full** (CRC32, header agreement, encryption/zip64 rejection, duplicates, incremental caps) | [record prepared — awaiting sign-off](../design/SECURITY-REVIEW-DD-005-containers.md) | `container-mini-project` + 7 adversarial | 2026-07-23 | -| `.bgcode` (Prusa binary) | **full** (`GCDE` magic sniff) | single plate | **partial** (`bed_shape` INI → `MachineGeometry`, `inferred`) | **full** (per-block CRC32; bounded, capped decode; DEFLATE flavor-lock) | [record prepared — awaiting sign-off](../design/SECURITY-REVIEW-DD-011-bgcode.md) | `prim-cube` golden pair + adversarial fuzz corpus | 2026-07-27 | +| `.gcode.3mf` (Orca/Bambu) | **full** (magic sniff + CD walk) | **full** (multi-plate lifecycle, `{plate}` select, default-0 + warning) | **full** (`printable_area`/`printable_height`/printer/filaments → `MachineGeometry`, `known`) | **full** (CRC32, header agreement, encryption/zip64 rejection, duplicates, incremental caps) | [**signed off** 2026-07-23](../design/SECURITY-REVIEW-DD-005-containers.md) | `container-mini-project` + 7 adversarial | 2026-07-23 | +| `.bgcode` (Prusa binary) | **full** (`GCDE` magic sniff) | single plate | **partial** (`bed_shape` INI → `MachineGeometry`, `inferred`) | **full** (per-block CRC32; bounded, capped decode; DEFLATE flavor-lock) | [**signed off** 2026-07-28](../design/SECURITY-REVIEW-DD-011-bgcode.md) | `prim-cube` golden pair + adversarial fuzz corpus | 2026-07-27 | ## Cross-cutting coverage diff --git a/docs/manual/recipes.md b/docs/manual/recipes.md index 6c610e2d..9d0b7efa 100644 --- a/docs/manual/recipes.md +++ b/docs/manual/recipes.md @@ -25,6 +25,45 @@ Clip to a layer range and scrub within it — no geometry rebuilds, just draw-ra `colorMode` is capability-gated — the stack colors by a feature only when the dialect actually disclosed it (see [ToolpathIR & the capability model](concept-ir-capabilities.md)). +## Camera control: preset views & saved state + +The imperative `controls` handle (the composable return / React ref / Svelte `bind:this` / element +instance) exposes preset orientations and a serializable camera snapshot: + +```ts +const { controls } = preview; // or handleRef.current, etc. + +controls.setView('iso'); // 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso' +controls.frame(); // re-fit to the model bounds +controls.setCameraMode('orthographic'); // perspective ↔ ortho + +// Persist "where the user was looking" and restore it later (e.g. a dashboard). +const view = controls.getCameraState(); // { position, target, zoom, cameraMode } | null +localStorage.setItem('view', JSON.stringify(view)); +controls.setCameraState(view); // restores verbatim — no re-fit to the current model +``` + +`setView` snaps instantly (no animation) and preserves the active projection. `getCameraState` +returns `null` on the low-resource 2D renderer, which has no 3D pose — `setView`/`setCameraState` +there disclose via the `renderer-unsupported` event rather than fabricating one. + +The same three are also **declarative props** on every adapter — a `view` prop (preset) and a +`cameraState` prop (restore) — paired with a **`camerachange`/`camera-change`** event that fires with +the new `CameraState` after a user orbits/pans/zooms. Together they form a two-way binding you can +persist: + +```svelte + save(e.detail)} /> +``` + +## Capabilities & warnings on `ready` + +The `ready` event (React `onReady`, Vue `@ready`, Svelte `on:ready`, element `ready`) carries +`capabilities` (the per-field `known | inferred | approximated | unavailable` map) and `warnings` +alongside `{ segments, layers, complete }` — so a consumer can gate its own UI (e.g. only offer a +color-by-power control when `capabilities.toolPower !== 'unavailable'`) without reaching for the raw +handle. + ## `.gcode.3mf` multi-plate `.gcode.3mf` containers can hold several sliced plates. Select one with `parseOptions.plate`: diff --git a/package-lock.json b/package-lock.json index 8d984506..d17cd5b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7385,11 +7385,11 @@ }, "packages/gcode-bgcode": { "name": "@chestnutlabs/gcode-bgcode", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-containers": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-containers": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7397,10 +7397,10 @@ }, "packages/gcode-colors": { "name": "@chestnutlabs/gcode-colors", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7408,10 +7408,10 @@ }, "packages/gcode-containers": { "name": "@chestnutlabs/gcode-containers", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7419,10 +7419,10 @@ }, "packages/gcode-dialects": { "name": "@chestnutlabs/gcode-dialects", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7430,13 +7430,13 @@ }, "packages/gcode-parser": { "name": "@chestnutlabs/gcode-parser", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-bgcode": "0.4.0", - "@chestnutlabs/gcode-containers": "0.4.0", - "@chestnutlabs/gcode-dialects": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-bgcode": "0.5.0", + "@chestnutlabs/gcode-containers": "0.5.0", + "@chestnutlabs/gcode-dialects": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7444,13 +7444,13 @@ }, "packages/gcode-preview-core": { "name": "@chestnutlabs/gcode-preview-core", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.4.0", - "@chestnutlabs/gcode-renderer-2d": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-renderer-2d": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7458,13 +7458,13 @@ }, "packages/gcode-preview-element": { "name": "@chestnutlabs/gcode-preview-element", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.4.0", - "@chestnutlabs/gcode-preview-core": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-preview-core": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7472,13 +7472,13 @@ }, "packages/gcode-preview-react": { "name": "@chestnutlabs/gcode-preview-react", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.4.0", - "@chestnutlabs/gcode-preview-core": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-preview-core": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "devDependencies": { "@types/react": "^18.3.0", @@ -7494,13 +7494,13 @@ }, "packages/gcode-preview-svelte": { "name": "@chestnutlabs/gcode-preview-svelte", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.4.0", - "@chestnutlabs/gcode-preview-core": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-preview-core": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "devDependencies": { "svelte": "^4.2.0" @@ -7514,13 +7514,13 @@ }, "packages/gcode-preview-vue": { "name": "@chestnutlabs/gcode-preview-vue", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-parser": "0.4.0", - "@chestnutlabs/gcode-preview-core": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-preview-core": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "devDependencies": { "vue": "^3.4.0" @@ -7534,11 +7534,11 @@ }, "packages/gcode-renderer-2d": { "name": "@chestnutlabs/gcode-renderer-2d", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-colors": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-colors": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "engines": { "node": ">=22" @@ -7546,11 +7546,11 @@ }, "packages/gcode-renderer-three": { "name": "@chestnutlabs/gcode-renderer-three", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@chestnutlabs/gcode-colors": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-colors": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "devDependencies": { "@types/three": "0.178.0" @@ -7564,7 +7564,7 @@ }, "packages/toolpath-core": { "name": "@chestnutlabs/toolpath-core", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "engines": { "node": ">=22" diff --git a/packages/gcode-bgcode/CHANGELOG.md b/packages/gcode-bgcode/CHANGELOG.md index 98247a5d..95385297 100644 --- a/packages/gcode-bgcode/CHANGELOG.md +++ b/packages/gcode-bgcode/CHANGELOG.md @@ -1,5 +1,13 @@ # @chestnutlabs/gcode-bgcode +## 0.5.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/gcode-containers@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/gcode-bgcode/package.json b/packages/gcode-bgcode/package.json index 124e0995..ffa0a5f1 100644 --- a/packages/gcode-bgcode/package.json +++ b/packages/gcode-bgcode/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-bgcode", - "version": "0.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/gcode-containers": "0.4.0" + "@chestnutlabs/toolpath-core": "0.5.0", + "@chestnutlabs/gcode-containers": "0.5.0" } } diff --git a/packages/gcode-colors/CHANGELOG.md b/packages/gcode-colors/CHANGELOG.md index 8199bd0d..fd28c2cb 100644 --- a/packages/gcode-colors/CHANGELOG.md +++ b/packages/gcode-colors/CHANGELOG.md @@ -1,5 +1,12 @@ # @chestnutlabs/gcode-colors +## 0.5.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Minor Changes diff --git a/packages/gcode-colors/package.json b/packages/gcode-colors/package.json index 37a414d1..c2f6a286 100644 --- a/packages/gcode-colors/package.json +++ b/packages/gcode-colors/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-colors", - "version": "0.4.0", + "version": "0.5.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.4.0" + "@chestnutlabs/toolpath-core": "0.5.0" } } diff --git a/packages/gcode-containers/CHANGELOG.md b/packages/gcode-containers/CHANGELOG.md index c619c592..11d54ff5 100644 --- a/packages/gcode-containers/CHANGELOG.md +++ b/packages/gcode-containers/CHANGELOG.md @@ -1,5 +1,12 @@ # @chestnutlabs/gcode-containers +## 0.5.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/gcode-containers/package.json b/packages/gcode-containers/package.json index b5cf4409..3836cf8c 100644 --- a/packages/gcode-containers/package.json +++ b/packages/gcode-containers/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-containers", - "version": "0.4.0", + "version": "0.5.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.4.0" + "@chestnutlabs/toolpath-core": "0.5.0" } } diff --git a/packages/gcode-dialects/CHANGELOG.md b/packages/gcode-dialects/CHANGELOG.md index cacaa069..c84da47e 100644 --- a/packages/gcode-dialects/CHANGELOG.md +++ b/packages/gcode-dialects/CHANGELOG.md @@ -1,5 +1,12 @@ # @chestnutlabs/gcode-dialects +## 0.5.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Minor Changes diff --git a/packages/gcode-dialects/package.json b/packages/gcode-dialects/package.json index 1955fda4..0f318876 100644 --- a/packages/gcode-dialects/package.json +++ b/packages/gcode-dialects/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-dialects", - "version": "0.4.0", + "version": "0.5.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.4.0" + "@chestnutlabs/toolpath-core": "0.5.0" } } diff --git a/packages/gcode-dialects/src/__tests__/cnc.test.ts b/packages/gcode-dialects/src/__tests__/cnc.test.ts index 71851393..67609dca 100644 --- a/packages/gcode-dialects/src/__tests__/cnc.test.ts +++ b/packages/gcode-dialects/src/__tests__/cnc.test.ts @@ -119,6 +119,13 @@ describe('DD-012 phase 3 — evidence-based detection of header-less real files expect(detected).toBe(true); // recognized as non-extrusion, not rejected as FDM by the bare `E` }); + it('GRBL `$32=1` laser-mode setting is a detection signal (#277/M5)', () => { + // The $32=1 branch (grbl-laser) was uncovered — it guards the honesty tier for header-less files. + const { ir, metadata } = cncParse('$32=1\nG21 G90\nM4 S0\nG1 X10 F600 S255\nS0\nM5\n'); + expect(ir.header.dialects.some((d) => d.id === 'grbl-laser')).toBe(true); + expect((metadata.raw as Record)['cnc.machineClass']).toBe('laser'); + }); + it('a commented-out spindle (`(M3)`) is not treated as tool-on', () => { // TinyG posts sometimes note `(M3)` in a comment with no active spindle — no tool-state to infer. const { detected } = cncParse('N1 T1M6\nN2 (M3)\nN3 G1 X10 Y0 F100\nN4 X20\n'); diff --git a/packages/gcode-parser/CHANGELOG.md b/packages/gcode-parser/CHANGELOG.md index 0880b56a..cf78fc77 100644 --- a/packages/gcode-parser/CHANGELOG.md +++ b/packages/gcode-parser/CHANGELOG.md @@ -1,5 +1,15 @@ # @chestnutlabs/gcode-parser +## 0.5.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/gcode-bgcode@0.5.0 + - @chestnutlabs/gcode-containers@0.5.0 + - @chestnutlabs/gcode-dialects@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Minor Changes diff --git a/packages/gcode-parser/package.json b/packages/gcode-parser/package.json index 453c24ef..e701d52e 100644 --- a/packages/gcode-parser/package.json +++ b/packages/gcode-parser/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-parser", - "version": "0.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/gcode-dialects": "0.4.0", - "@chestnutlabs/gcode-containers": "0.4.0", - "@chestnutlabs/gcode-bgcode": "0.4.0" + "@chestnutlabs/toolpath-core": "0.5.0", + "@chestnutlabs/gcode-dialects": "0.5.0", + "@chestnutlabs/gcode-containers": "0.5.0", + "@chestnutlabs/gcode-bgcode": "0.5.0" } } diff --git a/packages/gcode-parser/src/__tests__/cnc-adversarial.test.ts b/packages/gcode-parser/src/__tests__/cnc-adversarial.test.ts new file mode 100644 index 00000000..0aecaefb --- /dev/null +++ b/packages/gcode-parser/src/__tests__/cnc-adversarial.test.ts @@ -0,0 +1,84 @@ +/** + * CNC/laser adversarial hardening (#277, M1+M2). The v0.4.0 non-extrusion lexer + canned-cycle + * expansion (#189) was only happy-path tested. Hostile input must yield BOUNDED, partial IR — never + * a hang, crash, or unbounded expansion — through BOTH the in-memory and streaming drivers. Fixtures + * are synthetic inline strings (MIT-clean), never committed third-party files. + */ +import { describe, expect, it } from 'vitest'; +import { parseGcodeToIR, parseGcodeStreamToIR, type ParseOptions } from '../index'; + +const enc = (s: string): Uint8Array => new TextEncoder().encode(s); + +/** Run a hostile input through both drivers; neither may throw/hang. Returns both results. */ +async function bothDrivers(src: string, opts: ParseOptions = {}) { + const bytes = enc(src); + const mem = parseGcodeToIR(bytes, opts); + const stream = await parseGcodeStreamToIR(new Blob([bytes]), opts, { yieldIntervalMs: 10 }); + expect(stream.cancelled).toBe(false); + return { mem, stream }; +} + +function minZ1(ir: { segments: { count: number; z1: Float32Array } }): number { + let m = Infinity; + for (let i = 0; i < ir.segments.count; i++) m = Math.min(m, ir.segments.z1[i]); + return m; +} + +describe('CNC/laser adversarial hardening (#277)', () => { + it('malformed multi-command lines + garbage tokens parse bounded, no crash (both drivers)', async () => { + const { mem, stream } = await bothDrivers( + 'g20 g17 g90\ns3400 m3 xyz\ng1z-.1 f???\ng0 g53 g@#$\ng1 x10 m3 m4 m5\ng1 y5\nm2\n' + ); + expect(mem.ir.header.complete).toBe(true); + expect(mem.ir.header.warnings.length).toBeLessThanOrEqual(10); // aggregated, never unbounded + expect(stream.ir.segments.count).toBe(mem.ir.segments.count); // drivers agree on geometry + }); + + it('huge / overflowing N line numbers are stripped, not treated as motion', async () => { + const { mem, stream } = await bothDrivers('N999999999999999999 G1 X10 F100\nN2 G1 X20\nN3 G1 X30\n'); + expect(mem.ir.segments.count).toBe(3); // three real moves; N-words add no geometry + expect(stream.ir.segments.count).toBe(3); + }); + + it('bare G / S with trailing garbage is ignored, not a spurious command', async () => { + const { mem } = await bothDrivers('G\nS\nGxx\nSyy\nM3 S1000\nG1 X10 F100\n'); + expect(mem.ir.segments.count).toBe(1); // only the real G1 emits + }); + + it('G83 pathological tiny Q is bounded by the segment budget — no hang', async () => { + // ~1M pecks if unbounded; the emitSegment budget must stop the peck loop. + const { mem, stream } = await bothDrivers('M3 S1000\nG0 X0 Y0 Z5\nG83 X0 Y0 Z-1000 R2 Q0.001 F100\nG80\n', { + limits: { maxSegments: 2000 } + }); + expect(mem.stats.stopReason?.code).toBe('E_LIMIT_SEGMENTS'); + expect(mem.ir.segments.count).toBeLessThanOrEqual(2000); + // Streaming honors the same bound (it may stop at a slightly different chunk boundary, but bounded). + expect(stream.ir.segments.count).toBeLessThanOrEqual(2000); + }); + + it('G83 with Q=0 falls back to a single plunge (no divide-by-zero / infinite loop)', async () => { + const { mem } = await bothDrivers('M3 S1000\nG0 X0 Y0 Z5\nG83 X0 Y0 Z-3 R1 Q0 F100\nG80\n'); + expect(minZ1(mem.ir)).toBeCloseTo(-3); // reached depth once, bounded + }); + + it('G81 with no Z/R on the first cycle is graceful (degenerate, bounded — never a fabricated plunge)', async () => { + // RS274NGC retains modal Z/R across cycles; a first cycle that never specified them is a program + // error. We handle it as a zero-plane no-op (Z=R=0), not a crash or a fabricated deep plunge. + const { mem, stream } = await bothDrivers('M3 S1000\nG0 X0 Y0 Z5\nG81 X10 Y10 F100\nG80\n'); + expect(Number.isFinite(minZ1(mem.ir))).toBe(true); + expect(minZ1(mem.ir)).toBeGreaterThanOrEqual(-1e-6); // collapses to the zero plane, no deep plunge + expect(stream.ir.segments.count).toBe(mem.ir.segments.count); + }); + + it('canned Z/R are correctly retained across a modal repeat (RS274NGC modal params)', async () => { + // Second cycle omits Z/R → must reuse -2 / 1 from the first, not reset to 0. + const { mem } = await bothDrivers('M3 S1000\nG0 X0 Y0 Z5\nG81 X0 Y0 Z-2 R1 F100\nX10 Y0\nG80\n'); + expect(minZ1(mem.ir)).toBeCloseTo(-2); // both holes reach -2 + }); + + it('G80 cancels the cycle: a following bare coordinate line is a normal move, not a drill', async () => { + const { mem } = await bothDrivers('M3 S1000\nG0 X0 Y0 Z5\nG81 X0 Y0 Z-2 R1 F100\nG80\nG1 X10 Y0\n'); + expect(mem.ir.header.complete).toBe(true); + expect(mem.ir.segments.count).toBeGreaterThan(0); + }); +}); diff --git a/packages/gcode-parser/src/__tests__/non-extrusion.test.ts b/packages/gcode-parser/src/__tests__/non-extrusion.test.ts index cf4f00c9..368c5c1c 100644 --- a/packages/gcode-parser/src/__tests__/non-extrusion.test.ts +++ b/packages/gcode-parser/src/__tests__/non-extrusion.test.ts @@ -190,6 +190,16 @@ describe('DD-012 phase 2 — canned drilling cycles (#189)', () => { expect(minZ1(ir)).toBeCloseTo(-6); }); + it('G82 (dwell drill) expands to a single Cut plunge to depth (#277/M5)', () => { + // G82 is routed in the parser but was untested — it drills like G81 (the dwell P is a no-op here). + const { ir } = parseGcodeToIR('G0 X0 Y0 Z5\nG82 X0 Y0 Z-4 R2 P50 F100\nG80\n', {}); + expect(ir.header.capabilities.cannedCycles).toBe('known'); + let cutCount = 0; + for (let i = 0; i < ir.segments.count; i++) if (cut(ir.segments.kind[i])) cutCount++; + expect(cutCount).toBe(1); // single plunge (not a peck loop) + expect(minZ1(ir)).toBeCloseTo(-4); + }); + it('G80 cancels: a following bare coordinate line drills nothing', () => { const active = parseGcodeToIR('G0 X0 Y0 Z5\nG81 X10 Y10 Z-5 R2\nX20 Y10\n', {}).ir.segments.count; const cancelled = parseGcodeToIR('G0 X0 Y0 Z5\nG81 X10 Y10 Z-5 R2\nG80\nX20 Y10\n', {}).ir.segments.count; diff --git a/packages/gcode-preview-core/CHANGELOG.md b/packages/gcode-preview-core/CHANGELOG.md index 46a09aa8..42f528af 100644 --- a/packages/gcode-preview-core/CHANGELOG.md +++ b/packages/gcode-preview-core/CHANGELOG.md @@ -1,5 +1,46 @@ # @chestnutlabs/gcode-preview-core +## 0.5.0 + +### Minor Changes + +- [#283](https://github.com/ChestnutLabs/gcode-preview/pull/283) [`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Adapter surface: capabilities/warnings on `ready` + declarative `view`/`cameraState` ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275) M3+M6) + + **M3** — the `parse-complete` / `ready` event now carries `capabilities` (the per-field confidence + map) and `warnings` alongside `{ segments, layers, complete }`, so consumers can gate their own UI on + capability-honesty without reaching for the raw handle. + + **M6** — the `setView`/`getCameraState`/`setCameraState` methods ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) get first-class declarative + props on all four adapters: a `view` prop (preset orientation) and a `cameraState` prop (restore), + paired with a new **`camera-changed`** event (renderer → controller → adapters, emitted when a user + camera interaction settles) so a `cameraState` binding round-trips. The 2D renderer keeps disclosing + via `renderer-unsupported` rather than fabricating a pose. Behavioral-suite coverage added for the + capabilities/warnings payload across all four adapters. + +- [#270](https://github.com/ChestnutLabs/gcode-preview/pull/270) [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Preset camera views + serializable camera state ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) + + Adds three imperative camera methods, threaded from the renderer through `PreviewRenderer` and the + `controls` handle into all four adapters: + - `setView(view)` — snap to a preset orientation (`top`/`bottom`/`front`/`back`/`left`/`right`/`iso`), + instant, preserving the active projection. + - `getCameraState()` — read the current camera as a serializable `CameraState` + (`{ position, target, zoom, cameraMode }`, scene coordinates); a stable contract a dashboard can + persist. + - `setCameraState(state)` — restore a snapshot verbatim (no re-fit to the current model). + + New public types `CameraView` and `CameraState`. No new dependency, no IR/schema change, no animation + (snapping is instant). The low-resource 2D renderer has no 3D pose, so it honors these as documented + disclosures (`getCameraState()` → `null`; `setView`/`setCameraState` → `renderer-unsupported`) rather + than fabricating a pose. Covered across all four adapters by the portable behavioral suite. + +### Patch Changes + +- Updated dependencies [[`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941), [`b671d02`](https://github.com/ChestnutLabs/gcode-preview/commit/b671d02179ba6cf30ce9888fa4b851328852e0f1), [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069), [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e)]: + - @chestnutlabs/gcode-renderer-three@0.5.0 + - @chestnutlabs/gcode-parser@0.5.0 + - @chestnutlabs/gcode-renderer-2d@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/gcode-preview-core/package.json b/packages/gcode-preview-core/package.json index 80b68ee8..301274cf 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.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/gcode-renderer-2d": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-renderer-2d": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" } } diff --git a/packages/gcode-preview-core/src/controller.ts b/packages/gcode-preview-core/src/controller.ts index 306c3f4e..fc1e6044 100644 --- a/packages/gcode-preview-core/src/controller.ts +++ b/packages/gcode-preview-core/src/controller.ts @@ -21,6 +21,8 @@ import { import type { BuildVolumeDef, CameraMode, + CameraState, + CameraView, ColorMode, GLRendererLike, ProgressPresentationMode, @@ -42,7 +44,8 @@ import { type ProgressMapper, type ProgressObservation, type ToolpathIR, - type ToolpathTime + type ToolpathTime, + type Warning } from '@chestnutlabs/toolpath-core'; /** Session/renderer events, re-emitted, plus the controller's own lifecycle events. */ @@ -50,7 +53,16 @@ export type PreviewEvent = | PreviewRendererEvent | { type: 'parse-started'; bytes: number } | { type: 'parse-progress'; progress: ParseProgress } - | { type: 'parse-complete'; segments: number; layers: number; complete: boolean } + | { + type: 'parse-complete'; + segments: number; + layers: number; + complete: boolean; + /** Per-field capability confidence (DD-001) — lets consumers gate their own UI honestly (#275/M3). */ + capabilities: Record; + /** Parse warnings (codes/messages), so consumers can surface disclosures without the raw handle. */ + warnings: readonly Warning[]; + } | { type: 'parse-cancelled' } | { type: 'parse-error'; code: string; message: string } | { @@ -141,6 +153,14 @@ export interface GcodePreviewControls { setQuality(quality: QualityMode | 'auto'): void; /** Switch camera projection (#150, DD-009 D3). */ setCameraMode(mode: CameraMode): void; + /** Snap to a preset orientation — top/front/iso/… (#268). Instant; preserves the projection. + * The 2D renderer discloses this via `renderer-unsupported` rather than moving. */ + setView(view: CameraView): void; + /** Read the current camera as a serializable snapshot (#268), or null before the renderer is ready + * / on the 2D renderer (which has no 3D pose). */ + getCameraState(): CameraState | null; + /** Restore a camera snapshot verbatim (#268) — no re-fit to the current model. 2D discloses. */ + setCameraState(state: CameraState): void; /** Apply a bounded declarative theme (#153, DD-009 D4). */ setTheme(theme: Theme): void; /** Marks the volume consumer-configured: file-discovered geometry stops auto-applying. */ @@ -402,7 +422,9 @@ export function createPreviewController(options: PreviewControllerOptions = {}): type: 'parse-complete', segments: result.ir.segments.count, layers: result.ir.layers.length, - complete: result.ir.header.complete + complete: result.ir.header.complete, + capabilities: { ...result.ir.header.capabilities }, + warnings: result.ir.header.warnings }); return { ok: true, result }; } catch (err) { @@ -441,6 +463,10 @@ export function createPreviewController(options: PreviewControllerOptions = {}): }, setQuality: (q) => withRenderer((r) => r.setQuality(q)), setCameraMode: (m) => withRenderer((r) => r.setCameraMode(m)), + 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), + setCameraState: (s) => withRenderer((r) => r.setCameraState(s)), setTheme: (t) => withRenderer((r) => r.setTheme(t)), setBuildVolume: (def) => { consumerVolumeSet = true; diff --git a/packages/gcode-preview-core/src/index.ts b/packages/gcode-preview-core/src/index.ts index 4686620d..ba58bc05 100644 --- a/packages/gcode-preview-core/src/index.ts +++ b/packages/gcode-preview-core/src/index.ts @@ -11,3 +11,5 @@ export { LayerView2DRenderer, type LayerView2DRendererOptions } from './renderer export { renderStill, type RenderStillOptions, type RenderStillResult, type StillCameraPose } from './render-still.js'; // Re-export the theme contract so core consumers get it three-free (#153, DD-009 D4). 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'; diff --git a/packages/gcode-preview-core/src/renderer-2d-adapter.ts b/packages/gcode-preview-core/src/renderer-2d-adapter.ts index feb5ff1f..89b8fa61 100644 --- a/packages/gcode-preview-core/src/renderer-2d-adapter.ts +++ b/packages/gcode-preview-core/src/renderer-2d-adapter.ts @@ -18,7 +18,14 @@ import { type LayerProgress, type TravelStyle } from '@chestnutlabs/gcode-renderer-2d'; -import type { BuildVolumeDef, CameraMode, QualityMode, Theme } from '@chestnutlabs/gcode-renderer-three'; +import type { + BuildVolumeDef, + CameraMode, + CameraState, + CameraView, + QualityMode, + Theme +} from '@chestnutlabs/gcode-renderer-three'; import type { MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; import type { MoveKindToggle, PreviewRenderer, PreviewRendererEvent } from './renderer-interface.js'; @@ -144,6 +151,19 @@ export class LayerView2DRenderer implements PreviewRenderer { this.disclose('camera', 'Camera projection applies only to the 3D renderer; the 2D view is flat top-down.'); } + setView(_view: CameraView): void { + this.disclose('camera', 'Preset views apply only to the 3D renderer; the 2D view is fixed top-down.'); + } + + getCameraState(): CameraState | null { + // The flat 2D view has no 3D pose — return null rather than fabricate one (#268, honesty pattern). + return null; + } + + setCameraState(_state: CameraState): void { + this.disclose('camera', 'Camera state applies only to the 3D renderer; the 2D view has no 3D pose.'); + } + setTheme(_theme: Theme): void { // The 2D view uses per-segment colors from the color mode; scene theming is a 3D concern. } diff --git a/packages/gcode-preview-core/src/renderer-interface.ts b/packages/gcode-preview-core/src/renderer-interface.ts index 8c9d7cb9..6b367bf7 100644 --- a/packages/gcode-preview-core/src/renderer-interface.ts +++ b/packages/gcode-preview-core/src/renderer-interface.ts @@ -11,11 +11,15 @@ import type { BuildVolumeDef, CameraMode, + CameraState, + CameraView, ColorMode, QualityMode, RendererEvent, Theme } from '@chestnutlabs/gcode-renderer-three'; + +export type { CameraState, CameraView } 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). */ @@ -54,6 +58,12 @@ export interface PreviewRenderer { setColorMode(mode: ColorMode): boolean; setQuality(quality: QualityMode | 'auto'): void; setCameraMode(mode: CameraMode): void; + /** Snap to a preset orientation (#268). 2D renderers disclose via `renderer-unsupported`. */ + setView(view: CameraView): void; + /** Read the current camera as a serializable snapshot (#268). Null when the renderer has no 3D pose (2D). */ + getCameraState(): CameraState | null; + /** Restore a camera snapshot verbatim (#268). 2D renderers disclose via `renderer-unsupported`. */ + setCameraState(state: CameraState): void; setTheme(theme: Theme): void; setProgress(p: MappedProgress | null): void; /** diff --git a/packages/gcode-preview-core/src/testing.ts b/packages/gcode-preview-core/src/testing.ts index 1d92f742..43b24425 100644 --- a/packages/gcode-preview-core/src/testing.ts +++ b/packages/gcode-preview-core/src/testing.ts @@ -110,15 +110,20 @@ export interface AdapterInstance { settle(): Promise; } +/** The subset of matchers the parity suite uses — structurally satisfied by any vitest/jest `expect`. */ +interface Matchers { + toBe(v: unknown): void; + toBeNull(): void; + toMatchObject(v: object): void; + toBeGreaterThan(v: number): void; + toBeLessThan(v: number): void; + toBeCloseTo(v: number, numDigits?: number): void; +} + interface TestApi { describe: (name: string, fn: () => void) => void; it: (name: string, fn: () => Promise | void) => void; - expect: (actual: unknown) => Record unknown> & { - toBe(v: unknown): void; - toBeNull(): void; - toMatchObject(v: object): void; - toBeGreaterThan(v: number): void; - }; + expect: (actual: unknown) => Matchers & { not: Matchers }; } /** The parity contract every adapter must pass (DD-007 §4.6 amendment). */ @@ -148,6 +153,59 @@ export function runBehavioralSuite(name: string, api: TestApi, harness: AdapterH await a.dispose(); }); + it('the parse-complete (ready) event carries capabilities + warnings (#275/M3)', async () => { + const a = await harness.create(); + const events: PreviewEvent[] = []; + a.onEvent((e) => events.push(e)); + await a.parse(new Uint8Array(1_000)); + await a.settle(); + const done = events.find( + (e): e is Extract => e.type === 'parse-complete' + ); + if (!done) throw new Error('no parse-complete event emitted'); + // Both are surfaced on the event so a consumer can gate its own UI without the raw handle. + expect(typeof done.capabilities).toBe('object'); + expect(Array.isArray(done.warnings)).toBe(true); + await a.dispose(); + }); + + it('camera preset views + state round-trip reach the renderer (#268)', async () => { + const a = await harness.create(); + await a.parse(new Uint8Array(1_000)); + await a.settle(); + + const initial = a.controls.getCameraState(); + expect(initial).not.toBeNull(); + expect(initial!.cameraMode).toBe('perspective'); + + // A preset snaps deterministically and preserves the projection. 'top' looks straight down + // scene +Y, so the camera sits above the target on Y and level in X/Z. + a.controls.setView('top'); + await a.settle(); + const top = a.controls.getCameraState()!; + expect(top.cameraMode).toBe('perspective'); + expect(top.position.y).toBeGreaterThan(top.target.y); + expect(Math.abs(top.position.x - top.target.x)).toBeLessThan(1e-3); + expect(Math.abs(top.position.z - top.target.z)).toBeLessThan(1e-3); + + // getCameraState → setCameraState restores the same view even after moving away. + const saved = a.controls.getCameraState()!; + a.controls.setView('front'); + await a.settle(); + a.controls.setCameraState(saved); + await a.settle(); + const restored = a.controls.getCameraState()!; + expect(restored.position.x).toBeCloseTo(saved.position.x, 3); + expect(restored.position.y).toBeCloseTo(saved.position.y, 3); + expect(restored.position.z).toBeCloseTo(saved.position.z, 3); + expect(restored.target.x).toBeCloseTo(saved.target.x, 3); + expect(restored.target.y).toBeCloseTo(saved.target.y, 3); + expect(restored.target.z).toBeCloseTo(saved.target.z, 3); + expect(restored.cameraMode).toBe(saved.cameraMode); + + await a.dispose(); + }); + it('drives the DD-006 progress chain honestly (exact → cleared)', async () => { const a = await harness.create(); await a.parse(new Uint8Array(1_000)); diff --git a/packages/gcode-preview-element/CHANGELOG.md b/packages/gcode-preview-element/CHANGELOG.md index 394ca749..94b657f9 100644 --- a/packages/gcode-preview-element/CHANGELOG.md +++ b/packages/gcode-preview-element/CHANGELOG.md @@ -1,5 +1,54 @@ # @chestnutlabs/gcode-preview-element +## 0.5.0 + +### Minor Changes + +- [#283](https://github.com/ChestnutLabs/gcode-preview/pull/283) [`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Adapter surface: capabilities/warnings on `ready` + declarative `view`/`cameraState` ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275) M3+M6) + + **M3** — the `parse-complete` / `ready` event now carries `capabilities` (the per-field confidence + map) and `warnings` alongside `{ segments, layers, complete }`, so consumers can gate their own UI on + capability-honesty without reaching for the raw handle. + + **M6** — the `setView`/`getCameraState`/`setCameraState` methods ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) get first-class declarative + props on all four adapters: a `view` prop (preset orientation) and a `cameraState` prop (restore), + paired with a new **`camera-changed`** event (renderer → controller → adapters, emitted when a user + camera interaction settles) so a `cameraState` binding round-trips. The 2D renderer keeps disclosing + via `renderer-unsupported` rather than fabricating a pose. Behavioral-suite coverage added for the + capabilities/warnings payload across all four adapters. + +- [#270](https://github.com/ChestnutLabs/gcode-preview/pull/270) [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Preset camera views + serializable camera state ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) + + Adds three imperative camera methods, threaded from the renderer through `PreviewRenderer` and the + `controls` handle into all four adapters: + - `setView(view)` — snap to a preset orientation (`top`/`bottom`/`front`/`back`/`left`/`right`/`iso`), + instant, preserving the active projection. + - `getCameraState()` — read the current camera as a serializable `CameraState` + (`{ position, target, zoom, cameraMode }`, scene coordinates); a stable contract a dashboard can + persist. + - `setCameraState(state)` — restore a snapshot verbatim (no re-fit to the current model). + + New public types `CameraView` and `CameraState`. No new dependency, no IR/schema change, no animation + (snapping is instant). The low-resource 2D renderer has no 3D pose, so it honors these as documented + disclosures (`getCameraState()` → `null`; `setView`/`setCameraState` → `renderer-unsupported`) rather + than fabricating a pose. Covered across all four adapters by the portable behavioral suite. + +### Patch Changes + +- [#282](https://github.com/ChestnutLabs/gcode-preview/pull/282) [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Keyboard-operable camera for embedded viewers (DD-004 a11y) ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275)/M4) + + The embedded adapter canvases had `aria-label` but no `tabindex`, so they weren't focusable, and the + renderer never enabled OrbitControls key events — only the standalone demo page was keyboard-usable. + Now every adapter canvas is focusable (`tabindex="0"`) and the renderer enables OrbitControls keyboard + events scoped to the canvas (arrow keys pan the view when it's focused, without hijacking the page's + arrow keys). Keyboard operability is satisfied for embedders, not just the demo. + +- Updated dependencies [[`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941), [`b671d02`](https://github.com/ChestnutLabs/gcode-preview/commit/b671d02179ba6cf30ce9888fa4b851328852e0f1), [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069), [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e)]: + - @chestnutlabs/gcode-renderer-three@0.5.0 + - @chestnutlabs/gcode-preview-core@0.5.0 + - @chestnutlabs/gcode-parser@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/gcode-preview-element/package.json b/packages/gcode-preview-element/package.json index f790bd6a..5e566cf2 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.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/gcode-preview-core": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-preview-core": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" } } diff --git a/packages/gcode-preview-element/src/__tests__/keyboard-a11y.test.ts b/packages/gcode-preview-element/src/__tests__/keyboard-a11y.test.ts new file mode 100644 index 00000000..9fe8d59a --- /dev/null +++ b/packages/gcode-preview-element/src/__tests__/keyboard-a11y.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment happy-dom +/** + * Keyboard-operability a11y (DD-004, #275/M4). The embedded canvas must be focusable (`tabindex="0"`) + * so an embedder's viewer is keyboard-reachable, not just the standalone demo page. The custom element + * mounts a real canvas in an open shadow root, so this is directly assertable here; the OrbitControls + * key wiring lives in the renderer (`scene.ts`, exercised by the demo/live check). + */ +import { describe, expect, it } from 'vitest'; +import { defineGcodePreview, GcodePreviewElement } from '../index'; + +describe('keyboard a11y (#275/M4)', () => { + it('the embedded canvas is focusable (tabindex="0") and labelled', () => { + defineGcodePreview(); + const el = document.createElement('gcode-preview') as GcodePreviewElement; + document.body.appendChild(el); // connectedCallback builds the canvas synchronously + const canvas = el.shadowRoot!.querySelector('canvas'); + expect(canvas).not.toBeNull(); + expect(canvas!.getAttribute('tabindex')).toBe('0'); + expect(canvas!.getAttribute('aria-label')).toBeTruthy(); + el.remove(); + }); +}); diff --git a/packages/gcode-preview-element/src/gcode-preview-element.ts b/packages/gcode-preview-element/src/gcode-preview-element.ts index 91a99549..b3d7cb4a 100644 --- a/packages/gcode-preview-element/src/gcode-preview-element.ts +++ b/packages/gcode-preview-element/src/gcode-preview-element.ts @@ -19,7 +19,15 @@ import { type PreviewEvent, type RendererMode } from '@chestnutlabs/gcode-preview-core'; -import type { BuildVolumeDef, CameraMode, ColorMode, Theme, TubeOptions } from '@chestnutlabs/gcode-renderer-three'; +import type { + BuildVolumeDef, + CameraMode, + CameraState, + CameraView, + ColorMode, + Theme, + TubeOptions +} from '@chestnutlabs/gcode-renderer-three'; import type { MachineGeometry, MappedProgress, ProgressObservation } from '@chestnutlabs/toolpath-core'; import type { WireParseOptions, WorkerLike } from '@chestnutlabs/gcode-parser'; @@ -36,6 +44,7 @@ type PreviewControllerRenderer = NonNullable WorkerLike) | undefined; private _rendererOptions: ElementRendererOptions | undefined; + private _cameraState: CameraState | null = null; // ---- rich-option property accessors ---- get source(): GcodePreviewSource { @@ -140,6 +150,22 @@ export class GcodePreviewElement extends HTMLElement { set cameraMode(v: string | null) { this.reflect('camera-mode', v); } + /** #268/#275/M6: preset orientation attribute (top/front/iso/…). */ + get view(): string | null { + return this.getAttribute('view'); + } + set view(v: string | null) { + this.reflect('view', v); + } + /** #268/#275/M6: restore a saved pose (property-only — objects can't be attributes). Pair with the + * `camerachange` event for two-way. */ + get cameraState(): CameraState | null { + return this.controller !== null ? this.controller.controls.getCameraState() : this._cameraState; + } + set cameraState(v: CameraState | null) { + this._cameraState = v; + if (this.controller !== null && v !== null) this.controller.controls.setCameraState(v); + } /** Default true; only `show-travel="false"` hides travel. */ get showTravel(): boolean { return this.getAttribute('show-travel') !== 'false'; @@ -218,6 +244,7 @@ export class GcodePreviewElement extends HTMLElement { style.textContent = ':host{display:block;width:100%;height:100%}canvas{display:block;width:100%;height:100%}'; const canvas = document.createElement('canvas'); canvas.setAttribute('aria-label', '3D G-code toolpath preview'); + canvas.setAttribute('tabindex', '0'); // focusable → keyboard camera (DD-004 a11y, #275/M4) root.append(style, canvas); this.canvasEl = canvas; @@ -262,6 +289,9 @@ export class GcodePreviewElement extends HTMLElement { case 'camera-mode': if (value !== null) c.setCameraMode(value as CameraMode); break; + case 'view': + if (value !== null) c.setView(value as CameraView); + break; case 'show-travel': c.setKindVisible('travel', value !== 'false'); break; @@ -328,7 +358,16 @@ export class GcodePreviewElement extends HTMLElement { }; switch (e.type) { case 'parse-complete': - emit('ready', { segments: e.segments, layers: e.layers, complete: e.complete }); + emit('ready', { + segments: e.segments, + layers: e.layers, + complete: e.complete, + capabilities: e.capabilities, + warnings: e.warnings + }); + break; + case 'camera-changed': + emit('camerachange', e.state); break; case 'parse-error': emit('parse-error', { code: e.code, message: e.message }); diff --git a/packages/gcode-preview-react/CHANGELOG.md b/packages/gcode-preview-react/CHANGELOG.md index ea9071d1..7da55bcb 100644 --- a/packages/gcode-preview-react/CHANGELOG.md +++ b/packages/gcode-preview-react/CHANGELOG.md @@ -1,5 +1,54 @@ # @chestnutlabs/gcode-preview-react +## 0.5.0 + +### Minor Changes + +- [#283](https://github.com/ChestnutLabs/gcode-preview/pull/283) [`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Adapter surface: capabilities/warnings on `ready` + declarative `view`/`cameraState` ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275) M3+M6) + + **M3** — the `parse-complete` / `ready` event now carries `capabilities` (the per-field confidence + map) and `warnings` alongside `{ segments, layers, complete }`, so consumers can gate their own UI on + capability-honesty without reaching for the raw handle. + + **M6** — the `setView`/`getCameraState`/`setCameraState` methods ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) get first-class declarative + props on all four adapters: a `view` prop (preset orientation) and a `cameraState` prop (restore), + paired with a new **`camera-changed`** event (renderer → controller → adapters, emitted when a user + camera interaction settles) so a `cameraState` binding round-trips. The 2D renderer keeps disclosing + via `renderer-unsupported` rather than fabricating a pose. Behavioral-suite coverage added for the + capabilities/warnings payload across all four adapters. + +- [#270](https://github.com/ChestnutLabs/gcode-preview/pull/270) [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Preset camera views + serializable camera state ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) + + Adds three imperative camera methods, threaded from the renderer through `PreviewRenderer` and the + `controls` handle into all four adapters: + - `setView(view)` — snap to a preset orientation (`top`/`bottom`/`front`/`back`/`left`/`right`/`iso`), + instant, preserving the active projection. + - `getCameraState()` — read the current camera as a serializable `CameraState` + (`{ position, target, zoom, cameraMode }`, scene coordinates); a stable contract a dashboard can + persist. + - `setCameraState(state)` — restore a snapshot verbatim (no re-fit to the current model). + + New public types `CameraView` and `CameraState`. No new dependency, no IR/schema change, no animation + (snapping is instant). The low-resource 2D renderer has no 3D pose, so it honors these as documented + disclosures (`getCameraState()` → `null`; `setView`/`setCameraState` → `renderer-unsupported`) rather + than fabricating a pose. Covered across all four adapters by the portable behavioral suite. + +### Patch Changes + +- [#282](https://github.com/ChestnutLabs/gcode-preview/pull/282) [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Keyboard-operable camera for embedded viewers (DD-004 a11y) ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275)/M4) + + The embedded adapter canvases had `aria-label` but no `tabindex`, so they weren't focusable, and the + renderer never enabled OrbitControls key events — only the standalone demo page was keyboard-usable. + Now every adapter canvas is focusable (`tabindex="0"`) and the renderer enables OrbitControls keyboard + events scoped to the canvas (arrow keys pan the view when it's focused, without hijacking the page's + arrow keys). Keyboard operability is satisfied for embedders, not just the demo. + +- Updated dependencies [[`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941), [`b671d02`](https://github.com/ChestnutLabs/gcode-preview/commit/b671d02179ba6cf30ce9888fa4b851328852e0f1), [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069), [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e)]: + - @chestnutlabs/gcode-renderer-three@0.5.0 + - @chestnutlabs/gcode-preview-core@0.5.0 + - @chestnutlabs/gcode-parser@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/gcode-preview-react/package.json b/packages/gcode-preview-react/package.json index b5694e3e..5b309bd2 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.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/gcode-preview-core": "0.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-preview-core": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" diff --git a/packages/gcode-preview-react/src/__tests__/behavioral-suite.test.ts b/packages/gcode-preview-react/src/__tests__/behavioral-suite.test.ts index cf165919..d4a3d5f5 100644 --- a/packages/gcode-preview-react/src/__tests__/behavioral-suite.test.ts +++ b/packages/gcode-preview-react/src/__tests__/behavioral-suite.test.ts @@ -78,6 +78,9 @@ runBehavioralSuite( setColorMode: (m) => latest().controls.setColorMode(m), setQuality: (q) => latest().controls.setQuality(q), setBuildVolume: (d) => latest().controls.setBuildVolume(d), + setView: (v) => latest().controls.setView(v), + getCameraState: () => latest().controls.getCameraState(), + setCameraState: (s) => latest().controls.setCameraState(s), frame: () => latest().controls.frame() }, observeProgress: (obs) => latest().observeProgress(obs), diff --git a/packages/gcode-preview-react/src/gcode-preview-component.ts b/packages/gcode-preview-react/src/gcode-preview-component.ts index 600caedb..42b3ee0a 100644 --- a/packages/gcode-preview-react/src/gcode-preview-component.ts +++ b/packages/gcode-preview-react/src/gcode-preview-component.ts @@ -21,6 +21,8 @@ import { import type { BuildVolumeDef, CameraMode, + CameraState, + CameraView, ColorMode, QualityMode, Theme, @@ -54,6 +56,10 @@ export interface GcodePreviewProps { quality?: QualityMode | 'auto'; /** #150 (DD-009 D3): camera projection. */ cameraMode?: CameraMode; + /** #268/#275/M6: snap to a preset orientation (top/front/iso/…). Instant; preserves the projection. */ + view?: CameraView; + /** #268/#275/M6: restore a saved camera pose. Pair with `onCameraChange` for a two-way binding. */ + cameraState?: CameraState | null; /** #153 (DD-009 D4): bounded declarative theme. */ theme?: Theme; colorMode?: ColorMode; @@ -78,7 +84,15 @@ export interface GcodePreviewProps { NonNullable, 'buildVolume' | 'quality' | 'cameraMode' | 'theme' | 'colorMode' | 'tube' >; - onReady?: (summary: { segments: number; layers: number; complete: boolean }) => void; + onReady?: (summary: { + segments: number; + layers: number; + complete: boolean; + capabilities: Record; + warnings: readonly import('@chestnutlabs/toolpath-core').Warning[]; + }) => void; + /** #275/M6: fires after a user camera interaction settles, with the new serializable state. */ + onCameraChange?: (state: CameraState) => void; onParseError?: (e: { code: string; message: string }) => void; onParseCancelled?: () => void; onParseProgress?: (p: { bytesProcessed: number; totalBytes: number }) => void; @@ -118,7 +132,16 @@ function GcodePreviewImpl(props: GcodePreviewProps, ref: ForwardedRef { if (cameraMode !== undefined) preview.controls.setCameraMode(cameraMode); }, [cameraMode]); + useEffect(() => { + if (view !== undefined) preview.controls.setView(view); + }, [view]); + useEffect(() => { + if (cameraState !== undefined && cameraState !== null) preview.controls.setCameraState(cameraState); + }, [cameraState]); useEffect(() => { if (theme !== undefined) preview.controls.setTheme(theme); }, [theme]); @@ -215,6 +246,7 @@ function GcodePreviewImpl(props: GcodePreviewProps, ref: ForwardedRef { switch (e.type) { case 'parse-complete': - dispatch('ready', { segments: e.segments, layers: e.layers, complete: e.complete }); + dispatch('ready', { + segments: e.segments, + layers: e.layers, + complete: e.complete, + capabilities: e.capabilities, + warnings: e.warnings + }); + break; + case 'camera-changed': + dispatch('camerachange', e.state); break; case 'parse-error': dispatch('parseerror', { code: e.code, message: e.message }); @@ -111,6 +122,10 @@ }); // ---- prop wiring: each prop is a thin reactive call into the handle (D1 shell rule) ---- + // buildVolume must be reactive too (#274) — matching Vue's watch and React's useEffect. A machine + // geometry is (re)applied via the handle; a plain BuildVolumeDef is an init-time renderer option + // (above) that a post-mount change re-applies here as well. + $: if (buildVolume !== undefined) preview.controls.setBuildVolume(buildVolume); $: if (source !== null && source !== undefined) void preview.parse(source, parseOptions); $: if (layerRange === null || layerRange === undefined) preview.controls.setLayerRange(0, Number.POSITIVE_INFINITY); else preview.controls.setLayerRange(layerRange[0], layerRange[1]); @@ -122,6 +137,8 @@ $: if (colorMode !== undefined) preview.controls.setColorMode(colorMode); $: preview.controls.setQuality(quality); $: preview.controls.setCameraMode(cameraMode); + $: if (view !== undefined) preview.controls.setView(view); + $: if (cameraState !== undefined && cameraState !== null) preview.controls.setCameraState(cameraState); $: if (theme !== undefined) preview.controls.setTheme(theme); $: if (progress === null || progress === undefined) preview.clearProgress(); else preview.observeProgress(progress); @@ -129,8 +146,10 @@ onDestroy(() => preview.dispose()); + diff --git a/packages/gcode-preview-svelte/src/__tests__/prop-reactivity.test.ts b/packages/gcode-preview-svelte/src/__tests__/prop-reactivity.test.ts new file mode 100644 index 00000000..5dec9e51 --- /dev/null +++ b/packages/gcode-preview-svelte/src/__tests__/prop-reactivity.test.ts @@ -0,0 +1,49 @@ +/** + * Prop-reactivity invariant for the Svelte shell (#274). `buildVolume` shipped as a one-time init + * call with no `$:` statement, so post-mount changes were a silent no-op — a parity break vs Vue's + * `watch` and React's `useEffect`. The portable behavioral suite drives `createGcodePreview` (the + * store), not the `.svelte` component, and this package is browser-only (DD-007 §7 — no Node imports) + * with no component-mount harness, so this static invariant is the enforceable guard: **every writable + * prop must be wired into a reactive `$:` block** (or be a documented init-only renderer option that + * genuinely cannot change after mount). The component source is read via a Vite `?raw` import (no Node + * `fs`), keeping the package browser-clean. + */ +import { describe, expect, it } from 'vitest'; +import SOURCE from '../GcodePreview.svelte?raw'; + +/** + * Props fixed at construction (passed into `createGcodePreview` / renderer options) — they cannot be + * re-applied after mount, so they are intentionally not reactive. Keep this list tight: a new + * consumer-facing option that *can* change at runtime must be reactive, not added here. + */ +const INIT_ONLY = new Set(['renderer', 'createWorker', 'rendererOptions', 'adjacentLayers', 'tube']); + +describe('Svelte shell prop reactivity (#274)', () => { + const props = [...SOURCE.matchAll(/export let (\w+)/g)].map((m) => m[1]); + // Names referenced in a reactive statement: a `$:` line, or the `else` continuation of a + // `$: if (…) …; else …` pair. Deliberately NOT arbitrary `preview.*` calls — an init-time call + // (like the old one-time `setBuildVolume`) must not count as reactive, or the guard is toothless. + const reactiveNames = new Set( + SOURCE.split('\n') + .filter((line) => { + const t = line.trimStart(); + return t.startsWith('$:') || t.startsWith('else '); + }) + .join('\n') + .match(/\b\w+\b/g) ?? [] + ); + + it('discovers the full prop surface', () => { + expect(props).toContain('buildVolume'); + expect(props.length).toBeGreaterThan(10); + }); + + it('every writable prop is reactively wired (or a documented init-only option)', () => { + const nonReactive = props.filter((p) => !INIT_ONLY.has(p) && !reactiveNames.has(p)); + expect(nonReactive).toEqual([]); + }); + + it('buildVolume specifically is reactive (the #274 regression)', () => { + expect(SOURCE).toMatch(/\$:[^\n]*buildVolume[^\n]*setBuildVolume/); + }); +}); diff --git a/packages/gcode-preview-vue/CHANGELOG.md b/packages/gcode-preview-vue/CHANGELOG.md index b5664a3f..fb391f06 100644 --- a/packages/gcode-preview-vue/CHANGELOG.md +++ b/packages/gcode-preview-vue/CHANGELOG.md @@ -1,5 +1,54 @@ # @chestnutlabs/gcode-preview-vue +## 0.5.0 + +### Minor Changes + +- [#283](https://github.com/ChestnutLabs/gcode-preview/pull/283) [`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Adapter surface: capabilities/warnings on `ready` + declarative `view`/`cameraState` ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275) M3+M6) + + **M3** — the `parse-complete` / `ready` event now carries `capabilities` (the per-field confidence + map) and `warnings` alongside `{ segments, layers, complete }`, so consumers can gate their own UI on + capability-honesty without reaching for the raw handle. + + **M6** — the `setView`/`getCameraState`/`setCameraState` methods ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) get first-class declarative + props on all four adapters: a `view` prop (preset orientation) and a `cameraState` prop (restore), + paired with a new **`camera-changed`** event (renderer → controller → adapters, emitted when a user + camera interaction settles) so a `cameraState` binding round-trips. The 2D renderer keeps disclosing + via `renderer-unsupported` rather than fabricating a pose. Behavioral-suite coverage added for the + capabilities/warnings payload across all four adapters. + +- [#270](https://github.com/ChestnutLabs/gcode-preview/pull/270) [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Preset camera views + serializable camera state ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) + + Adds three imperative camera methods, threaded from the renderer through `PreviewRenderer` and the + `controls` handle into all four adapters: + - `setView(view)` — snap to a preset orientation (`top`/`bottom`/`front`/`back`/`left`/`right`/`iso`), + instant, preserving the active projection. + - `getCameraState()` — read the current camera as a serializable `CameraState` + (`{ position, target, zoom, cameraMode }`, scene coordinates); a stable contract a dashboard can + persist. + - `setCameraState(state)` — restore a snapshot verbatim (no re-fit to the current model). + + New public types `CameraView` and `CameraState`. No new dependency, no IR/schema change, no animation + (snapping is instant). The low-resource 2D renderer has no 3D pose, so it honors these as documented + disclosures (`getCameraState()` → `null`; `setView`/`setCameraState` → `renderer-unsupported`) rather + than fabricating a pose. Covered across all four adapters by the portable behavioral suite. + +### Patch Changes + +- [#282](https://github.com/ChestnutLabs/gcode-preview/pull/282) [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Keyboard-operable camera for embedded viewers (DD-004 a11y) ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275)/M4) + + The embedded adapter canvases had `aria-label` but no `tabindex`, so they weren't focusable, and the + renderer never enabled OrbitControls key events — only the standalone demo page was keyboard-usable. + Now every adapter canvas is focusable (`tabindex="0"`) and the renderer enables OrbitControls keyboard + events scoped to the canvas (arrow keys pan the view when it's focused, without hijacking the page's + arrow keys). Keyboard operability is satisfied for embedders, not just the demo. + +- Updated dependencies [[`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941), [`b671d02`](https://github.com/ChestnutLabs/gcode-preview/commit/b671d02179ba6cf30ce9888fa4b851328852e0f1), [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069), [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e)]: + - @chestnutlabs/gcode-renderer-three@0.5.0 + - @chestnutlabs/gcode-preview-core@0.5.0 + - @chestnutlabs/gcode-parser@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/gcode-preview-vue/package.json b/packages/gcode-preview-vue/package.json index c2da5933..de6db526 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.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/gcode-renderer-three": "0.4.0", - "@chestnutlabs/toolpath-core": "0.4.0", - "@chestnutlabs/gcode-preview-core": "0.4.0" + "@chestnutlabs/gcode-parser": "0.5.0", + "@chestnutlabs/gcode-renderer-three": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0", + "@chestnutlabs/gcode-preview-core": "0.5.0" }, "peerDependencies": { "vue": "^3.4.0" diff --git a/packages/gcode-preview-vue/src/gcode-preview-component.ts b/packages/gcode-preview-vue/src/gcode-preview-component.ts index aeb0018b..91a02352 100644 --- a/packages/gcode-preview-vue/src/gcode-preview-component.ts +++ b/packages/gcode-preview-vue/src/gcode-preview-component.ts @@ -13,11 +13,14 @@ import { defineComponent, h, onMounted, ref, watch, type PropType } from 'vue'; import type { BuildVolumeDef, CameraMode, + CameraState, + CameraView, ColorMode, QualityMode, Theme, TubeOptions } from '@chestnutlabs/gcode-renderer-three'; +import type { Confidence, Warning } from '@chestnutlabs/toolpath-core'; import type { MachineGeometry, ProgressObservation } from '@chestnutlabs/toolpath-core'; import type { WireParseOptions, WorkerLike } from '@chestnutlabs/gcode-parser'; import { @@ -44,6 +47,10 @@ export const GcodePreview = defineComponent({ quality: { type: String as PropType, default: 'auto' }, /** #150 (DD-009 D3): camera projection. */ cameraMode: { type: String as PropType, default: 'perspective' }, + /** #268/#275/M6: snap to a preset orientation (top/front/iso/…). */ + view: { type: String as PropType, default: undefined }, + /** #268/#275/M6: restore a saved camera pose. Pair with `@camera-change` for a two-way binding. */ + cameraState: { type: Object as PropType, default: undefined }, /** #153 (DD-009 D4): bounded declarative theme. */ theme: { type: Object as PropType, default: undefined }, colorMode: { type: Object as PropType, default: undefined }, @@ -80,7 +87,14 @@ export const GcodePreview = defineComponent({ }, emits: { /* eslint-disable @typescript-eslint/no-unused-vars -- emit validators document payloads */ - ready: (_summary: { segments: number; layers: number; complete: boolean }) => true, + ready: (_summary: { + segments: number; + layers: number; + complete: boolean; + capabilities: Record; + warnings: readonly Warning[]; + }) => true, + 'camera-change': (_state: CameraState) => true, 'parse-error': (_e: { code: string; message: string }) => true, 'parse-cancelled': () => true, 'parse-progress': (_p: { bytesProcessed: number; totalBytes: number }) => true, @@ -120,7 +134,16 @@ export const GcodePreview = defineComponent({ preview.onEvent((e: PreviewEvent) => { switch (e.type) { case 'parse-complete': - emit('ready', { segments: e.segments, layers: e.layers, complete: e.complete }); + emit('ready', { + segments: e.segments, + layers: e.layers, + complete: e.complete, + capabilities: e.capabilities, + warnings: e.warnings + }); + break; + case 'camera-changed': + emit('camera-change', e.state); break; case 'parse-error': emit('parse-error', { code: e.code, message: e.message }); @@ -205,6 +228,18 @@ export const GcodePreview = defineComponent({ () => props.cameraMode, (mode) => preview.controls.setCameraMode(mode) ); + watch( + () => props.view, + (view) => { + if (view !== undefined) preview.controls.setView(view); + } + ); + watch( + () => props.cameraState, + (state) => { + if (state !== undefined && state !== null) preview.controls.setCameraState(state); + } + ); watch( () => props.theme, (theme) => { @@ -237,6 +272,7 @@ export const GcodePreview = defineComponent({ h('canvas', { ref: canvasEl, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none' }, + tabindex: '0', // focusable → keyboard camera (DD-004 a11y, #275/M4) 'aria-label': '3D G-code toolpath preview' }); } diff --git a/packages/gcode-renderer-2d/CHANGELOG.md b/packages/gcode-renderer-2d/CHANGELOG.md index b947e1f5..3fb75506 100644 --- a/packages/gcode-renderer-2d/CHANGELOG.md +++ b/packages/gcode-renderer-2d/CHANGELOG.md @@ -1,5 +1,13 @@ # @chestnutlabs/gcode-renderer-2d +## 0.5.0 + +### Patch Changes + +- Updated dependencies []: + - @chestnutlabs/gcode-colors@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/gcode-renderer-2d/package.json b/packages/gcode-renderer-2d/package.json index 9aa60b83..5bd539e2 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.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-colors": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" } } diff --git a/packages/gcode-renderer-three/CHANGELOG.md b/packages/gcode-renderer-three/CHANGELOG.md index 10ff8a30..798c31d5 100644 --- a/packages/gcode-renderer-three/CHANGELOG.md +++ b/packages/gcode-renderer-three/CHANGELOG.md @@ -1,5 +1,60 @@ # @chestnutlabs/gcode-renderer-three +## 0.5.0 + +### Minor Changes + +- [#283](https://github.com/ChestnutLabs/gcode-preview/pull/283) [`804cafb`](https://github.com/ChestnutLabs/gcode-preview/commit/804cafb33f8f8be2617585156babf1221a856941) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Adapter surface: capabilities/warnings on `ready` + declarative `view`/`cameraState` ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275) M3+M6) + + **M3** — the `parse-complete` / `ready` event now carries `capabilities` (the per-field confidence + map) and `warnings` alongside `{ segments, layers, complete }`, so consumers can gate their own UI on + capability-honesty without reaching for the raw handle. + + **M6** — the `setView`/`getCameraState`/`setCameraState` methods ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) get first-class declarative + props on all four adapters: a `view` prop (preset orientation) and a `cameraState` prop (restore), + paired with a new **`camera-changed`** event (renderer → controller → adapters, emitted when a user + camera interaction settles) so a `cameraState` binding round-trips. The 2D renderer keeps disclosing + via `renderer-unsupported` rather than fabricating a pose. Behavioral-suite coverage added for the + capabilities/warnings payload across all four adapters. + +- [#270](https://github.com/ChestnutLabs/gcode-preview/pull/270) [`bb2af7a`](https://github.com/ChestnutLabs/gcode-preview/commit/bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Preset camera views + serializable camera state ([#268](https://github.com/ChestnutLabs/gcode-preview/issues/268)) + + Adds three imperative camera methods, threaded from the renderer through `PreviewRenderer` and the + `controls` handle into all four adapters: + - `setView(view)` — snap to a preset orientation (`top`/`bottom`/`front`/`back`/`left`/`right`/`iso`), + instant, preserving the active projection. + - `getCameraState()` — read the current camera as a serializable `CameraState` + (`{ position, target, zoom, cameraMode }`, scene coordinates); a stable contract a dashboard can + persist. + - `setCameraState(state)` — restore a snapshot verbatim (no re-fit to the current model). + + New public types `CameraView` and `CameraState`. No new dependency, no IR/schema change, no animation + (snapping is instant). The low-resource 2D renderer has no 3D pose, so it honors these as documented + disclosures (`getCameraState()` → `null`; `setView`/`setCameraState` → `renderer-unsupported`) rather + than fabricating a pose. Covered across all four adapters by the portable behavioral suite. + +### Patch Changes + +- [#269](https://github.com/ChestnutLabs/gcode-preview/pull/269) [`b671d02`](https://github.com/ChestnutLabs/gcode-preview/commit/b671d02179ba6cf30ce9888fa4b851328852e0f1) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Camera UX polish: enable OrbitControls affordances already available ([#267](https://github.com/ChestnutLabs/gcode-preview/issues/267)) + + Turns on `zoomToCursor` (wheel zoom moves toward the pointer, not the orbit target) and derives + `minDistance`/`maxDistance` clamps from the framed model size so the view can't dolly through the + model or lose it at the extremes. Clamps are recomputed in `frame()`, so they track each file's + bounds. Internal to `scene.ts` — no dependency, no public-API/adapter change; the headless + still-render path (no OrbitControls) is unaffected. + +- [#282](https://github.com/ChestnutLabs/gcode-preview/pull/282) [`54b54fe`](https://github.com/ChestnutLabs/gcode-preview/commit/54b54fe240e5ef7edae0e03e351127de531c5069) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Keyboard-operable camera for embedded viewers (DD-004 a11y) ([#275](https://github.com/ChestnutLabs/gcode-preview/issues/275)/M4) + + The embedded adapter canvases had `aria-label` but no `tabindex`, so they weren't focusable, and the + renderer never enabled OrbitControls key events — only the standalone demo page was keyboard-usable. + Now every adapter canvas is focusable (`tabindex="0"`) and the renderer enables OrbitControls keyboard + events scoped to the canvas (arrow keys pan the view when it's focused, without hijacking the page's + arrow keys). Keyboard operability is satisfied for embedders, not just the demo. + +- Updated dependencies []: + - @chestnutlabs/gcode-colors@0.5.0 + - @chestnutlabs/toolpath-core@0.5.0 + ## 0.4.0 ### Minor Changes diff --git a/packages/gcode-renderer-three/package.json b/packages/gcode-renderer-three/package.json index 78ac75ac..21b63dbd 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.4.0", + "version": "0.5.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.4.0", - "@chestnutlabs/toolpath-core": "0.4.0" + "@chestnutlabs/gcode-colors": "0.5.0", + "@chestnutlabs/toolpath-core": "0.5.0" }, "devDependencies": { "@types/three": "0.178.0" diff --git a/packages/gcode-renderer-three/src/__tests__/camera-mode.test.ts b/packages/gcode-renderer-three/src/__tests__/camera-mode.test.ts index 43c1e665..c4a1ab85 100644 --- a/packages/gcode-renderer-three/src/__tests__/camera-mode.test.ts +++ b/packages/gcode-renderer-three/src/__tests__/camera-mode.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { OrthographicCamera, PerspectiveCamera } from 'three'; import { MoveKind, ToolpathIRBuilder, type ToolpathIR } from '@chestnutlabs/toolpath-core'; -import { ToolpathRenderer, type CameraMode, type GLRendererLike } from '../index.js'; +import { ToolpathRenderer, type CameraMode, type CameraState, type CameraView, type GLRendererLike } from '../index.js'; function makeIR(): ToolpathIR { const b = new ToolpathIRBuilder({ parserVersion: 'test', units: 'mm', unitsSource: 'known' }); @@ -135,3 +135,83 @@ describe('orthographic camera (#150)', () => { renderer.dispose(); }); }); + +describe('preset views + camera state (#268)', () => { + /** Expected unit direction (scene coords) from the target to the camera, per preset. */ + const DIRS: Record = { + top: [0, 1, 0], + bottom: [0, -1, 0], + front: [0, 0, 1], + back: [0, 0, -1], + left: [-1, 0, 0], + right: [1, 0, 0], + iso: [1, 1, 1] + }; + const distance = (s: CameraState): number => + Math.hypot(s.position.x - s.target.x, s.position.y - s.target.y, s.position.z - s.target.z); + + it('each preset places the camera on the expected unit direction from the target', () => { + const { renderer, runTicks } = makeRenderer(); + renderer.setIR(makeIR()); + runTicks(); + for (const view of Object.keys(DIRS) as CameraView[]) { + renderer.setView(view); + const s = renderer.getCameraState(); + const off = [s.position.x - s.target.x, s.position.y - s.target.y, s.position.z - s.target.z]; + const len = Math.hypot(off[0], off[1], off[2]); + const dir = DIRS[view]; + const dl = Math.hypot(dir[0], dir[1], dir[2]); + expect(off[0] / len).toBeCloseTo(dir[0] / dl, 5); + expect(off[1] / len).toBeCloseTo(dir[1] / dl, 5); + expect(off[2] / len).toBeCloseTo(dir[2] / dl, 5); + } + renderer.dispose(); + }); + + it('setView preserves the active projection and the dolly distance', () => { + const { renderer, runTicks } = makeRenderer('orthographic'); + renderer.setIR(makeIR()); + runTicks(); + const d0 = distance(renderer.getCameraState()); + renderer.setView('iso'); + const s = renderer.getCameraState(); + expect(s.cameraMode).toBe('orthographic'); + expect(distance(s)).toBeCloseTo(d0, 3); + renderer.dispose(); + }); + + it('getCameraState → setCameraState restores position, target, zoom, and mode', () => { + const { renderer, runTicks } = makeRenderer(); + renderer.setIR(makeIR()); + runTicks(); + renderer.setView('right'); + const saved = renderer.getCameraState(); + renderer.setView('top'); // move somewhere else first + renderer.setCameraState(saved); + const back = renderer.getCameraState(); + expect(back.position.x).toBeCloseTo(saved.position.x, 6); + expect(back.position.y).toBeCloseTo(saved.position.y, 6); + expect(back.position.z).toBeCloseTo(saved.position.z, 6); + expect(back.target.x).toBeCloseTo(saved.target.x, 6); + expect(back.target.y).toBeCloseTo(saved.target.y, 6); + expect(back.target.z).toBeCloseTo(saved.target.z, 6); + expect(back.zoom).toBeCloseTo(saved.zoom, 6); + expect(back.cameraMode).toBe(saved.cameraMode); + renderer.dispose(); + }); + + it('setCameraState also restores the projection (ortho state onto a perspective renderer)', () => { + const { renderer, runTicks } = makeRenderer(); // starts perspective + renderer.setIR(makeIR()); + runTicks(); + renderer.setCameraState({ + position: { x: 10, y: 20, z: 30 }, + target: { x: 0, y: 0, z: 0 }, + zoom: 1, + cameraMode: 'orthographic' + }); + expect(renderer.cameraMode).toBe('orthographic'); + expect(renderer.camera).toBeInstanceOf(OrthographicCamera); + renderer.dispose(); + }); +}); diff --git a/packages/gcode-renderer-three/src/index.ts b/packages/gcode-renderer-three/src/index.ts index 07f07bef..1c64573e 100644 --- a/packages/gcode-renderer-three/src/index.ts +++ b/packages/gcode-renderer-three/src/index.ts @@ -21,7 +21,9 @@ export type { GLRendererLike, ProgressPresentationMode, QualityMode, - CameraMode + CameraMode, + CameraView, + CameraState } from './scene.js'; export { createBuildVolume } from './build-volume.js'; export type { BuildVolumeDef, BuildVolumeStyle } from './build-volume.js'; diff --git a/packages/gcode-renderer-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index 686f74e4..858c1d60 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -45,7 +45,7 @@ import { WebGLRenderer } from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import type { MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; +import type { MachineGeometry, MappedProgress, ToolpathIR, Vec3 } from '@chestnutlabs/toolpath-core'; import { autoDecimation, buildChunks, type ChunkBuildResult, type GeometryChunk } from './chunks.js'; import { buildChunkColors, type ColorMode } from './colors.js'; import { computeDrawState, computeOverlayDrawStates } from './ranges.js'; @@ -63,6 +63,40 @@ export type QualityMode = 'lines' | 'tubes'; */ export type CameraMode = 'perspective' | 'orthographic'; +/** + * Preset orientations for {@link ToolpathRenderer.setView} (#268). `iso` is the standard + * front-top-right corner; the six orthogonal views look along a principal axis. + */ +export type CameraView = 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso'; + +/** + * A serializable snapshot of the camera (#268), in **scene coordinates**. A stable contract a + * dashboard may persist across sessions: {@link ToolpathRenderer.getCameraState} reads it and + * {@link ToolpathRenderer.setCameraState} restores it verbatim (no re-fit to the current model — + * restoring onto a different model is the caller's choice). + */ +export interface CameraState { + position: Vec3; + target: Vec3; + /** Camera zoom factor (three's `camera.zoom`); mainly meaningful for the orthographic view. */ + zoom: number; + cameraMode: CameraMode; +} + +/** + * Unit direction (scene coords) from the framed target to the camera for each preset (#268). The + * root rotation maps printer (x,y,z) → scene (x, z, -y), so e.g. printer +Z "up" is scene +Y. + */ +const VIEW_DIRECTIONS: Record = { + top: [0, 1, 0], + bottom: [0, -1, 0], + front: [0, 0, 1], + back: [0, 0, -1], + left: [-1, 0, 0], + right: [1, 0, 0], + iso: [1, 1, 1] +}; + /** §4.3 `auto` decision, exported for tests and consumers. */ export function chooseQuality(requested: QualityMode | 'auto', totalSegments: number): QualityMode { if (requested !== 'auto') return requested; @@ -86,6 +120,10 @@ export type RendererEvent = | { type: 'previewAppend'; cumulativeSegments: number; decimationApplied: number } | { type: 'contextlost' } | { type: 'restored' } + /** The camera settled after a user interaction (orbit/pan/zoom/key) — carries the new serializable + * state so a consumer can persist "where the user was looking" (#275/M6). Programmatic setView / + * setCameraState do NOT emit this (they are consumer-driven already), so a bound prop can't loop. */ + | { type: 'camera-changed'; state: CameraState } | { type: 'error'; code: string; message: string }; /** @@ -226,6 +264,9 @@ export class ToolpathRenderer { private aspect = 1; /** Vertical half-height the camera frames, set by frame(); sizes the ortho frustum. */ private viewHalfHeight = 100; + /** Last framed orbit target (scene coords). Mirrors `controls.target`, and stands in for it on + * headless hosts (no OrbitControls) so setView/getCameraState work there too (#268). */ + private framedTarget = new Vector3(); private controls: OrbitControls | null = null; private ir: ToolpathIR | null = null; @@ -373,7 +414,20 @@ export class ToolpathRenderer { if (isHtmlCanvas(domEl)) { try { this.controls = new OrbitControls(this.activeCamera, domEl); + // Zoom toward the pointer rather than the orbit target — the affordance users expect from a + // 3D viewer, and free inside OrbitControls (#267). Distance clamps are derived from the framed + // model size and (re)applied in frame(), since they change per file. + this.controls.zoomToCursor = true; + // Keyboard-operable camera for embedders (DD-004 a11y, #275/M4): arrow keys pan the view when + // the canvas is focused. Scoped to the canvas element (not window) so an embedded viewer never + // hijacks the page's arrow keys; the adapters make the canvas focusable via `tabindex="0"`. + this.controls.listenToKeyEvents(domEl); this.controls.addEventListener('change', () => this.render()); + // Two-way camera state (#275/M6): after a user interaction settles, publish the new pose so a + // bound `cameraState` can persist it. `end` fires once per gesture (not per frame like `change`). + this.controls.addEventListener('end', () => + this.emit({ type: 'camera-changed', state: this.getCameraState() }) + ); } catch { this.controls = null; // headless hosts without full DOM events } @@ -1058,6 +1112,76 @@ export class ToolpathRenderer { this.render(); } + /** The live orbit target: `controls.target` when interactive, else the last framed target (#268). */ + private currentTarget(): Vector3 { + return this.controls ? this.controls.target.clone() : this.framedTarget.clone(); + } + + /** Screen-up for a view direction. World-up (0,1,0) is degenerate when looking straight up/down it, + * so top/bottom roll about scene −Z instead. Reproduces every preset's up *and* keeps setCameraState + * round-trips deterministic (the state contract carries no explicit up). */ + private upForViewDir(dir: Vector3): Vector3 { + return Math.abs(dir.y) > 0.999 ? new Vector3(0, 0, -1) : new Vector3(0, 1, 0); + } + + /** + * Snap to a preset orientation (#268): place the camera on the view's unit direction from the + * current target, at the current distance, and look at the target. Instant (no animation); + * preserves the active `cameraMode`. A no-op on a disposed renderer. + */ + setView(view: CameraView): void { + if (this.disposed) return; + const dir = new Vector3(...VIEW_DIRECTIONS[view]).normalize(); + const target = this.currentTarget(); + // Keep the current dolly distance; fall back to the framed distance if the camera sits on target. + const distance = this.activeCamera.position.distanceTo(target) || this.viewHalfHeight * 2.15; + this.activeCamera.up.copy(this.upForViewDir(dir)); + this.activeCamera.position.copy(target).addScaledVector(dir, distance); + this.activeCamera.lookAt(target); + this.framedTarget.copy(target); + this.updateCameraProjection(); + if (this.controls) { + this.controls.target.copy(target); + this.controls.update(); + } + this.render(); + } + + /** Read the current camera as a serializable {@link CameraState} (scene coords, #268). */ + getCameraState(): CameraState { + const t = this.currentTarget(); + const p = this.activeCamera.position; + return { + position: { x: p.x, y: p.y, z: p.z }, + target: { x: t.x, y: t.y, z: t.z }, + zoom: this.activeCamera.zoom, + cameraMode: this.cameraModeState + }; + } + + /** + * Restore a {@link CameraState} verbatim (#268) — position/target/zoom/mode as given, with no + * re-fit to the current model's bounds. Instant. A no-op on a disposed renderer. + */ + setCameraState(state: CameraState): void { + if (this.disposed) return; + this.setCameraMode(state.cameraMode); // no-op if unchanged; activates the right camera otherwise + const cam = this.activeCamera; + const target = new Vector3(state.target.x, state.target.y, state.target.z); + const dir = new Vector3(state.position.x, state.position.y, state.position.z).sub(target).normalize(); + cam.up.copy(this.upForViewDir(dir)); + cam.position.set(state.position.x, state.position.y, state.position.z); + cam.zoom = state.zoom; + cam.lookAt(target); + cam.updateProjectionMatrix(); + this.framedTarget.copy(target); + if (this.controls) { + this.controls.target.copy(target); + this.controls.update(); + } + this.render(); + } + /** Fit the camera to the toolpath bounds (falls back to the build volume). */ frame(): void { if (this.disposed) return; @@ -1084,11 +1208,17 @@ export class ToolpathRenderer { // ortho frustum uses the same half-height so toggling projection keeps the // model the same apparent size (#150). this.viewHalfHeight = radius * 1.25; + this.framedTarget.copy(target); this.activeCamera.position.set(target.x - radius * 1.2, target.y + radius * 1.6, target.z + radius * 1.8); this.activeCamera.lookAt(target); this.updateCameraProjection(); if (this.controls) { this.controls.target.copy(target); + // Bound zoom to the framed model so the user can't dolly through it or lose it at the extremes + // (#267). Recomputed here, not just at construction, because `radius` changes per file. The + // default framed distance is ≈2.69·radius, so this keeps a wide but finite range around it. + this.controls.minDistance = Math.max(1, radius * 0.15); + this.controls.maxDistance = radius * 30; this.controls.update(); } this.render(); diff --git a/packages/toolpath-core/CHANGELOG.md b/packages/toolpath-core/CHANGELOG.md index 4c0cb7be..8ca631e3 100644 --- a/packages/toolpath-core/CHANGELOG.md +++ b/packages/toolpath-core/CHANGELOG.md @@ -1,5 +1,7 @@ # @chestnutlabs/toolpath-core +## 0.5.0 + ## 0.4.0 ### Minor Changes diff --git a/packages/toolpath-core/package.json b/packages/toolpath-core/package.json index 47d2a5c9..f47aacea 100644 --- a/packages/toolpath-core/package.json +++ b/packages/toolpath-core/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/toolpath-core", - "version": "0.4.0", + "version": "0.5.0", "description": "Neutral ToolpathIR and capability model for the Chestnut Labs G-code Preview toolpath stack.", "keywords": [ "gcode", diff --git a/tools/demo/index.html b/tools/demo/index.html index b4aea5ae..7e2ffd8f 100644 --- a/tools/demo/index.html +++ b/tools/demo/index.html @@ -91,6 +91,18 @@ opacity: 0.45; cursor: default; } + .preset-views { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 4px 0; + } + .preset-views button { + flex: 1 1 auto; + min-width: 44px; + padding: 4px 6px; + font-size: 12px; + } button.primary { background: var(--accent); border-color: var(--accent); @@ -184,6 +196,11 @@

Scrub (segments): all + +

Estimated print time: —

@@ -226,6 +243,19 @@

+
+ + + + + + + +
+
+ + +

diff --git a/tools/demo/src/main.js b/tools/demo/src/main.js index b80f144c..bdaaea0c 100644 --- a/tools/demo/src/main.js +++ b/tools/demo/src/main.js @@ -6,7 +6,7 @@ */ import { GcodeParseSession, CancelledError } from '@chestnutlabs/gcode-parser'; import { ToolpathRenderer } from '@chestnutlabs/gcode-renderer-three'; -import { createProgressMapper } from '@chestnutlabs/toolpath-core'; +import { createProgressMapper, computeToolpathTime, segmentsCompletedAtTime } from '@chestnutlabs/toolpath-core'; import { downloadToolpathStl } from './stl-export.js'; // Inherited MIT demo corpus (see test-data/manifest.json), served by Vite's publicDir. @@ -96,6 +96,11 @@ const els = { qualityNote: $('qualityNote'), frame: $('frame'), exportStl: $('exportStl'), + timeScrub: $('timeScrub'), + timeScrubVal: $('timeScrubVal'), + printTimeNote: $('printTimeNote'), + saveView: $('saveView'), + restoreView: $('restoreView'), disclosure: $('disclosure'), stats: $('stats'), progressTier: $('progressTier'), @@ -158,6 +163,42 @@ function applyScrub() { renderer.setScrubPosition(all ? null : v); } +// #276: kinematic time axis backs the time-scrub; the slicer estimate (when present) is the +// displayed total. The provenance (slicer vs kinematic) is shown honestly. +let timeAxis = null; +let savedCameraState = null; + +function fmtTime(ms) { + const min = ms / 60000; + return min >= 1 ? `${min.toFixed(1)} min` : `${(ms / 1000).toFixed(1)} s`; +} + +function setupTimeScrub(ir, metadata) { + timeAxis = computeToolpathTime(ir); + const slicerSeconds = metadata?.printEstimate?.seconds; + const totalMs = slicerSeconds !== undefined ? slicerSeconds * 1000 : timeAxis.totalMs; + const source = slicerSeconds !== undefined ? 'slicer' : 'kinematic'; + els.timeScrub.max = String(Math.max(1, Math.round(timeAxis.totalMs))); + els.timeScrub.value = els.timeScrub.max; + els.timeScrub.disabled = false; + els.timeScrubVal.textContent = 'all'; + const caveat = + source === 'slicer' + ? ' (slicer estimate)' + : ` (kinematic — constant-velocity approximation${ + timeAxis.hasUnknownFeedrate ? ', lower bound: some feedrates unknown' : '' + })`; + els.printTimeNote.textContent = `Estimated print time: ${fmtTime(totalMs)}${caveat}`; +} + +function applyTimeScrub() { + if (timeAxis === null) return; + const ms = Number(els.timeScrub.value); + const all = ms >= Number(els.timeScrub.max); + renderer.setScrubPosition(all ? null : segmentsCompletedAtTime(timeAxis.cumulativeMs, ms)); + els.timeScrubVal.textContent = all ? 'all' : fmtTime(ms); +} + function enableControls(ir) { const lastLayer = Math.max(0, renderer.layerCount - 1); els.startLayer.max = String(lastLayer); @@ -169,8 +210,9 @@ function enableControls(ir) { els.scrub.max = String(renderer.segmentCount); els.scrub.value = String(renderer.segmentCount); els.scrubVal.textContent = 'all'; - for (const el of [els.startLayer, els.endLayer, els.scrub, els.colorMode, els.frame, els.exportStl]) + for (const el of [els.startLayer, els.endLayer, els.scrub, els.colorMode, els.frame, els.exportStl, els.saveView]) el.disabled = false; + for (const btn of document.querySelectorAll('.view-btn')) btn.disabled = false; // Capability-honest color modes (§4.6): never offer fabricated feature colors. const featureOpt = els.colorMode.querySelector('option[value="feature"]'); @@ -278,6 +320,7 @@ async function parseAndRender() { enableSim(ir, bytes); // DD-005 §4.2: this demo opts into file-discovered bed geometry (arrives // with the phase-2/3 adapters); mismatches surface via the renderer event. + setupTimeScrub(ir, result.metadata); const machine = result.metadata?.machine; bedNote = machine ? ` · bed from file: ${machine.printerName ?? 'unknown printer'} (${machine.confidence})` : ''; if (machine) { @@ -430,6 +473,18 @@ const applyTheme = () => renderer.setTheme(themeFor(els.theme.value, els.materia els.theme.addEventListener('change', applyTheme); els.material.addEventListener('change', applyTheme); els.frame.addEventListener('click', () => renderer.frame()); +els.timeScrub.addEventListener('input', applyTimeScrub); +// #276: preset views + save/restore camera state (exercises setView / get+setCameraState). +for (const btn of document.querySelectorAll('.view-btn')) { + btn.addEventListener('click', () => renderer.setView(btn.dataset.view)); +} +els.saveView.addEventListener('click', () => { + savedCameraState = renderer.getCameraState(); + els.restoreView.disabled = false; +}); +els.restoreView.addEventListener('click', () => { + if (savedCameraState !== null) renderer.setCameraState(savedCameraState); +}); els.exportStl.addEventListener('click', () => { const ir = renderer.ir; if (!ir) return;