From 77df389f5f675d37134f59d24520657e8e707506 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Thu, 27 Aug 2026 13:39:24 -0700 Subject: [PATCH] feat(renderer): interactive view capture() -> Blob (DD-030 D1) Add capture(opts?) that returns the currently displayed view as an image Blob, for user-selected thumbnails, large-file fallback, and screenshots. Available on GcodePreviewControls.capture (all 4 adapters inherit it; the Web Component adds an imperative method) and ModelViewer.capture; implemented on InteractiveStage and delegated by ToolpathRenderer. Mechanism = render-to-target: render the current scene + active camera into an off-screen WebGLRenderTarget at the requested size and read it back, so it never flips the interactive preserveDrawingBuffer default nor disturbs the live view, and supports an arbitrary output size + independent/transparent background. The library returns the Blob and never triggers a download (caller owns the pixels, same contract as renderStill). Honest: capture() rejects with a typed CaptureUnsupportedError (E_CAPTURE_UNSUPPORTED) when the renderer cannot render-to-target (2D renderer, stub GL / no WebGL) or the stage is disposed/context-lost. Purely additive (new optional method on the renderer contract). Final increment of the DD-030 batch. Co-Authored-By: Claude Opus 4.8 --- .changeset/dd030-capture.md | 32 +++++++ .../gcode-model-renderer/src/model-viewer.ts | 13 +++ packages/gcode-preview-core/src/controller.ts | 12 +++ packages/gcode-preview-core/src/index.ts | 3 + .../src/renderer-interface.ts | 9 +- .../src/gcode-preview-element.ts | 5 + .../src/__tests__/capture.test.ts | 51 ++++++++++ packages/gcode-renderer-three/src/capture.ts | 94 +++++++++++++++++++ packages/gcode-renderer-three/src/index.ts | 2 + .../src/interactive-stage.ts | 56 ++++++++++- packages/gcode-renderer-three/src/scene.ts | 6 ++ packages/gcode-renderer-three/src/stage.ts | 20 +++- tools/pack-check-snapshots.json | 3 + 13 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 .changeset/dd030-capture.md create mode 100644 packages/gcode-renderer-three/src/__tests__/capture.test.ts create mode 100644 packages/gcode-renderer-three/src/capture.ts diff --git a/.changeset/dd030-capture.md b/.changeset/dd030-capture.md new file mode 100644 index 00000000..866c0329 --- /dev/null +++ b/.changeset/dd030-capture.md @@ -0,0 +1,32 @@ +--- +"@chestnutlabs/gcode-renderer-three": minor +"@chestnutlabs/gcode-model-renderer": minor +"@chestnutlabs/gcode-preview-core": minor +"@chestnutlabs/gcode-preview-element": minor +--- + +feat(renderer): interactive view capture() → Blob (DD-030 D1) + +The interactive viewer can now hand back **what is on screen right now** as an image `Blob` — for a +user-selected thumbnail, a large-file thumbnail fallback, or a screenshot. New `capture(opts?)` where +`opts` is `{ width?, height?, format?, quality?, background? }` (all optional; defaults match the live +view). + +Available on every interactive surface: `GcodePreviewControls.capture()` (so the Vue, React, Svelte, and +Web-Component adapters all inherit it — the Web Component also exposes an imperative `capture()` method), +and `ModelViewer.capture()` on the model-viewer handle. The toolpath `ToolpathRenderer` and the shared +`InteractiveStage` carry the implementation. + +**Mechanism (render-to-target).** Capture renders the current scene + active camera into an off-screen +`WebGLRenderTarget` at the requested size and reads it back, rather than flipping the interactive +context's `preserveDrawingBuffer` (which would tax every interactive frame). That gives an arbitrary +output size and an independent/transparent background **without** disturbing the live view, and reuses the +headless still path's "single render, then read pixels" recipe. The thumbnail is framed at its own aspect +so it isn't distorted; the live view is repainted afterward. The library returns the `Blob` and **never** +triggers a download — the caller owns the pixels (same contract as `renderStill`). + +**Honest.** When the renderer cannot render-to-target (the 2D renderer, a stub GL / no WebGL) or the stage +is disposed / its context is lost, `capture()` rejects with a typed `CaptureUnsupportedError` +(`code: 'E_CAPTURE_UNSUPPORTED'`) — never fabricated output. Purely additive (a new optional method on the +renderer contract; no existing signature changed). Final increment of the DD-030 renderer/viewer +interoperability batch (bed + per-plate scope + capture). diff --git a/packages/gcode-model-renderer/src/model-viewer.ts b/packages/gcode-model-renderer/src/model-viewer.ts index 6f79e175..426dc05e 100644 --- a/packages/gcode-model-renderer/src/model-viewer.ts +++ b/packages/gcode-model-renderer/src/model-viewer.ts @@ -14,11 +14,13 @@ */ import { Color, Scene } from 'three'; import { + CaptureUnsupportedError, InteractiveStage, createDefaultGLRenderer, type CameraMode, type CameraState, type CameraView, + type CaptureOptions, type GLRendererLike, type InteractiveStageOptions, type LoadProgress, @@ -117,6 +119,11 @@ export interface ModelViewer { * `{ plateId }` on the ready info's `plates` being present — an unmatched scope renders empty. */ setRenderScope(scope: RenderScope | null): void; + /** + * Capture the current view as an image `Blob` (DD-030 D1) — for an operator-selected thumbnail or a + * large-file fallback. Rejects with `E_CAPTURE_UNSUPPORTED` when the renderer is unavailable. + */ + capture(opts?: CaptureOptions): Promise; onEvent(cb: (e: ModelViewerEvent) => void): () => void; dispose(): void; } @@ -278,6 +285,12 @@ export function createModelViewer(canvas: RenderTargetCanvas, options: ModelView if (f !== null) stage.frameTo(f.center, f.radius); stage.render(); }, + capture(opts) { + if (stage === null) { + return Promise.reject(new CaptureUnsupportedError('model viewer renderer unavailable')); + } + return stage.capture(opts); + }, onEvent(cb) { listeners.add(cb); if (pending.length > 0) { diff --git a/packages/gcode-preview-core/src/controller.ts b/packages/gcode-preview-core/src/controller.ts index 8546f782..e6ec5034 100644 --- a/packages/gcode-preview-core/src/controller.ts +++ b/packages/gcode-preview-core/src/controller.ts @@ -23,6 +23,7 @@ import type { CameraMode, CameraState, CameraView, + CaptureOptions, ColorMode, GLRendererLike, ProgressPresentationMode, @@ -33,6 +34,7 @@ import type { Theme, TubeOptions } from '@chestnutlabs/gcode-renderer-three'; +import { CaptureUnsupportedError } from '@chestnutlabs/gcode-renderer-three'; import { LayerView2DRenderer } from './renderer-2d-adapter.js'; import type { MoveKindToggle, PreviewRenderer, PreviewRendererEvent, RendererMode } from './renderer-interface.js'; import { @@ -208,6 +210,12 @@ export interface GcodePreviewControls { /** Interaction-aware quality: 'auto' reduces detail while the camera moves (#306/2, DD-020). */ setInteractionQuality(mode: 'off' | 'auto'): void; frame(): void; + /** + * Capture the currently displayed view as an image `Blob` (DD-030 D1) — for a user-selected thumbnail, + * a large-file fallback, or a screenshot. Rejects with `E_CAPTURE_UNSUPPORTED` before the 3D renderer + * is ready or on the 2D renderer (which cannot render-to-target). The caller owns the `Blob`. + */ + capture(opts?: CaptureOptions): Promise; } export interface PreviewController { @@ -556,6 +564,10 @@ export function createPreviewController(options: PreviewControllerOptions = {}): setView: (v) => withRenderer((r) => r.setView(v)), // Returns a value, so it can't queue: before the renderer is ready (or on 2D) there is no pose → null. getCameraState: () => (renderer !== null ? renderer.getCameraState() : null), + capture: (opts) => + renderer !== null && typeof renderer.capture === 'function' + ? renderer.capture(opts) + : Promise.reject(new CaptureUnsupportedError('capture unavailable: renderer not ready or unsupported')), setCameraState: (s) => withRenderer((r) => r.setCameraState(s)), setTheme: (t) => withRenderer((r) => r.setTheme(t)), setBuildVolume: (def) => { diff --git a/packages/gcode-preview-core/src/index.ts b/packages/gcode-preview-core/src/index.ts index ba58bc05..56709ff5 100644 --- a/packages/gcode-preview-core/src/index.ts +++ b/packages/gcode-preview-core/src/index.ts @@ -13,3 +13,6 @@ export { renderStill, type RenderStillOptions, type RenderStillResult, type Stil export type { Theme, MaterialPreset, ThemeColor } from '@chestnutlabs/gcode-renderer-three'; // Camera contracts (three-free type re-exports): projection + preset views + serializable state (#268). export type { CameraMode, CameraView, CameraState } from '@chestnutlabs/gcode-renderer-three'; +// Interactive view capture (DD-030 D1): the options type + the typed unsupported error for consumers. +export type { CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; +export { CaptureUnsupportedError } from '@chestnutlabs/gcode-renderer-three'; diff --git a/packages/gcode-preview-core/src/renderer-interface.ts b/packages/gcode-preview-core/src/renderer-interface.ts index 04a7704b..4d6a7555 100644 --- a/packages/gcode-preview-core/src/renderer-interface.ts +++ b/packages/gcode-preview-core/src/renderer-interface.ts @@ -13,6 +13,7 @@ import type { CameraMode, CameraState, CameraView, + CaptureOptions, ColorMode, ProgressivePreview, QualityMode, @@ -21,7 +22,7 @@ import type { Theme } from '@chestnutlabs/gcode-renderer-three'; -export type { CameraState, CameraView } from '@chestnutlabs/gcode-renderer-three'; +export type { CameraState, CameraView, CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; import type { MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; /** Which renderer implementation backs the preview. `'3d'` is the default (Three.js). */ @@ -87,5 +88,11 @@ export interface PreviewRenderer { */ pickSegment(ndcX: number, ndcY: number, threshold?: number): number | null; frame(): void; + /** + * Capture the current view as an image `Blob` (DD-030 D1). Optional: the 3D renderer implements it via + * render-to-target; a renderer that cannot (the 2D renderer) omits it, and the controller reports + * `renderer-unsupported` / rejects. Never fabricated output. + */ + capture?(opts?: CaptureOptions): Promise; dispose(): void; } diff --git a/packages/gcode-preview-element/src/gcode-preview-element.ts b/packages/gcode-preview-element/src/gcode-preview-element.ts index af5bc07d..9c5adae3 100644 --- a/packages/gcode-preview-element/src/gcode-preview-element.ts +++ b/packages/gcode-preview-element/src/gcode-preview-element.ts @@ -12,6 +12,7 @@ */ import { createPreviewController, + type CaptureOptions, type GcodePreviewControls, type GcodePreviewState, type ParseOutcome, @@ -269,6 +270,10 @@ export class GcodePreviewElement extends HTMLElement { parse(input: Uint8Array | ArrayBuffer | File, opts?: WireParseOptions): Promise { return this.requireController().parse(input, opts); } + /** Capture the current view as an image `Blob` (DD-030 D1). Delegates to `controls.capture`. */ + capture(opts?: CaptureOptions): Promise { + return this.requireController().controls.capture(opts); + } observeProgress(obs: ProgressObservation): MappedProgress | null { return this.controller?.observeProgress(obs) ?? null; } diff --git a/packages/gcode-renderer-three/src/__tests__/capture.test.ts b/packages/gcode-renderer-three/src/__tests__/capture.test.ts new file mode 100644 index 00000000..4b9f94e5 --- /dev/null +++ b/packages/gcode-renderer-three/src/__tests__/capture.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +/** + * DD-030 D1 — interactive capture. The GL-free halves (row flip, size resolution) are unit-tested here; + * the unsupported path is verified through `ToolpathRenderer.capture()` with a stub GL that cannot + * render-to-target. The full pixel path (render-target readback + canvas encode) is browser-only and is + * validated in the browser, not in Node. + */ +import { describe, expect, it } from 'vitest'; +import { ToolpathRenderer, type GLRendererLike } from '../index.js'; +import { flipRowsRGBA, resolveCaptureSize } from '../capture.js'; + +describe('capture — pure helpers', () => { + it('flipRowsRGBA flips rows top↔bottom (2 rows, 1px each)', () => { + const src = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); // row0 = 1..4, row1 = 5..8 + const out = flipRowsRGBA(src, 1, 2); + expect(Array.from(out)).toEqual([5, 6, 7, 8, 1, 2, 3, 4]); // rows swapped + }); + + it('flipRowsRGBA is a no-op for a single row', () => { + const src = new Uint8Array([9, 8, 7, 6]); + expect(Array.from(flipRowsRGBA(src, 1, 1))).toEqual([9, 8, 7, 6]); + }); + + it('resolveCaptureSize defaults to the buffer size, honors overrides, rounds and clamps to ≥1', () => { + expect(resolveCaptureSize({}, 800, 600)).toEqual({ w: 800, h: 600 }); + expect(resolveCaptureSize({ width: 256 }, 800, 600)).toEqual({ w: 256, h: 600 }); + expect(resolveCaptureSize({ width: 100.6, height: 50.2 }, 800, 600)).toEqual({ w: 101, h: 50 }); + expect(resolveCaptureSize({ width: 0, height: -5 }, 800, 600)).toEqual({ w: 1, h: 1 }); + }); +}); + +describe('capture — unsupported path', () => { + it('rejects with E_CAPTURE_UNSUPPORTED when the renderer cannot render-to-target', async () => { + const canvas = document.createElement('canvas'); + const stub: GLRendererLike = { + render: () => undefined, + setSize: () => undefined, + dispose: () => undefined, + domElement: canvas + // no setRenderTarget / readRenderTargetPixels — a stub GL cannot capture + }; + const renderer = new ToolpathRenderer({ + canvas, + quality: 'lines', + createRenderer: () => stub, + scheduleFrame: () => undefined + }); + await expect(renderer.capture()).rejects.toMatchObject({ code: 'E_CAPTURE_UNSUPPORTED' }); + renderer.dispose(); + }); +}); diff --git a/packages/gcode-renderer-three/src/capture.ts b/packages/gcode-renderer-three/src/capture.ts new file mode 100644 index 00000000..9d42f554 --- /dev/null +++ b/packages/gcode-renderer-three/src/capture.ts @@ -0,0 +1,94 @@ +/** + * Interactive view capture (DD-030 D1). Turn "what is on screen right now" into an image `Blob` — + * for a user-selected thumbnail, a large-file fallback, a screenshot, any consumer that needs the + * currently displayed view. The real capture (render-to-target + readback) lives on `InteractiveStage` + * where the GL/scene/camera are; this module holds the vendor-neutral option type and the pure, + * GL-free helpers (row flip, RGBA→Blob encode) so they are unit-testable without a WebGL context. + * + * Design (DD-030 D1): capture renders the current scene + active camera into an **off-screen render + * target** and reads it back, rather than flipping the interactive context's `preserveDrawingBuffer` + * (which taxes every interactive frame). That gives an arbitrary output size and an independent / + * transparent background without disturbing the live view, and reuses the headless still path's + * "single render, then read pixels" recipe. The library returns the `Blob`; it never triggers a + * download (the caller owns the pixels, same contract as `renderStill`). + */ +import type { ThemeColor } from './theme.js'; + +/** Thrown when `capture()` cannot run — no render-to-target support (stub GL / no WebGL), or the + * stage is disposed / its context is lost. Carries a stable `code` for consumer branching. */ +export class CaptureUnsupportedError extends Error { + readonly code = 'E_CAPTURE_UNSUPPORTED'; + constructor(message: string) { + super(message); + this.name = 'CaptureUnsupportedError'; + } +} + +/** Options for an interactive view capture. All optional; sensible defaults match the live view. */ +export interface CaptureOptions { + /** Output width in pixels (default: the current drawing-buffer width). */ + width?: number; + /** Output height in pixels (default: the current drawing-buffer height). */ + height?: number; + /** Encoded image type (default `'image/png'`). */ + format?: 'image/png' | 'image/jpeg' | 'image/webp'; + /** Quality 0..1 for lossy formats (`jpeg`/`webp`); ignored for `png`. */ + quality?: number; + /** Background for the capture: `'transparent'`, or a solid color. Default: the live scene background. */ + background?: 'transparent' | ThemeColor; +} + +/** Resolve the capture pixel size from options + the live drawing-buffer size (both clamped to ≥1). */ +export function resolveCaptureSize(opts: CaptureOptions, bufferW: number, bufferH: number): { w: number; h: number } { + const w = Math.max(1, Math.round(opts.width ?? bufferW)); + const h = Math.max(1, Math.round(opts.height ?? bufferH)); + return { w, h }; +} + +/** + * WebGL `readRenderTargetPixels` returns rows bottom-to-top; canvas `ImageData` is top-to-bottom. + * Return a vertically-flipped copy of the RGBA buffer. Pure — the load-bearing, testable half of capture. + */ +export function flipRowsRGBA(src: Uint8Array, width: number, height: number): Uint8Array { + const stride = width * 4; + const out = new Uint8Array(src.length); + for (let y = 0; y < height; y++) { + const from = y * stride; + const to = (height - 1 - y) * stride; + out.set(src.subarray(from, from + stride), to); + } + return out; +} + +/** + * Encode a top-down RGBA buffer into an image `Blob` via a 2D canvas. Uses `OffscreenCanvas` when + * available (workers + modern browsers), else a DOM ``. Browser/worker only — there is no + * canvas 2D encoder in bare Node, so this is exercised in the browser, not unit tests. + */ +export async function encodeRGBAToBlob( + rgba: Uint8Array, + width: number, + height: number, + format: string, + quality?: number +): Promise { + const canvas = makeCanvas2D(width, height); + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; + if (ctx === null) throw new Error('capture: 2D canvas context unavailable'); + const clamped = new Uint8ClampedArray(rgba.buffer, rgba.byteOffset, rgba.byteLength); + ctx.putImageData(new ImageData(clamped, width, height), 0, 0); + if ('convertToBlob' in canvas) { + return canvas.convertToBlob({ type: format, quality }); + } + return new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('capture: toBlob returned null'))), format, quality); + }); +} + +function makeCanvas2D(width: number, height: number): OffscreenCanvas | HTMLCanvasElement { + if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(width, height); + const c = document.createElement('canvas'); + c.width = width; + c.height = height; + return c; +} diff --git a/packages/gcode-renderer-three/src/index.ts b/packages/gcode-renderer-three/src/index.ts index ea2a6a5a..414c3ad5 100644 --- a/packages/gcode-renderer-three/src/index.ts +++ b/packages/gcode-renderer-three/src/index.ts @@ -36,6 +36,8 @@ export type { } from './scene.js'; export { createBuildVolume } from './build-volume.js'; export type { BuildVolumeDef, BuildVolumeStyle, BedShape } from './build-volume.js'; +export { CaptureUnsupportedError } from './capture.js'; +export type { CaptureOptions } from './capture.js'; export { framingFromCenterRadius, createDefaultGLRenderer } from './stage.js'; export type { Framing, DefaultGLOptions } from './stage.js'; export { InteractionQualityController } from './interaction-quality.js'; diff --git a/packages/gcode-renderer-three/src/interactive-stage.ts b/packages/gcode-renderer-three/src/interactive-stage.ts index 06e12ff0..825f92d6 100644 --- a/packages/gcode-renderer-three/src/interactive-stage.ts +++ b/packages/gcode-renderer-three/src/interactive-stage.ts @@ -9,15 +9,23 @@ * The camera types live here (not in the toolpath renderer) so the stage has no dependency on * toolpath concepts; the toolpath renderer re-exports them for import-path stability. */ -import { OrthographicCamera, PerspectiveCamera, Scene, Vector3 } from 'three'; +import { Color, OrthographicCamera, PerspectiveCamera, Scene, Vector3, WebGLRenderTarget } from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { Vec3 } from '@chestnutlabs/toolpath-core'; import { createDefaultGLRenderer, framingFromCenterRadius, + supportsCapture, type GLRendererLike, type RenderTargetCanvas } from './stage.js'; +import { + CaptureUnsupportedError, + encodeRGBAToBlob, + flipRowsRGBA, + resolveCaptureSize, + type CaptureOptions +} from './capture.js'; import { InteractionQualityController } from './interaction-quality.js'; /** @@ -334,6 +342,52 @@ export class InteractiveStage { this.gl.render(this.scene, this.activeCameraRef); } + /** + * Capture the currently displayed view as an image `Blob` (DD-030 D1). Renders the current scene + + * active camera into an off-screen target at the requested size (so it never disturbs the live view + * nor requires `preserveDrawingBuffer`), reads it back, and encodes it. Rejects with an + * `E_CAPTURE_UNSUPPORTED` error when the renderer cannot render-to-target (a stub GL / no WebGL) or the + * stage is disposed / context-lost. The caller owns the returned `Blob` — the library never downloads. + */ + async capture(opts: CaptureOptions = {}): Promise { + if (this.disposedFlag || this.contextLost || !supportsCapture(this.gl)) { + const why = this.disposedFlag + ? 'stage disposed' + : this.contextLost + ? 'WebGL context lost' + : 'renderer cannot render-to-target'; + throw new CaptureUnsupportedError(`capture unavailable: ${why}`); + } + const gl = this.gl; + const { w, h } = resolveCaptureSize(opts, this.canvas.width, this.canvas.height); + const target = new WebGLRenderTarget(w, h); + // Frame the capture at its OWN aspect (not the viewport's) so a differently-shaped thumbnail is not + // distorted; reuse the stage's projection machinery, then restore the live aspect. + const prevAspect = this.aspect; + const prevBackground = this.scene.background; + const overrideBackground = opts.background !== undefined; + try { + if (overrideBackground) { + this.scene.background = + opts.background === 'transparent' ? null : new Color(opts.background as string | number); + } + this.aspect = w / h; + this.updateCameraProjection(); + gl.setRenderTarget(target); + gl.render(this.scene, this.activeCameraRef); + const buffer = new Uint8Array(w * h * 4); + gl.readRenderTargetPixels(target, 0, 0, w, h, buffer); + gl.setRenderTarget(null); + return await encodeRGBAToBlob(flipRowsRGBA(buffer, w, h), w, h, opts.format ?? 'image/png', opts.quality); + } finally { + target.dispose(); + if (overrideBackground) this.scene.background = prevBackground; + this.aspect = prevAspect; + this.updateCameraProjection(); + this.render(); // repaint the live canvas (we redirected the renderer to a target) + } + } + dispose(): void { if (this.disposedFlag) return; this.disposedFlag = true; diff --git a/packages/gcode-renderer-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index b5df671b..e46402dc 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -63,6 +63,7 @@ import type { GeometryBuildRequest, GeometryBuildResponse } from './geometry-wor import { estimateTubeBuildMs, POOL_ENGAGE_MS, SINGLE_REVEAL_MS } from './render-cost.js'; import { resolveTheme, type Theme, type ResolvedTheme } from './theme.js'; import { InteractiveStage, type CameraMode, type CameraView, type CameraState } from './interactive-stage.js'; +import type { CaptureOptions } from './capture.js'; /** §4.3 quality tiers. `auto` picks by segment count (chooseQuality). */ export type QualityMode = 'lines' | 'tubes'; @@ -1442,6 +1443,11 @@ export class ToolpathRenderer { this.stage.setCameraState(state); } + /** Capture the current view as an image `Blob` (DD-030 D1) — delegates to the shared stage. */ + capture(opts?: CaptureOptions): Promise { + return this.stage.capture(opts); + } + /** * Choose the bounds to frame to (#306/#6, DD-026 D5). `frameContent: 'object'` frames the printed * **model**, excluding skirt/prime/purge/tower, using the classifier's `modelBounds` when available, diff --git a/packages/gcode-renderer-three/src/stage.ts b/packages/gcode-renderer-three/src/stage.ts index e7372d94..6e682a4d 100644 --- a/packages/gcode-renderer-three/src/stage.ts +++ b/packages/gcode-renderer-three/src/stage.ts @@ -9,7 +9,7 @@ * It is grown incrementally and each move is gated by the toolpath renderer's existing golden/visual * parity (the extraction must be behavior-preserving for the toolpath side). */ -import { Camera, Scene, Vector3, WebGLRenderer } from 'three'; +import { Camera, Scene, Vector3, WebGLRenderer, type WebGLRenderTarget } from 'three'; /** * Render target: a DOM canvas (interactive hosts) or an OffscreenCanvas (workers / headless @@ -26,6 +26,24 @@ export interface GLRendererLike { setPixelRatio?(ratio: number): void; dispose(): void; domElement: RenderTargetCanvas; + // Render-target readback for interactive `capture()` (DD-030 D1). Optional: a real `WebGLRenderer` + // provides both; a stub GL (tests / no WebGL) provides neither, so `capture()` reports unsupported. + setRenderTarget?(target: WebGLRenderTarget | null): void; + readRenderTargetPixels?( + target: WebGLRenderTarget, + x: number, + y: number, + width: number, + height: number, + buffer: Uint8Array + ): void; +} + +/** True when the renderer can render-to-target + read back — the requirement for `capture()`. */ +export function supportsCapture( + gl: GLRendererLike +): gl is GLRendererLike & Required> { + return typeof gl.setRenderTarget === 'function' && typeof gl.readRenderTargetPixels === 'function'; } /** Options for {@link createDefaultGLRenderer}. */ diff --git a/tools/pack-check-snapshots.json b/tools/pack-check-snapshots.json index 672b5e30..e7242db2 100644 --- a/tools/pack-check-snapshots.json +++ b/tools/pack-check-snapshots.json @@ -163,6 +163,9 @@ "dist/capability.d.ts", "dist/capability.d.ts.map", "dist/capability.js", + "dist/capture.d.ts", + "dist/capture.d.ts.map", + "dist/capture.js", "dist/chunks.d.ts", "dist/chunks.d.ts.map", "dist/chunks.js",