From b671d02179ba6cf30ce9888fa4b851328852e0f1 Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Thu, 13 Aug 2026 22:46:34 -0700 Subject: [PATCH 1/9] feat(renderer-three): enable OrbitControls zoom-to-cursor + distance clamps (#267) (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn on zoomToCursor (wheel zoom toward the pointer) and derive minDistance/ maxDistance from the framed model size, recomputed in frame() so they track each file's bounds — the user can't dolly through or lose the model. Internal to scene.ts; no dependency, no public-API/adapter change; headless still-render path unaffected. Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- .changeset/camera-ux-orbitcontrols.md | 11 +++++++++++ packages/gcode-renderer-three/src/scene.ts | 9 +++++++++ 2 files changed, 20 insertions(+) create mode 100644 .changeset/camera-ux-orbitcontrols.md diff --git a/.changeset/camera-ux-orbitcontrols.md b/.changeset/camera-ux-orbitcontrols.md new file mode 100644 index 00000000..3c3de5ad --- /dev/null +++ b/.changeset/camera-ux-orbitcontrols.md @@ -0,0 +1,11 @@ +--- +"@chestnutlabs/gcode-renderer-three": patch +--- + +Camera UX polish: enable OrbitControls affordances already available (#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. diff --git a/packages/gcode-renderer-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index 686f74e4..e4b62aed 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -373,6 +373,10 @@ 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; this.controls.addEventListener('change', () => this.render()); } catch { this.controls = null; // headless hosts without full DOM events @@ -1089,6 +1093,11 @@ export class ToolpathRenderer { 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(); From bb2af7a4b9c433ef8caf59ecb5ece51f39a8eb9e Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Thu, 13 Aug 2026 22:46:37 -0700 Subject: [PATCH 2/9] feat: preset camera views + serializable camera state across adapters (#268) (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 camera as a serializable CameraState { position, target, zoom, cameraMode } (scene coords) — a stable contract a dashboard can persist. - setCameraState(state): restore a snapshot verbatim, no re-fit to the model. New public types CameraView + CameraState (re-exported three-free from core). No dependency, no IR/schema change, no animation. The 2D renderer honors the methods as documented disclosures (getCameraState -> null; setView/ setCameraState -> renderer-unsupported) rather than fabricating a 3D pose. Covered by the portable behavioral suite across all 4 adapters + focused renderer-three unit tests (all 7 presets, distance/mode preservation, exact round-trip). Verified live with real OrbitControls. Minor changeset (lockstep). Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- .changeset/preset-views-camera-state.md | 25 ++++ docs/manual/recipes.md | 22 ++++ packages/gcode-preview-core/src/controller.ts | 14 +++ packages/gcode-preview-core/src/index.ts | 2 + .../src/renderer-2d-adapter.ts | 22 +++- .../src/renderer-interface.ts | 10 ++ packages/gcode-preview-core/src/testing.ts | 54 ++++++++- .../src/__tests__/behavioral-suite.test.ts | 3 + .../src/__tests__/camera-mode.test.ts | 82 ++++++++++++- packages/gcode-renderer-three/src/index.ts | 4 +- packages/gcode-renderer-three/src/scene.ts | 110 +++++++++++++++++- 11 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 .changeset/preset-views-camera-state.md diff --git a/.changeset/preset-views-camera-state.md b/.changeset/preset-views-camera-state.md new file mode 100644 index 00000000..8bba96f8 --- /dev/null +++ b/.changeset/preset-views-camera-state.md @@ -0,0 +1,25 @@ +--- +"@chestnutlabs/gcode-renderer-three": minor +"@chestnutlabs/gcode-preview-core": minor +"@chestnutlabs/gcode-preview-vue": minor +"@chestnutlabs/gcode-preview-react": minor +"@chestnutlabs/gcode-preview-svelte": minor +"@chestnutlabs/gcode-preview-element": minor +--- + +Preset camera views + serializable camera state (#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. diff --git a/docs/manual/recipes.md b/docs/manual/recipes.md index 6c610e2d..26fc92b3 100644 --- a/docs/manual/recipes.md +++ b/docs/manual/recipes.md @@ -25,6 +25,28 @@ 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. + ## `.gcode.3mf` multi-plate `.gcode.3mf` containers can hold several sliced plates. Select one with `parseOptions.plate`: diff --git a/packages/gcode-preview-core/src/controller.ts b/packages/gcode-preview-core/src/controller.ts index 306c3f4e..722ffc33 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, @@ -141,6 +143,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. */ @@ -441,6 +451,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..d81dde6b 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,43 @@ export function runBehavioralSuite(name: string, api: TestApi, harness: AdapterH 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-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-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 e4b62aed..f6d72414 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; @@ -226,6 +260,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; @@ -1062,6 +1099,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; @@ -1088,6 +1195,7 @@ 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(); From 07b3cb402d9aba0e82c8e547d2bd45b95c51dd2d Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Fri, 14 Aug 2026 08:27:48 -0700 Subject: [PATCH 3/9] docs: refresh stale compat matrix + README adapter count (#273) (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q1: docs/compatibility/dialects-and-containers.md now matches the code and the sign-offs — grbl-laser is `validated` / claims `known` (hardware-validated 2026-07-29, DD-012 log), and the container security-review rows read "signed off" (DD-005 containers 2026-07-23, DD-011 bgcode 2026-07-28) instead of "awaiting sign-off". Q4: README describes FOUR equal adapters — Vue, React, Svelte, and the framework-neutral Web Component — in the lead, the adapters bullet, and the shared-option-surface paragraph. Docs only, no changeset. Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- README.md | 14 ++++++++------ docs/compatibility/dialects-and-containers.md | 11 ++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) 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 From 746930ee13b759ae3625b455a09670f8d7ef9850 Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Fri, 14 Aug 2026 08:27:52 -0700 Subject: [PATCH 4/9] =?UTF-8?q?test:=20harden=20CNC/laser=20parsing=20cove?= =?UTF-8?q?rage=20=E2=80=94=20adversarial=20+=20hostile=20cycles=20(#277)?= =?UTF-8?q?=20(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-only; no code fix surfaced (the v0.4.0 non-extrusion paths are already robust). Adds: - M1/M2 CNC adversarial tier (cnc-adversarial.test.ts): malformed multi-command lines, overflow N-numbers, bare G/S garbage, and hostile canned cycles — through BOTH the in-memory and streaming drivers, all bounded. Proves G83 pathological tiny-Q is stopped by the segment budget (no hang), Q=0 falls back to a single plunge, a first cycle missing Z/R collapses to the zero plane (never a fabricated deep plunge), and Z/R are correctly retained across a modal repeat (RS274NGC). - M5 gaps: G82 (dwell drill) single-plunge expansion; GRBL $32=1 laser-mode detection branch. (toolPower NaN-off + its color-mapping fallback were already covered in the parser + gcode-colors suites.) Fixtures are synthetic MIT-clean inline strings, never committed third-party files. Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- .../gcode-dialects/src/__tests__/cnc.test.ts | 7 ++ .../src/__tests__/cnc-adversarial.test.ts | 84 +++++++++++++++++++ .../src/__tests__/non-extrusion.test.ts | 10 +++ 3 files changed, 101 insertions(+) create mode 100644 packages/gcode-parser/src/__tests__/cnc-adversarial.test.ts 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/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; From c5ba45adea953aeb9a39db234e966d3804046564 Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Fri, 14 Aug 2026 08:27:57 -0700 Subject: [PATCH 5/9] feat(demo): surface preset views, save/restore, and time-scrub/print-time (#276) (#284) The two most recent headline features shipped dark in the showcase. Add to tools/demo: - 7 preset-view buttons (top/bottom/front/back/left/right/iso) exercising renderer.setView, plus Save view / Restore view exercising getCameraState/setCameraState. - A time-based scrub (kinematic time axis via computeToolpathTime + segmentsCompletedAtTime) and an estimated-print-time readout that shows the slicer|kinematic provenance honestly (slicer estimate when present; else kinematic constant-velocity, flagged as a lower bound when feedrates are unknown). Verified live: calicat shows "37.3 min (slicer estimate)", time-scrub maps to segments, presets + save/restore work. No changeset (tools/demo unpublished). Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- tools/demo/index.html | 30 +++++++++++++++++++++ tools/demo/src/main.js | 59 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 2 deletions(-) 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; From 775598fcde26d19c72567725b7f78dd16fc14c8e Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Fri, 14 Aug 2026 08:28:01 -0700 Subject: [PATCH 6/9] fix(svelte): make buildVolume reactive after mount (#274) (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(svelte): make buildVolume reactive after mount (#274) The Svelte shell applied buildVolume once at init with no reactive statement, so post-mount changes were a silent no-op — a parity break vs Vue (watch) and React (useEffect). It was the only writable prop missing a $: wiring. Replace the one-time call with a reactive statement matching the twelve siblings. Guard: a zero-dependency source-invariant test asserts every writable prop is reactively wired (or a documented init-only renderer option). The package has no component-mount harness — its vitest runs in node with no Svelte compiler plugin, and the portable behavioral suite drives the store, not the .svelte component — so this static invariant is the enforceable guard; verified it fails when the fix is removed. Patch changeset (gcode-preview-svelte). Co-Authored-By: Claude Opus 4.8 * fix(svelte): read component source via ?raw, not node:fs (browser-only rule) The prop-reactivity guard used node:fs, which the svelte package's no-restricted-imports rule bans (browser-only, DD-007 §7) — caught in CI. Switch to a Vite ?raw import of the .svelte source; same invariant, no Node imports. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- .changeset/svelte-buildvolume-reactive.md | 11 +++++ .../src/GcodePreview.svelte | 6 ++- .../src/__tests__/prop-reactivity.test.ts | 49 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 .changeset/svelte-buildvolume-reactive.md create mode 100644 packages/gcode-preview-svelte/src/__tests__/prop-reactivity.test.ts diff --git a/.changeset/svelte-buildvolume-reactive.md b/.changeset/svelte-buildvolume-reactive.md new file mode 100644 index 00000000..10d3e147 --- /dev/null +++ b/.changeset/svelte-buildvolume-reactive.md @@ -0,0 +1,11 @@ +--- +"@chestnutlabs/gcode-preview-svelte": patch +--- + +Fix: Svelte `buildVolume` is reactive after mount (parity with Vue/React) (#274) + +The Svelte shell applied `buildVolume` once at init with no reactive statement, so changing it after +mount was a silent no-op — a cross-adapter parity break (Vue watches it; React re-applies via +`useEffect`). It was the only writable prop missing a `$:` wiring. Now a post-mount `buildVolume` +change re-applies through the handle, matching the other twelve props. A source-invariant test guards +that every writable prop stays reactively wired (the shell has no component-mount harness). diff --git a/packages/gcode-preview-svelte/src/GcodePreview.svelte b/packages/gcode-preview-svelte/src/GcodePreview.svelte index be0df806..cfbb9c70 100644 --- a/packages/gcode-preview-svelte/src/GcodePreview.svelte +++ b/packages/gcode-preview-svelte/src/GcodePreview.svelte @@ -68,8 +68,6 @@ parseDefaults: parseOptions }); - if (isMachine) preview.controls.setBuildVolume(buildVolume); - preview.onEvent((e) => { switch (e.type) { case 'parse-complete': @@ -111,6 +109,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]); 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/); + }); +}); From 54b54fe240e5ef7edae0e03e351127de531c5069 Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Fri, 14 Aug 2026 08:28:05 -0700 Subject: [PATCH 7/9] feat(a11y): keyboard-operable camera for embedded viewers (#275/M4) (#282) Adapter canvases had aria-label but no tabindex (not focusable) and the renderer never enabled OrbitControls key events, so only the standalone demo was keyboard-usable. Add tabindex="0" to all four adapter canvases and enable OrbitControls listenToKeyEvents scoped to the canvas (arrow-key pan when focused; no page-level key hijack). Element a11y test asserts the focusable, labelled canvas. DD-004 keyboard operability now holds for embedders. Patch changesets: renderer-three + 4 adapters. Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- .changeset/keyboard-camera-a11y.md | 15 +++++++++++++ .../src/__tests__/keyboard-a11y.test.ts | 22 +++++++++++++++++++ .../src/gcode-preview-element.ts | 1 + .../src/gcode-preview-component.ts | 1 + .../src/GcodePreview.svelte | 2 ++ .../src/gcode-preview-component.ts | 1 + packages/gcode-renderer-three/src/scene.ts | 4 ++++ 7 files changed, 46 insertions(+) create mode 100644 .changeset/keyboard-camera-a11y.md create mode 100644 packages/gcode-preview-element/src/__tests__/keyboard-a11y.test.ts diff --git a/.changeset/keyboard-camera-a11y.md b/.changeset/keyboard-camera-a11y.md new file mode 100644 index 00000000..a300abe3 --- /dev/null +++ b/.changeset/keyboard-camera-a11y.md @@ -0,0 +1,15 @@ +--- +"@chestnutlabs/gcode-renderer-three": patch +"@chestnutlabs/gcode-preview-vue": patch +"@chestnutlabs/gcode-preview-react": patch +"@chestnutlabs/gcode-preview-svelte": patch +"@chestnutlabs/gcode-preview-element": patch +--- + +Keyboard-operable camera for embedded viewers (DD-004 a11y) (#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. 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..84b53aa6 100644 --- a/packages/gcode-preview-element/src/gcode-preview-element.ts +++ b/packages/gcode-preview-element/src/gcode-preview-element.ts @@ -218,6 +218,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; diff --git a/packages/gcode-preview-react/src/gcode-preview-component.ts b/packages/gcode-preview-react/src/gcode-preview-component.ts index 600caedb..e3d81dea 100644 --- a/packages/gcode-preview-react/src/gcode-preview-component.ts +++ b/packages/gcode-preview-react/src/gcode-preview-component.ts @@ -215,6 +215,7 @@ function GcodePreviewImpl(props: GcodePreviewProps, ref: ForwardedRef preview.dispose()); + diff --git a/packages/gcode-preview-vue/src/gcode-preview-component.ts b/packages/gcode-preview-vue/src/gcode-preview-component.ts index aeb0018b..b4d00138 100644 --- a/packages/gcode-preview-vue/src/gcode-preview-component.ts +++ b/packages/gcode-preview-vue/src/gcode-preview-component.ts @@ -237,6 +237,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-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index f6d72414..c9c26a67 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -414,6 +414,10 @@ export class ToolpathRenderer { // 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()); } catch { this.controls = null; // headless hosts without full DOM events From 804cafb33f8f8be2617585156babf1221a856941 Mon Sep 17 00:00:00 2001 From: "Nathaniel \"Sobe\" Chestnut" Date: Fri, 14 Aug 2026 08:28:32 -0700 Subject: [PATCH 8/9] feat: capabilities/warnings on ready + declarative view/cameraState props (#275 M3+M6) (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3: the parse-complete/ready event now carries capabilities (per-field confidence map) + warnings, so adapter consumers can gate their own UI on capability-honesty without the raw handle. Behavioral-suite case added ×4. M6: setView/getCameraState/setCameraState (#268) get first-class declarative props on all four adapters — a `view` prop (preset) and a `cameraState` prop (restore) — paired with a new `camera-changed` event (renderer emits on OrbitControls interaction end → controller → adapters) so cameraState round-trips as a two-way binding. 2D keeps disclosing via renderer-unsupported. Manual: camera two-way binding + capabilities-on-ready notes. Minor changesets (renderer-three + core + 4 adapters, lockstep). Co-authored-by: Nathaniel Chestnut Co-authored-by: Claude Opus 4.8 --- .changeset/adapter-surface-m3-m6.md | 21 ++++++++++ docs/manual/recipes.md | 17 ++++++++ packages/gcode-preview-core/src/controller.ts | 18 ++++++-- packages/gcode-preview-core/src/testing.ts | 16 +++++++ .../src/gcode-preview-element.ts | 42 ++++++++++++++++++- .../src/gcode-preview-component.ts | 35 +++++++++++++++- .../src/GcodePreview.svelte | 17 +++++++- .../src/gcode-preview-component.ts | 39 ++++++++++++++++- packages/gcode-renderer-three/src/scene.ts | 9 ++++ 9 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 .changeset/adapter-surface-m3-m6.md diff --git a/.changeset/adapter-surface-m3-m6.md b/.changeset/adapter-surface-m3-m6.md new file mode 100644 index 00000000..aec7fab8 --- /dev/null +++ b/.changeset/adapter-surface-m3-m6.md @@ -0,0 +1,21 @@ +--- +"@chestnutlabs/gcode-renderer-three": minor +"@chestnutlabs/gcode-preview-core": minor +"@chestnutlabs/gcode-preview-vue": minor +"@chestnutlabs/gcode-preview-react": minor +"@chestnutlabs/gcode-preview-svelte": minor +"@chestnutlabs/gcode-preview-element": minor +--- + +Adapter surface: capabilities/warnings on `ready` + declarative `view`/`cameraState` (#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) 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. diff --git a/docs/manual/recipes.md b/docs/manual/recipes.md index 26fc92b3..9d0b7efa 100644 --- a/docs/manual/recipes.md +++ b/docs/manual/recipes.md @@ -47,6 +47,23 @@ controls.setCameraState(view); // restores verbatim — no re-fit to the current 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/packages/gcode-preview-core/src/controller.ts b/packages/gcode-preview-core/src/controller.ts index 722ffc33..fc1e6044 100644 --- a/packages/gcode-preview-core/src/controller.ts +++ b/packages/gcode-preview-core/src/controller.ts @@ -44,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. */ @@ -52,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 } | { @@ -412,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) { diff --git a/packages/gcode-preview-core/src/testing.ts b/packages/gcode-preview-core/src/testing.ts index d81dde6b..43b24425 100644 --- a/packages/gcode-preview-core/src/testing.ts +++ b/packages/gcode-preview-core/src/testing.ts @@ -153,6 +153,22 @@ 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)); diff --git a/packages/gcode-preview-element/src/gcode-preview-element.ts b/packages/gcode-preview-element/src/gcode-preview-element.ts index 84b53aa6..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'; @@ -263,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; @@ -329,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/src/gcode-preview-component.ts b/packages/gcode-preview-react/src/gcode-preview-component.ts index e3d81dea..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]); diff --git a/packages/gcode-preview-svelte/src/GcodePreview.svelte b/packages/gcode-preview-svelte/src/GcodePreview.svelte index e3e4b136..ed685bc9 100644 --- a/packages/gcode-preview-svelte/src/GcodePreview.svelte +++ b/packages/gcode-preview-svelte/src/GcodePreview.svelte @@ -22,6 +22,10 @@ export let quality = 'auto'; /** #150 (DD-009 D3): camera projection ('perspective' | 'orthographic'). */ export let cameraMode = 'perspective'; + /** #268/#275/M6: snap to a preset orientation (top/front/iso/…). */ + export let view = undefined; + /** #268/#275/M6: restore a saved camera pose. Pair with the `camerachange` event for two-way. */ + export let cameraState = undefined; /** #153 (DD-009 D4): bounded declarative theme. */ export let theme = undefined; export let colorMode = undefined; @@ -71,7 +75,16 @@ preview.onEvent((e) => { 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 }); @@ -124,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); diff --git a/packages/gcode-preview-vue/src/gcode-preview-component.ts b/packages/gcode-preview-vue/src/gcode-preview-component.ts index b4d00138..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) => { diff --git a/packages/gcode-renderer-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index c9c26a67..858c1d60 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -120,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 }; /** @@ -419,6 +423,11 @@ export class ToolpathRenderer { // 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 } From 89fa98a9d11f299faef7a2113686c94f11059c9f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:52:47 -0700 Subject: [PATCH 9/9] release: version packages (lockstep) (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: version packages (lockstep) * ci: trigger required checks on the v0.5.0 version PR (#272) Changesets' GITHUB_TOKEN push doesn't trigger workflows (release gotcha #1), so the version PR's required build check never ran → BLOCKED. This empty commit kicks CI so #271 can merge. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Nathaniel Chestnut --- .changeset/adapter-surface-m3-m6.md | 21 ----- .changeset/camera-ux-orbitcontrols.md | 11 --- .changeset/keyboard-camera-a11y.md | 15 ---- .changeset/preset-views-camera-state.md | 25 ------ .changeset/svelte-buildvolume-reactive.md | 11 --- package-lock.json | 92 ++++++++++----------- packages/gcode-bgcode/CHANGELOG.md | 8 ++ packages/gcode-bgcode/package.json | 6 +- packages/gcode-colors/CHANGELOG.md | 7 ++ packages/gcode-colors/package.json | 4 +- packages/gcode-containers/CHANGELOG.md | 7 ++ packages/gcode-containers/package.json | 4 +- packages/gcode-dialects/CHANGELOG.md | 7 ++ packages/gcode-dialects/package.json | 4 +- packages/gcode-parser/CHANGELOG.md | 10 +++ packages/gcode-parser/package.json | 10 +-- packages/gcode-preview-core/CHANGELOG.md | 41 +++++++++ packages/gcode-preview-core/package.json | 10 +-- packages/gcode-preview-element/CHANGELOG.md | 49 +++++++++++ packages/gcode-preview-element/package.json | 10 +-- packages/gcode-preview-react/CHANGELOG.md | 49 +++++++++++ packages/gcode-preview-react/package.json | 10 +-- packages/gcode-preview-svelte/CHANGELOG.md | 57 +++++++++++++ packages/gcode-preview-svelte/package.json | 10 +-- packages/gcode-preview-vue/CHANGELOG.md | 49 +++++++++++ packages/gcode-preview-vue/package.json | 10 +-- packages/gcode-renderer-2d/CHANGELOG.md | 8 ++ packages/gcode-renderer-2d/package.json | 6 +- packages/gcode-renderer-three/CHANGELOG.md | 55 ++++++++++++ packages/gcode-renderer-three/package.json | 6 +- packages/toolpath-core/CHANGELOG.md | 2 + packages/toolpath-core/package.json | 2 +- 32 files changed, 441 insertions(+), 175 deletions(-) delete mode 100644 .changeset/adapter-surface-m3-m6.md delete mode 100644 .changeset/camera-ux-orbitcontrols.md delete mode 100644 .changeset/keyboard-camera-a11y.md delete mode 100644 .changeset/preset-views-camera-state.md delete mode 100644 .changeset/svelte-buildvolume-reactive.md diff --git a/.changeset/adapter-surface-m3-m6.md b/.changeset/adapter-surface-m3-m6.md deleted file mode 100644 index aec7fab8..00000000 --- a/.changeset/adapter-surface-m3-m6.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@chestnutlabs/gcode-renderer-three": minor -"@chestnutlabs/gcode-preview-core": minor -"@chestnutlabs/gcode-preview-vue": minor -"@chestnutlabs/gcode-preview-react": minor -"@chestnutlabs/gcode-preview-svelte": minor -"@chestnutlabs/gcode-preview-element": minor ---- - -Adapter surface: capabilities/warnings on `ready` + declarative `view`/`cameraState` (#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) 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. diff --git a/.changeset/camera-ux-orbitcontrols.md b/.changeset/camera-ux-orbitcontrols.md deleted file mode 100644 index 3c3de5ad..00000000 --- a/.changeset/camera-ux-orbitcontrols.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@chestnutlabs/gcode-renderer-three": patch ---- - -Camera UX polish: enable OrbitControls affordances already available (#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. diff --git a/.changeset/keyboard-camera-a11y.md b/.changeset/keyboard-camera-a11y.md deleted file mode 100644 index a300abe3..00000000 --- a/.changeset/keyboard-camera-a11y.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@chestnutlabs/gcode-renderer-three": patch -"@chestnutlabs/gcode-preview-vue": patch -"@chestnutlabs/gcode-preview-react": patch -"@chestnutlabs/gcode-preview-svelte": patch -"@chestnutlabs/gcode-preview-element": patch ---- - -Keyboard-operable camera for embedded viewers (DD-004 a11y) (#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. diff --git a/.changeset/preset-views-camera-state.md b/.changeset/preset-views-camera-state.md deleted file mode 100644 index 8bba96f8..00000000 --- a/.changeset/preset-views-camera-state.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@chestnutlabs/gcode-renderer-three": minor -"@chestnutlabs/gcode-preview-core": minor -"@chestnutlabs/gcode-preview-vue": minor -"@chestnutlabs/gcode-preview-react": minor -"@chestnutlabs/gcode-preview-svelte": minor -"@chestnutlabs/gcode-preview-element": minor ---- - -Preset camera views + serializable camera state (#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. diff --git a/.changeset/svelte-buildvolume-reactive.md b/.changeset/svelte-buildvolume-reactive.md deleted file mode 100644 index 10d3e147..00000000 --- a/.changeset/svelte-buildvolume-reactive.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@chestnutlabs/gcode-preview-svelte": patch ---- - -Fix: Svelte `buildVolume` is reactive after mount (parity with Vue/React) (#274) - -The Svelte shell applied `buildVolume` once at init with no reactive statement, so changing it after -mount was a silent no-op — a cross-adapter parity break (Vue watches it; React re-applies via -`useEffect`). It was the only writable prop missing a `$:` wiring. Now a post-mount `buildVolume` -change re-applies through the handle, matching the other twelve props. A source-invariant test guards -that every writable prop stays reactively wired (the shell has no component-mount harness). 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-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-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-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-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-svelte/CHANGELOG.md b/packages/gcode-preview-svelte/CHANGELOG.md index da69cef6..2d9a34ae 100644 --- a/packages/gcode-preview-svelte/CHANGELOG.md +++ b/packages/gcode-preview-svelte/CHANGELOG.md @@ -1,5 +1,62 @@ # @chestnutlabs/gcode-preview-svelte +## 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. + +- [#281](https://github.com/ChestnutLabs/gcode-preview/pull/281) [`775598f`](https://github.com/ChestnutLabs/gcode-preview/commit/775598fcde26d19c72567725b7f78dd16fc14c8e) Thanks [@sobechestnut-dev](https://github.com/sobechestnut-dev)! - Fix: Svelte `buildVolume` is reactive after mount (parity with Vue/React) ([#274](https://github.com/ChestnutLabs/gcode-preview/issues/274)) + + The Svelte shell applied `buildVolume` once at init with no reactive statement, so changing it after + mount was a silent no-op — a cross-adapter parity break (Vue watches it; React re-applies via + `useEffect`). It was the only writable prop missing a `$:` wiring. Now a post-mount `buildVolume` + change re-applies through the handle, matching the other twelve props. A source-invariant test guards + that every writable prop stays reactively wired (the shell has no component-mount harness). + +- 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-svelte/package.json b/packages/gcode-preview-svelte/package.json index e1b53813..0b03f99a 100644 --- a/packages/gcode-preview-svelte/package.json +++ b/packages/gcode-preview-svelte/package.json @@ -1,6 +1,6 @@ { "name": "@chestnutlabs/gcode-preview-svelte", - "version": "0.4.0", + "version": "0.5.0", "description": "Thin Svelte integration for the Chestnut Labs G-code viewer (DD-007 D1 amendment): createGcodePreview store/action API + GcodePreview component as a reactivity bridge over @chestnutlabs/gcode-preview-core.", "keywords": [ "gcode", @@ -55,10 +55,10 @@ "test": "vitest run" }, "dependencies": { - "@chestnutlabs/gcode-parser": "0.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": { "svelte": "^4.0.0 || ^5.0.0" 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-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/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",