Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/dd030-capture.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 13 additions & 0 deletions packages/gcode-model-renderer/src/model-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Blob>;
onEvent(cb: (e: ModelViewerEvent) => void): () => void;
dispose(): void;
}
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions packages/gcode-preview-core/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
CameraMode,
CameraState,
CameraView,
CaptureOptions,
ColorMode,
GLRendererLike,
ProgressPresentationMode,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Blob>;
}

export interface PreviewController {
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/gcode-preview-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
9 changes: 8 additions & 1 deletion packages/gcode-preview-core/src/renderer-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
CameraMode,
CameraState,
CameraView,
CaptureOptions,
ColorMode,
ProgressivePreview,
QualityMode,
Expand All @@ -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). */
Expand Down Expand Up @@ -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<Blob>;
dispose(): void;
}
5 changes: 5 additions & 0 deletions packages/gcode-preview-element/src/gcode-preview-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/
import {
createPreviewController,
type CaptureOptions,
type GcodePreviewControls,
type GcodePreviewState,
type ParseOutcome,
Expand Down Expand Up @@ -269,6 +270,10 @@ export class GcodePreviewElement extends HTMLElement {
parse(input: Uint8Array | ArrayBuffer | File, opts?: WireParseOptions): Promise<ParseOutcome> {
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<Blob> {
return this.requireController().controls.capture(opts);
}
observeProgress(obs: ProgressObservation): MappedProgress | null {
return this.controller?.observeProgress(obs) ?? null;
}
Expand Down
51 changes: 51 additions & 0 deletions packages/gcode-renderer-three/src/__tests__/capture.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
94 changes: 94 additions & 0 deletions packages/gcode-renderer-three/src/capture.ts
Original file line number Diff line number Diff line change
@@ -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 `<canvas>`. 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<Blob> {
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<Blob>((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;
}
2 changes: 2 additions & 0 deletions packages/gcode-renderer-three/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading