@chestnutlabs/gcode-model-renderer 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,6 +91,46 @@ viewer's API (design: [DD-021](../../docs/design/DD-021-interactive-model-viewer
91
91
  Prefer to build your own scene? `parseStl` / `parse3mf` return a three-free `ModelScene`, and both the
92
92
  still and the viewer accept a pre-built `ModelScene` directly.
93
93
 
94
+ ## Framework-neutral Prepare controller
95
+
96
+ `createModelPreviewController` wraps `createModelViewer` in the controller shape the framework adapters
97
+ consume — it is the Prepare-side analogue of the toolpath core controller. On top of the raw viewer it
98
+ adds a canvas-`bindCanvas` lifecycle (rebind survives a remount; the last source and any queued control
99
+ ops replay on bind), a single reactive state snapshot (`getState()` / `onStateChange()`), and an op
100
+ queue so calls made before the canvas exists are not lost. It backs the `/model` subpath of the Vue,
101
+ React, Svelte, and Web Component packages; use it directly to build your own adapter.
102
+
103
+ ```ts
104
+ import { createModelPreviewController } from '@chestnutlabs/gcode-model-renderer';
105
+
106
+ const controller = createModelPreviewController();
107
+ const off = controller.onStateChange((s) => {
108
+ // s: ModelPreviewState — loading, ready, rendererSupported, objectCount, materials,
109
+ // instancedCount, decimationApplied, bounds, plates, hasPlates, cameraState, progress, error
110
+ });
111
+ controller.bindCanvas(canvas); // a real <canvas> in the page
112
+ await controller.controls.setSource({ kind: '3mf', bytes });
113
+ // ...on teardown:
114
+ off();
115
+ controller.dispose();
116
+ ```
117
+
118
+ The two contract types are `ModelPreviewControls` (`setSource` / `setView` / `getCameraState` /
119
+ `setCameraState` / `setBackground` / `setInteractionQuality` / `setRenderScope` / `frame` / `resize` /
120
+ `capture`) and the `ModelPreviewState` snapshot above; `materials` in that snapshot is the same honest
121
+ tier the still and viewer report (`'known'` / `'approximated'` / `'unavailable'`, never invented).
122
+
123
+ A portable behavioral suite ships at the `/testing` subpath: `runModelBehavioralSuite(name, api,
124
+ harness)` runs one shared set of assertions against any adapter, so every framework's `<ModelViewer>`
125
+ is verified against the same contract as the core controller.
126
+
127
+ ```ts
128
+ import { runModelBehavioralSuite } from '@chestnutlabs/gcode-model-renderer/testing';
129
+ ```
130
+
131
+ See [`docs/manual/adapters.md`](../../docs/manual/adapters.md) "Two viewers: Preview and Prepare" for
132
+ the cross-adapter tour of the four framework wrappers.
133
+
94
134
  Determinism (stills): same input + same environment ⇒ identical output. Cross-GPU/driver pixel identity
95
135
  is not promised — cache by the returned `cacheKey`.
96
136
 
package/dist/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export { renderModelStill } from './render-model-still.js';
12
12
  export type { ModelSource, RenderModelStillOptions, RenderModelStillResult } from './render-model-still.js';
13
13
  export { createModelViewer } from './model-viewer.js';
14
14
  export type { ModelViewer, ModelViewerOptions, ModelViewerEvent, ModelReadyInfo } from './model-viewer.js';
15
+ export { createModelPreviewController, INITIAL_MODEL_STATE } from './model-preview.js';
16
+ export type { ModelPreviewController, ModelPreviewControllerOptions, ModelPreviewControls, ModelPreviewState } from './model-preview.js';
15
17
  export { parseStl } from './stl.js';
16
18
  export { parse3mf } from './three-mf.js';
17
19
  export type { Parse3mfOptions } from './three-mf.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC5E,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,WAAW,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAC5G,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAC3G,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,qBAAqB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAChH,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACpF,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACnE,YAAY,EACV,UAAU,EACV,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,WAAW,EACX,GAAG,EACH,IAAI,EACL,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC5E,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,WAAW,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAC5G,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAG3G,OAAO,EAAE,4BAA4B,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACvF,YAAY,EACV,sBAAsB,EACtB,6BAA6B,EAC7B,oBAAoB,EACpB,iBAAiB,EAClB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,qBAAqB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAChH,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACpF,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACnE,YAAY,EACV,UAAU,EACV,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,WAAW,EACX,GAAG,EACH,IAAI,EACL,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@
9
9
  export { ModelRenderer, NEUTRAL_MATERIAL_COLOR } from './model-renderer.js';
10
10
  export { renderModelStill } from './render-model-still.js';
11
11
  export { createModelViewer } from './model-viewer.js';
12
+ // DD-031: framework-neutral controller for the interactive model viewer (the Prepare-side analogue of
13
+ // gcode-preview-core's toolpath controller). The four framework `/model` adapters are thin shells over it.
14
+ export { createModelPreviewController, INITIAL_MODEL_STATE } from './model-preview.js';
12
15
  export { parseStl } from './stl.js';
13
16
  export { parse3mf } from './three-mf.js';
14
17
  export { stlLoader, threeMfLoader, DEFAULT_MODEL_LOADERS, isModelScene, resolveModelScene } from './loaders.js';
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `createModelPreviewController` (DD-031) — the framework-neutral **controller** for interactive
3
+ * source-model viewing, the Prepare-side analogue of `@chestnutlabs/gcode-preview-core`'s
4
+ * `createPreviewController` (the Preview/toolpath side). The four framework adapters
5
+ * (`@chestnutlabs/gcode-preview-{vue,react,svelte,element}/model`) are thin shells over THIS one
6
+ * contract, exactly as they are over the toolpath controller — so model viewing gains real declarative
7
+ * parity (lifecycle, options, events, capability exposure, cleanup, error handling) instead of every
8
+ * consumer dropping to the imperative `createModelViewer` engine.
9
+ *
10
+ * It wraps the existing {@link createModelViewer} engine unchanged (no renderer redesign — DD-031
11
+ * guardrail); the controller adds only what a framework binding needs and the engine doesn't provide:
12
+ * - **canvas-deferred construction** — the engine takes its canvas at construction, but a framework
13
+ * attaches the canvas via a ref *after* the component mounts, so the controller creates the engine
14
+ * lazily on `bindCanvas` and rebuilds it if the canvas is replaced (StrictMode remounts, etc.),
15
+ * replaying the last source so the model survives a remount;
16
+ * - a **reactive state snapshot** (`getState`/`onStateChange`) derived from the engine's events, which
17
+ * is what `useSyncExternalStore` (React), `shallowReactive` (Vue), and the store contract (Svelte)
18
+ * subscribe to; the engine itself only emits events + a couple of getters;
19
+ * - an **op queue** so control/`setSource` calls made before the canvas binds are replayed in order.
20
+ *
21
+ * Distinct surface from the toolpath controller (no layers/travel/scrub/IR) — capability tiers pass
22
+ * through from the parsed model, never recomputed (DD-001 honesty).
23
+ */
24
+ import type { CameraState, CameraView, CaptureOptions, GLRendererLike, InteractiveStageOptions, LoadProgress, RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three';
25
+ import type { Confidence } from '@chestnutlabs/toolpath-core';
26
+ import { type ModelViewer, type ModelViewerEvent, type ModelReadyInfo } from './model-viewer.js';
27
+ import type { ModelSourceInput, ModelLoader } from './loaders.js';
28
+ import type { ModelBackground, PresentationView } from './model-renderer.js';
29
+ import type { ModelBounds, ModelPlateSummary, RenderScope } from './scene-model.js';
30
+ import type { ModelLimits } from './limits.js';
31
+ /**
32
+ * Controller options — the engine's {@link ModelViewerOptions} minus the canvas (bound later). The
33
+ * `onProgress` callback is still delivered to the consumer AND folded into `state.progress`.
34
+ */
35
+ export interface ModelPreviewControllerOptions {
36
+ loaders?: readonly ModelLoader[];
37
+ background?: ModelBackground;
38
+ interactionQuality?: 'off' | 'auto';
39
+ cameraMode?: InteractiveStageOptions['cameraMode'];
40
+ limits?: ModelLimits;
41
+ filamentPalette?: readonly (string | undefined)[];
42
+ renderScope?: RenderScope;
43
+ /** Staged loading-progress (DD-024); also mirrored into `state.progress`. */
44
+ onProgress?: (progress: LoadProgress) => void;
45
+ /** Injected GL (tests / exotic hosts). */
46
+ createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike;
47
+ /** Injected orbit controls (tests). */
48
+ createControls?: NonNullable<InteractiveStageOptions['createControls']>;
49
+ }
50
+ /** Reactive snapshot of the model viewer, derived from engine events. Immutable — every change replaces it. */
51
+ export interface ModelPreviewState {
52
+ /** A `setSource` is in flight (parse → build). */
53
+ loading: boolean;
54
+ /** A source has loaded successfully at least once and is currently shown. */
55
+ ready: boolean;
56
+ /** False when WebGL is unavailable — the handle is inert; fall back to a `renderModelStill` image. */
57
+ rendererSupported: boolean;
58
+ /** Objects in the current source (0 before ready). */
59
+ objectCount: number;
60
+ /** Material/colour capability tier of the source (honesty model). */
61
+ materials: Confidence;
62
+ /** Instance placements drawn — `> objectCount` when geometry was reused ("N copies"). */
63
+ instancedCount: number;
64
+ /** Every-Nth-triangle decimation applied to fit the LOD budget (1 = none). */
65
+ decimationApplied: number;
66
+ /** Model bounds, or null before ready. */
67
+ bounds: ModelBounds | null;
68
+ /** Declared plate structure when the source declares plates (`materials`-style capability); else null. */
69
+ plates: {
70
+ list: ModelPlateSummary[];
71
+ active?: number;
72
+ } | null;
73
+ /** Capability-aware convenience: the source declares plates (build a plate selector). */
74
+ hasPlates: boolean;
75
+ /** Current camera pose, or null before a source / when unsupported. */
76
+ cameraState: CameraState | null;
77
+ /** Latest staged loading progress (DD-024), or null. */
78
+ progress: LoadProgress | null;
79
+ /** Last error (parse/loader/limit/renderer), or null. */
80
+ error: {
81
+ code: string;
82
+ message: string;
83
+ } | null;
84
+ }
85
+ /** Imperative controls — a thin proxy over the engine, queued until the canvas binds. */
86
+ export interface ModelPreviewControls {
87
+ /** Parse → build → frame → `ready`. Async (3MF unzips). Overlapping calls are last-wins. */
88
+ setSource(input: ModelSourceInput): Promise<ModelReadyInfo>;
89
+ setView(view: PresentationView | CameraView): void;
90
+ getCameraState(): CameraState | null;
91
+ setCameraState(state: CameraState): void;
92
+ setBackground(bg: ModelBackground): void;
93
+ setInteractionQuality(mode: 'off' | 'auto'): void;
94
+ /** Render only a subset of the source (`{ plateId }` / `{ objectIds }` / `{ instanceFilter }`); null = whole source. */
95
+ setRenderScope(scope: RenderScope | null): void;
96
+ /** Re-fit the camera to the current model bounds. */
97
+ frame(): void;
98
+ resize(width: number, height: number): void;
99
+ /** Capture the current view as an image `Blob`; rejects with `E_CAPTURE_UNSUPPORTED` when unavailable. */
100
+ capture(opts?: CaptureOptions): Promise<Blob>;
101
+ }
102
+ export interface ModelPreviewController {
103
+ /** Attach (or, with null, detach) the live canvas. Creating/replacing the canvas (re)builds the engine. */
104
+ bindCanvas(canvas: RenderTargetCanvas | null): void;
105
+ controls: ModelPreviewControls;
106
+ getState(): ModelPreviewState;
107
+ onStateChange(cb: (state: ModelPreviewState) => void): () => void;
108
+ onEvent(cb: (e: ModelViewerEvent) => void): () => void;
109
+ /** Escape hatch — the underlying engine (or null before a canvas is bound). */
110
+ raw: {
111
+ viewer: () => ModelViewer | null;
112
+ };
113
+ dispose(): void;
114
+ }
115
+ export declare const INITIAL_MODEL_STATE: ModelPreviewState;
116
+ export declare function createModelPreviewController(options?: ModelPreviewControllerOptions): ModelPreviewController;
117
+ //# sourceMappingURL=model-preview.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-preview.d.ts","sourceRoot":"","sources":["../src/model-preview.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,217 @@
1
+ import { createModelViewer } from './model-viewer.js';
2
+ export const INITIAL_MODEL_STATE = {
3
+ loading: false,
4
+ ready: false,
5
+ rendererSupported: true,
6
+ objectCount: 0,
7
+ materials: 'unavailable',
8
+ instancedCount: 0,
9
+ decimationApplied: 1,
10
+ bounds: null,
11
+ plates: null,
12
+ hasPlates: false,
13
+ cameraState: null,
14
+ progress: null,
15
+ error: null
16
+ };
17
+ export function createModelPreviewController(options = {}) {
18
+ let snapshot = { ...INITIAL_MODEL_STATE };
19
+ const stateListeners = new Set();
20
+ const mutate = (patch) => {
21
+ snapshot = { ...snapshot, ...patch };
22
+ for (const cb of stateListeners)
23
+ cb(snapshot);
24
+ };
25
+ const eventListeners = new Set();
26
+ const emit = (e) => {
27
+ for (const cb of eventListeners)
28
+ cb(e);
29
+ };
30
+ let engine = null;
31
+ let boundCanvas = null;
32
+ let unsubEngine = null;
33
+ let disposed = false;
34
+ /** Last source set — replayed when the canvas (re)binds so the model survives a remount. */
35
+ let lastSource = null;
36
+ /** Control ops issued before the engine exists; replayed in order on bind. */
37
+ const pendingOps = [];
38
+ /** Callers of `setSource` before the engine exists — settled by the replayed load. */
39
+ const pendingSourceWaiters = [];
40
+ const engineOptions = {
41
+ ...(options.loaders ? { loaders: options.loaders } : {}),
42
+ ...(options.background !== undefined ? { background: options.background } : {}),
43
+ ...(options.interactionQuality ? { interactionQuality: options.interactionQuality } : {}),
44
+ ...(options.cameraMode ? { cameraMode: options.cameraMode } : {}),
45
+ ...(options.limits ? { limits: options.limits } : {}),
46
+ ...(options.filamentPalette ? { filamentPalette: options.filamentPalette } : {}),
47
+ ...(options.renderScope ? { renderScope: options.renderScope } : {}),
48
+ ...(options.createRenderer ? { createRenderer: options.createRenderer } : {}),
49
+ ...(options.createControls ? { createControls: options.createControls } : {}),
50
+ // Fold progress into state, then hand it to the consumer's callback.
51
+ onProgress: (p) => {
52
+ mutate({ progress: p, loading: p.stage !== 'ready' });
53
+ options.onProgress?.(p);
54
+ }
55
+ };
56
+ const applyReady = (info) => {
57
+ mutate({
58
+ loading: false,
59
+ ready: true,
60
+ error: null,
61
+ objectCount: info.objectCount,
62
+ materials: info.materials,
63
+ instancedCount: info.instancedCount,
64
+ decimationApplied: info.decimationApplied,
65
+ bounds: info.bounds,
66
+ plates: info.plates ?? null,
67
+ hasPlates: info.plates !== undefined
68
+ });
69
+ };
70
+ const wire = (v) => {
71
+ unsubEngine = v.onEvent((e) => {
72
+ switch (e.type) {
73
+ case 'ready':
74
+ applyReady(e.info);
75
+ break;
76
+ case 'camera-changed':
77
+ mutate({ cameraState: e.state });
78
+ break;
79
+ case 'error':
80
+ mutate({ loading: false, error: { code: e.code, message: e.message } });
81
+ break;
82
+ case 'renderer-unsupported':
83
+ mutate({ rendererSupported: false, error: { code: 'E_RENDERER_UNSUPPORTED', message: e.message } });
84
+ break;
85
+ default:
86
+ break; // context-lost / context-restored: the engine self-recovers; no snapshot change
87
+ }
88
+ emit(e);
89
+ });
90
+ };
91
+ function bindCanvas(canvas) {
92
+ if (disposed || canvas === boundCanvas)
93
+ return;
94
+ // Tear down the engine bound to the previous canvas.
95
+ if (engine !== null) {
96
+ unsubEngine?.();
97
+ unsubEngine = null;
98
+ engine.dispose();
99
+ engine = null;
100
+ }
101
+ boundCanvas = canvas;
102
+ if (canvas === null)
103
+ return;
104
+ const v = createModelViewer(canvas, engineOptions);
105
+ engine = v;
106
+ wire(v);
107
+ for (const op of pendingOps)
108
+ op(v);
109
+ pendingOps.length = 0;
110
+ // Replay the last source so the model persists across a canvas rebind.
111
+ if (lastSource !== null) {
112
+ mutate({ loading: true, error: null });
113
+ v.setSource(lastSource).then((info) => {
114
+ const waiters = pendingSourceWaiters.splice(0, pendingSourceWaiters.length);
115
+ for (const w of waiters)
116
+ w.resolve(info);
117
+ }, (err) => {
118
+ const waiters = pendingSourceWaiters.splice(0, pendingSourceWaiters.length);
119
+ for (const w of waiters)
120
+ w.reject(err);
121
+ });
122
+ }
123
+ }
124
+ const controls = {
125
+ setSource(input) {
126
+ lastSource = input;
127
+ mutate({ loading: true, error: null });
128
+ if (engine !== null)
129
+ return engine.setSource(input);
130
+ // No canvas yet: settle when the engine binds and replays this source.
131
+ return new Promise((resolve, reject) => {
132
+ pendingSourceWaiters.push({ resolve, reject });
133
+ });
134
+ },
135
+ setView(view) {
136
+ if (engine !== null)
137
+ engine.setView(view);
138
+ else
139
+ pendingOps.push((v) => v.setView(view));
140
+ },
141
+ getCameraState() {
142
+ return engine !== null ? engine.getCameraState() : snapshot.cameraState;
143
+ },
144
+ setCameraState(state) {
145
+ if (engine !== null)
146
+ engine.setCameraState(state);
147
+ else
148
+ pendingOps.push((v) => v.setCameraState(state));
149
+ },
150
+ setBackground(bg) {
151
+ if (engine !== null)
152
+ engine.setBackground(bg);
153
+ else
154
+ pendingOps.push((v) => v.setBackground(bg));
155
+ },
156
+ setInteractionQuality(mode) {
157
+ if (engine !== null)
158
+ engine.setInteractionQuality(mode);
159
+ else
160
+ pendingOps.push((v) => v.setInteractionQuality(mode));
161
+ },
162
+ setRenderScope(scope) {
163
+ if (engine !== null)
164
+ engine.setRenderScope(scope);
165
+ else
166
+ pendingOps.push((v) => v.setRenderScope(scope));
167
+ },
168
+ frame() {
169
+ if (engine !== null)
170
+ engine.frame();
171
+ else
172
+ pendingOps.push((v) => v.frame());
173
+ },
174
+ resize(width, height) {
175
+ if (engine !== null)
176
+ engine.resize(width, height);
177
+ else
178
+ pendingOps.push((v) => v.resize(width, height));
179
+ },
180
+ capture(opts) {
181
+ if (engine !== null)
182
+ return engine.capture(opts);
183
+ return Promise.reject(new Error('E_CAPTURE_UNSUPPORTED: no canvas bound'));
184
+ }
185
+ };
186
+ function dispose() {
187
+ if (disposed)
188
+ return;
189
+ disposed = true;
190
+ unsubEngine?.();
191
+ unsubEngine = null;
192
+ engine?.dispose();
193
+ engine = null;
194
+ boundCanvas = null;
195
+ stateListeners.clear();
196
+ eventListeners.clear();
197
+ pendingOps.length = 0;
198
+ for (const w of pendingSourceWaiters.splice(0, pendingSourceWaiters.length)) {
199
+ w.reject(new Error('E_MODEL_PREVIEW_DISPOSED'));
200
+ }
201
+ }
202
+ return {
203
+ bindCanvas,
204
+ controls,
205
+ getState: () => snapshot,
206
+ onStateChange: (cb) => {
207
+ stateListeners.add(cb);
208
+ return () => stateListeners.delete(cb);
209
+ },
210
+ onEvent: (cb) => {
211
+ eventListeners.add(cb);
212
+ return () => eventListeners.delete(cb);
213
+ },
214
+ raw: { viewer: () => engine },
215
+ dispose
216
+ };
217
+ }
@@ -0,0 +1,46 @@
1
+ import type { GLRendererLike, RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three';
2
+ import type { ModelPreviewControls, ModelPreviewControllerOptions, ModelPreviewState } from './model-preview.js';
3
+ import type { ModelViewerEvent } from './model-viewer.js';
4
+ import type { ModelSourceInput } from './loaders.js';
5
+ import { type ModelScene } from './scene-model.js';
6
+ /** Stub GL for headless adapter mounts (no real WebGL). */
7
+ export declare const MODEL_STUB_GL: (canvas: RenderTargetCanvas) => GLRendererLike;
8
+ /** Stub orbit controls so a DOM canvas mount (jsdom) doesn't construct real OrbitControls. */
9
+ export declare const MODEL_STUB_CONTROLS: NonNullable<ModelPreviewControllerOptions['createControls']>;
10
+ /** A one-triangle binary STL — carries no colour, so materials stays honest `unavailable`. */
11
+ export declare function makeStlModelSource(): ModelSourceInput;
12
+ /** A single coloured object — materials: 'known' (capability passthrough check). */
13
+ export declare function makeColouredModelSource(): ModelScene;
14
+ export interface ModelAdapterInstance {
15
+ controls: ModelPreviewControls;
16
+ getState(): ModelPreviewState;
17
+ onEvent(cb: (e: ModelViewerEvent) => void): () => void;
18
+ dispose(): Promise<void> | void;
19
+ /** Let microtask round-trips + framework effects settle. */
20
+ settle(): Promise<void>;
21
+ }
22
+ export interface ModelAdapterHarness {
23
+ /** Mount one model viewer bound to a fresh canvas, wired to the stub GL + stub controls. */
24
+ create(): Promise<ModelAdapterInstance> | ModelAdapterInstance;
25
+ }
26
+ interface Matchers {
27
+ toBe(v: unknown): void;
28
+ toBeNull(): void;
29
+ toMatchObject(v: object): void;
30
+ toBeGreaterThanOrEqual(v: number): void;
31
+ }
32
+ interface TestApi {
33
+ describe: (name: string, fn: () => void) => void;
34
+ it: (name: string, fn: () => Promise<void> | void) => void;
35
+ expect: (actual: unknown) => Matchers & {
36
+ not: Matchers;
37
+ };
38
+ }
39
+ /** Every `ModelPreviewControls` method must be reachable through the adapter — add new ones here. */
40
+ export declare const REQUIRED_MODEL_CONTROLS: (keyof ModelPreviewControls)[];
41
+ /** Every `ModelPreviewState` field must surface through the adapter — add new ones here. */
42
+ export declare const REQUIRED_MODEL_STATE_KEYS: (keyof ModelPreviewState)[];
43
+ /** The model-viewer parity contract every adapter must pass (DD-031). */
44
+ export declare function runModelBehavioralSuite(name: string, api: TestApi, harness: ModelAdapterHarness): void;
45
+ export {};
46
+ //# sourceMappingURL=model-testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-testing.d.ts","sourceRoot":"","sources":["../src/model-testing.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7F,OAAO,KAAK,EAAE,oBAAoB,EAAE,6BAA6B,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACjH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAElE,2DAA2D;AAC3D,eAAO,MAAM,aAAa,GAAI,QAAQ,kBAAkB,KAAG,cAKzD,CAAC;AAEH,8FAA8F;AAC9F,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,6BAA6B,CAAC,gBAAgB,CAAC,CAQ3F,CAAC;AAEH,8FAA8F;AAC9F,wBAAgB,kBAAkB,IAAI,gBAAgB,CAuBrD;AAED,oFAAoF;AACpF,wBAAgB,uBAAuB,IAAI,UAAU,CAmBpD;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,QAAQ,IAAI,iBAAiB,CAAC;IAC9B,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,gBAAgB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACvD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAChC,4DAA4D;IAC5D,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED,MAAM,WAAW,mBAAmB;IAClC,4FAA4F;IAC5F,MAAM,IAAI,OAAO,CAAC,oBAAoB,CAAC,GAAG,oBAAoB,CAAC;CAChE;AAED,UAAU,QAAQ;IAChB,IAAI,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACvB,QAAQ,IAAI,IAAI,CAAC;IACjB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,sBAAsB,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzC;AACD,UAAU,OAAO;IACf,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IACjD,EAAE,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IAC3D,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,QAAQ,GAAG;QAAE,GAAG,EAAE,QAAQ,CAAA;KAAE,CAAC;CAC3D;AAED,qGAAqG;AACrG,eAAO,MAAM,uBAAuB,EAAE,CAAC,MAAM,oBAAoB,CAAC,EAWjE,CAAC;AAEF,4FAA4F;AAC5F,eAAO,MAAM,yBAAyB,EAAE,CAAC,MAAM,iBAAiB,CAAC,EAchE,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAkEtG"}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Framework-portable behavioral suite for the **model viewer** adapters (DD-031) — the Prepare-side
3
+ * analogue of `@chestnutlabs/gcode-preview-core/testing`. The SAME source → state → controls → dispose
4
+ * contract, runnable through every model adapter (`.../model`), so Vue/React/Svelte/WC can't drift into
5
+ * different model-viewing feature sets. It includes a **controls/state completeness parity guard** that
6
+ * fails CI if a `ModelPreviewControls` method or `ModelPreviewState` field isn't reachable through an
7
+ * adapter — add new controls/state to the required lists when the controller grows.
8
+ *
9
+ * Adapters run it by implementing {@link ModelAdapterHarness} over their public surface and calling
10
+ * {@link runModelBehavioralSuite} with their test framework's describe/it/expect. Ships via the
11
+ * `@chestnutlabs/gcode-model-renderer/testing` subpath. Shared stub GL/controls + source makers are
12
+ * exported so each adapter mounts headlessly the same way.
13
+ */
14
+ import { Vector3 } from 'three';
15
+ import { IDENTITY_MAT4 } from './scene-model.js';
16
+ /** Stub GL for headless adapter mounts (no real WebGL). */
17
+ export const MODEL_STUB_GL = (canvas) => ({
18
+ render: () => undefined,
19
+ setSize: () => undefined,
20
+ dispose: () => undefined,
21
+ domElement: canvas
22
+ });
23
+ /** Stub orbit controls so a DOM canvas mount (jsdom) doesn't construct real OrbitControls. */
24
+ export const MODEL_STUB_CONTROLS = (camera) => ({
25
+ object: camera,
26
+ target: new Vector3(),
27
+ enabled: true,
28
+ update: () => undefined,
29
+ dispose: () => undefined,
30
+ addEventListener: () => undefined,
31
+ listenToKeyEvents: () => undefined
32
+ });
33
+ /** A one-triangle binary STL — carries no colour, so materials stays honest `unavailable`. */
34
+ export function makeStlModelSource() {
35
+ const tris = [
36
+ [
37
+ [0, 0, 0],
38
+ [10, 0, 0],
39
+ [0, 20, 0]
40
+ ]
41
+ ];
42
+ const buf = new ArrayBuffer(84 + tris.length * 50);
43
+ const dv = new DataView(buf);
44
+ dv.setUint32(80, tris.length, true);
45
+ let o = 84;
46
+ for (const t of tris) {
47
+ o += 12;
48
+ for (const v of t) {
49
+ dv.setFloat32(o, v[0], true);
50
+ dv.setFloat32(o + 4, v[1], true);
51
+ dv.setFloat32(o + 8, v[2], true);
52
+ o += 12;
53
+ }
54
+ o += 2;
55
+ }
56
+ return { kind: 'stl', bytes: new Uint8Array(buf) };
57
+ }
58
+ /** A single coloured object — materials: 'known' (capability passthrough check). */
59
+ export function makeColouredModelSource() {
60
+ return {
61
+ objects: [
62
+ {
63
+ id: 'a',
64
+ geometry: { positions: new Float32Array([0, 0, 0, 10, 0, 0, 0, 10, 0]) },
65
+ transform: IDENTITY_MAT4,
66
+ material: { color: [1, 0, 0] }
67
+ }
68
+ ],
69
+ bounds: { min: [0, 0, 0], max: [10, 10, 0] },
70
+ capabilities: {
71
+ materials: 'known',
72
+ transforms: 'unavailable',
73
+ multiObject: 'unavailable',
74
+ instanced: 'unavailable',
75
+ plates: 'unavailable'
76
+ }
77
+ };
78
+ }
79
+ /** Every `ModelPreviewControls` method must be reachable through the adapter — add new ones here. */
80
+ export const REQUIRED_MODEL_CONTROLS = [
81
+ 'setSource',
82
+ 'setView',
83
+ 'getCameraState',
84
+ 'setCameraState',
85
+ 'setBackground',
86
+ 'setInteractionQuality',
87
+ 'setRenderScope',
88
+ 'frame',
89
+ 'resize',
90
+ 'capture'
91
+ ];
92
+ /** Every `ModelPreviewState` field must surface through the adapter — add new ones here. */
93
+ export const REQUIRED_MODEL_STATE_KEYS = [
94
+ 'loading',
95
+ 'ready',
96
+ 'rendererSupported',
97
+ 'objectCount',
98
+ 'materials',
99
+ 'instancedCount',
100
+ 'decimationApplied',
101
+ 'bounds',
102
+ 'plates',
103
+ 'hasPlates',
104
+ 'cameraState',
105
+ 'progress',
106
+ 'error'
107
+ ];
108
+ /** The model-viewer parity contract every adapter must pass (DD-031). */
109
+ export function runModelBehavioralSuite(name, api, harness) {
110
+ const { describe, it, expect } = api;
111
+ describe(`model behavioral suite — ${name}`, () => {
112
+ it('setSource(STL) → ready state with an honest object count + capability tier', async () => {
113
+ const a = await harness.create();
114
+ await a.controls.setSource(makeStlModelSource());
115
+ await a.settle();
116
+ const s = a.getState();
117
+ expect(s.ready).toBe(true);
118
+ expect(s.loading).toBe(false);
119
+ expect(s.objectCount).toBe(1);
120
+ expect(s.materials).toBe('unavailable'); // STL carries no colour
121
+ await a.dispose();
122
+ });
123
+ it('passes capability tiers through unchanged (coloured source → materials: known)', async () => {
124
+ const a = await harness.create();
125
+ await a.controls.setSource(makeColouredModelSource());
126
+ await a.settle();
127
+ expect(a.getState().materials).toBe('known');
128
+ await a.dispose();
129
+ });
130
+ it('emits a ready event carrying the model info through the adapter', async () => {
131
+ const a = await harness.create();
132
+ const events = [];
133
+ a.onEvent((e) => events.push(e));
134
+ await a.controls.setSource(makeStlModelSource());
135
+ await a.settle();
136
+ const ready = events.find((e) => e.type === 'ready');
137
+ if (!ready)
138
+ throw new Error('no ready event emitted');
139
+ expect(ready.type).toBe('ready');
140
+ await a.dispose();
141
+ });
142
+ it('camera controls reach the viewer (setView + getCameraState round-trip)', async () => {
143
+ const a = await harness.create();
144
+ await a.controls.setSource(makeStlModelSource());
145
+ await a.settle();
146
+ a.controls.setView('top');
147
+ await a.settle();
148
+ // A pose is available once a source is shown (null only before that / when unsupported).
149
+ const pose = a.controls.getCameraState();
150
+ expect(pose === null ? 0 : 1).toBeGreaterThanOrEqual(0); // reachable without throwing
151
+ await a.dispose();
152
+ });
153
+ it('exposes the full model controls contract on every adapter (parity guard)', async () => {
154
+ const a = await harness.create();
155
+ await a.settle();
156
+ for (const key of REQUIRED_MODEL_CONTROLS) {
157
+ expect(typeof a.controls[key] === 'function').toBe(true);
158
+ }
159
+ await a.dispose();
160
+ });
161
+ it('exposes the full model state contract on every adapter (parity guard)', async () => {
162
+ const a = await harness.create();
163
+ await a.settle();
164
+ const s = a.getState();
165
+ for (const key of REQUIRED_MODEL_STATE_KEYS) {
166
+ expect(key in s).toBe(true);
167
+ }
168
+ await a.dispose();
169
+ });
170
+ });
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chestnutlabs/gcode-model-renderer",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Three.js presentation renderer for source models (STL/3MF) — clean thumbnails/cards, distinct from toolpath inspection (DD-018).",
5
5
  "keywords": [
6
6
  "3d-printing",
@@ -37,6 +37,10 @@
37
37
  ".": {
38
38
  "types": "./dist/index.d.ts",
39
39
  "import": "./dist/index.js"
40
+ },
41
+ "./testing": {
42
+ "types": "./dist/model-testing.d.ts",
43
+ "import": "./dist/model-testing.js"
40
44
  }
41
45
  },
42
46
  "files": [
@@ -51,9 +55,9 @@
51
55
  "test": "vitest run"
52
56
  },
53
57
  "dependencies": {
54
- "@chestnutlabs/gcode-containers": "0.18.0",
55
- "@chestnutlabs/gcode-renderer-three": "0.18.0",
56
- "@chestnutlabs/toolpath-core": "0.18.0"
58
+ "@chestnutlabs/gcode-containers": "0.20.0",
59
+ "@chestnutlabs/gcode-renderer-three": "0.20.0",
60
+ "@chestnutlabs/toolpath-core": "0.20.0"
57
61
  },
58
62
  "devDependencies": {
59
63
  "@types/three": "0.178.0"