@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 +40 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/model-preview.d.ts +117 -0
- package/dist/model-preview.d.ts.map +1 -0
- package/dist/model-preview.js +217 -0
- package/dist/model-testing.d.ts +46 -0
- package/dist/model-testing.d.ts.map +1 -0
- package/dist/model-testing.js +171 -0
- package/package.json +8 -4
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
55
|
-
"@chestnutlabs/gcode-renderer-three": "0.
|
|
56
|
-
"@chestnutlabs/toolpath-core": "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"
|