@chestnutlabs/gcode-model-renderer 0.7.0 → 0.9.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 +97 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/loaders.d.ts +42 -0
- package/dist/loaders.d.ts.map +1 -0
- package/dist/loaders.js +43 -0
- package/dist/model-content.d.ts +42 -0
- package/dist/model-content.d.ts.map +1 -0
- package/dist/model-content.js +92 -0
- package/dist/model-renderer.d.ts +8 -8
- package/dist/model-renderer.d.ts.map +1 -1
- package/dist/model-renderer.js +16 -63
- package/dist/model-viewer.d.ts +72 -0
- package/dist/model-viewer.d.ts.map +1 -0
- package/dist/model-viewer.js +162 -0
- package/dist/render-model-still.d.ts +11 -1
- package/dist/render-model-still.d.ts.map +1 -1
- package/dist/render-model-still.js +6 -9
- package/dist/three-mf.d.ts +10 -1
- package/dist/three-mf.d.ts.map +1 -1
- package/dist/three-mf.js +151 -14
- package/package.json +4 -4
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @chestnutlabs/gcode-model-renderer
|
|
2
|
+
|
|
3
|
+
Three.js **presentation** renderer for source models — draws an `.stl` or `.3mf` mesh as a clean,
|
|
4
|
+
studio-lit thumbnail. It answers *"what object is this?"*, and is deliberately **separate** from the
|
|
5
|
+
toolpath renderer, which answers *"how does this print/cut run?"* (design: DD-018).
|
|
6
|
+
|
|
7
|
+
> **Peer dependency:** `three` (supported range `^0.178.0`) — install it alongside this package.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
Use it for file-browser thumbnails, library cards, and "what's in this file" previews — the picture a
|
|
12
|
+
slicer shows of a part, not the toolpath. For inspecting the actual moves (layers, travel, seams,
|
|
13
|
+
color modes, live progress), use [`@chestnutlabs/gcode-renderer-three`](../gcode-renderer-three)
|
|
14
|
+
instead.
|
|
15
|
+
|
|
16
|
+
- **STL and 3MF** — a bare STL is a single object with no declared material; a 3MF brings its own
|
|
17
|
+
multi-object structure, per-object transforms, and per-object / per-triangle material colors,
|
|
18
|
+
including files that use the 3MF **Production Extension** (`p:path` external parts).
|
|
19
|
+
- **Production multicolor** — real Bambu Studio / OrcaSlicer files paint per-region color with a
|
|
20
|
+
proprietary `paint_color` facet attribute and keep the palette in `project_settings.config`, not in
|
|
21
|
+
standard 3MF materials. The renderer decodes that facet-paint format (clean-room, see
|
|
22
|
+
[RR-005](../../docs/research/RR-005-3mf-paint-color-facet-format.md)) and reads the `filament_colour`
|
|
23
|
+
palette itself, so a designer's multicolor model renders in its true colors without slicing.
|
|
24
|
+
- **Capability-honest color** — when the source declares colors — standard 3MF `basematerials`, or
|
|
25
|
+
`paint_color` + a filament palette — the render uses them and reports `materials: 'known'`
|
|
26
|
+
(`'approximated'` when a handful of multi-color facets are flattened). When it declares none — a
|
|
27
|
+
plain STL, or a paint format with no palette present — it draws a neutral default and reports
|
|
28
|
+
`materials: 'unavailable'`. It never invents a source color.
|
|
29
|
+
- **Fixed presentation pose** — framed at a 3/4 angle on the shared render "stage" from
|
|
30
|
+
`@chestnutlabs/gcode-renderer-three`, under a neutral studio light rig, on a transparent (default)
|
|
31
|
+
or solid background you can composite onto a card.
|
|
32
|
+
- **Headless still** — `renderModelStill` mirrors the toolpath side's `renderStill`: hand it bytes,
|
|
33
|
+
get back a canvas plus a stable `cacheKey` and the `materials` confidence for that render. Runs in
|
|
34
|
+
any Chromium-class WebGL2 context (an `OffscreenCanvas` in a Worker, or headless Chromium).
|
|
35
|
+
- **Interactive viewer** — `createModelViewer` is the live analogue of the still: orbit, zoom, and pan
|
|
36
|
+
the same STL / 3MF (including production multicolor) in a browser `<canvas>`, with camera presets, a
|
|
37
|
+
serializable camera state, and an event stream for readiness and errors.
|
|
38
|
+
- **Three-free public types** — `ModelScene` / `ModelObject` / `MeshGeometry` are plain typed arrays,
|
|
39
|
+
so the package's surface never leaks `three`; the renderer builds three meshes internally.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { renderModelStill } from '@chestnutlabs/gcode-model-renderer';
|
|
43
|
+
|
|
44
|
+
// In a Worker with an OffscreenCanvas, or headless Chromium:
|
|
45
|
+
const { canvas, objectCount, materials, cacheKey } = await renderModelStill(
|
|
46
|
+
{ kind: '3mf', bytes }, // or { kind: 'stl', bytes }
|
|
47
|
+
{ canvas: new OffscreenCanvas(512, 512), background: 'transparent' }
|
|
48
|
+
);
|
|
49
|
+
// materials === 'known' → the render used colors the file declared
|
|
50
|
+
// materials === 'unavailable' → the file carried none; a neutral default was used (never faked)
|
|
51
|
+
const blob = await canvas.convertToBlob();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Already hold a corrected or richer filament palette (e.g. re-rendering a sliced file)? Pass
|
|
55
|
+
`filamentPalette` (hex per 0-based slot) to `renderModelStill` / `parse3mf` to override the one read
|
|
56
|
+
from `project_settings.config`. Optional — the renderer reads the file's own palette without it.
|
|
57
|
+
|
|
58
|
+
## Interactive viewer
|
|
59
|
+
|
|
60
|
+
For a live surface a user can orbit — a "View in 3D" for a source model, as opposed to a static
|
|
61
|
+
thumbnail — `createModelViewer` drives the same scene under the shared camera and orbit controls the
|
|
62
|
+
toolpath renderer uses:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { createModelViewer } from '@chestnutlabs/gcode-model-renderer';
|
|
66
|
+
|
|
67
|
+
const viewer = createModelViewer(canvas); // a real <canvas> in the page
|
|
68
|
+
viewer.onEvent((e) => {
|
|
69
|
+
if (e.type === 'ready') {
|
|
70
|
+
// e.info.materials === 'known' | 'approximated' → showing the file's true colors
|
|
71
|
+
// e.info.materials === 'unavailable' → neutral render; don't claim "true colors"
|
|
72
|
+
console.log(e.info.objectCount, e.info.materials, e.info.bounds);
|
|
73
|
+
}
|
|
74
|
+
if (e.type === 'renderer-unsupported') {
|
|
75
|
+
// No WebGL — fall back to a renderModelStill image or a static thumbnail
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await viewer.setSource({ kind: '3mf', bytes }); // or { kind: 'stl', bytes }; parse → build → frame
|
|
80
|
+
viewer.setView('front'); // 'iso' | 'top' | 'front' | 'back' | 'left' | 'right' | 'bottom'
|
|
81
|
+
// ...on unmount:
|
|
82
|
+
viewer.dispose();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Drag to orbit, scroll to zoom, right-drag to pan. `getCameraState()` / `setCameraState()` persist and
|
|
86
|
+
restore a pose (the same serializable `CameraState` as the toolpath renderer), `resize(w, h)` matches a
|
|
87
|
+
`ResizeObserver`, and `setInteractionQuality('auto')` trades detail for smoothness while orbiting. New
|
|
88
|
+
source formats become viewable by registering a `ModelLoader` for a new `kind`, with no change to the
|
|
89
|
+
viewer's API (design: [DD-021](../../docs/design/DD-021-interactive-model-viewer.md)).
|
|
90
|
+
|
|
91
|
+
Prefer to build your own scene? `parseStl` / `parse3mf` return a three-free `ModelScene`, and both the
|
|
92
|
+
still and the viewer accept a pre-built `ModelScene` directly.
|
|
93
|
+
|
|
94
|
+
Determinism (stills): same input + same environment ⇒ identical output. Cross-GPU/driver pixel identity
|
|
95
|
+
is not promised — cache by the returned `cacheKey`.
|
|
96
|
+
|
|
97
|
+
Part of [Chestnut Labs G-code Preview](../../README.md) · MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @chestnutlabs/gcode-model-renderer — Three.js **presentation** renderer for source models
|
|
3
|
-
*
|
|
4
|
-
* distinct from toolpath inspection
|
|
5
|
-
*
|
|
2
|
+
* @chestnutlabs/gcode-model-renderer — Three.js **presentation** renderer for source models (STL and
|
|
3
|
+
* 3MF multi-object/material, incl. production `paint_color` multicolor) — clean thumbnails/cards
|
|
4
|
+
* answering "what object is this?", distinct from toolpath inspection. A headless still
|
|
5
|
+
* (`renderModelStill`, DD-018) and an interactive viewer (`createModelViewer`, DD-021) share one scene
|
|
6
|
+
* core. Sits on the shared render "stage" from `@chestnutlabs/gcode-renderer-three`. `three` is a peer
|
|
7
|
+
* dependency.
|
|
6
8
|
*/
|
|
7
9
|
export { ModelRenderer, NEUTRAL_MATERIAL_COLOR } from './model-renderer.js';
|
|
8
10
|
export type { ModelRendererOptions, ModelBackground, PresentationView } from './model-renderer.js';
|
|
9
11
|
export { renderModelStill } from './render-model-still.js';
|
|
10
12
|
export type { ModelSource, RenderModelStillOptions, RenderModelStillResult } from './render-model-still.js';
|
|
13
|
+
export { createModelViewer } from './model-viewer.js';
|
|
14
|
+
export type { ModelViewer, ModelViewerOptions, ModelViewerEvent, ModelReadyInfo } from './model-viewer.js';
|
|
11
15
|
export { parseStl } from './stl.js';
|
|
12
16
|
export { parse3mf } from './three-mf.js';
|
|
17
|
+
export type { Parse3mfOptions } from './three-mf.js';
|
|
18
|
+
export { stlLoader, threeMfLoader, DEFAULT_MODEL_LOADERS, isModelScene, resolveModelScene } from './loaders.js';
|
|
19
|
+
export type { ModelLoader, ModelLoadOptions, ModelSourceInput } from './loaders.js';
|
|
13
20
|
export { computeCacheKey, defaultEnvId } from './cache-key.js';
|
|
14
21
|
export { DEFAULT_LIMITS, resolveLimits, ModelParseError } from './limits.js';
|
|
15
22
|
export type { ModelLimits, ResolvedLimits } from './limits.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
|
|
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,MAAM,kBAAkB,CAAC;AACjD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @chestnutlabs/gcode-model-renderer — Three.js **presentation** renderer for source models
|
|
3
|
-
*
|
|
4
|
-
* distinct from toolpath inspection
|
|
5
|
-
*
|
|
2
|
+
* @chestnutlabs/gcode-model-renderer — Three.js **presentation** renderer for source models (STL and
|
|
3
|
+
* 3MF multi-object/material, incl. production `paint_color` multicolor) — clean thumbnails/cards
|
|
4
|
+
* answering "what object is this?", distinct from toolpath inspection. A headless still
|
|
5
|
+
* (`renderModelStill`, DD-018) and an interactive viewer (`createModelViewer`, DD-021) share one scene
|
|
6
|
+
* core. Sits on the shared render "stage" from `@chestnutlabs/gcode-renderer-three`. `three` is a peer
|
|
7
|
+
* dependency.
|
|
6
8
|
*/
|
|
7
9
|
export { ModelRenderer, NEUTRAL_MATERIAL_COLOR } from './model-renderer.js';
|
|
8
10
|
export { renderModelStill } from './render-model-still.js';
|
|
11
|
+
export { createModelViewer } from './model-viewer.js';
|
|
9
12
|
export { parseStl } from './stl.js';
|
|
10
13
|
export { parse3mf } from './three-mf.js';
|
|
14
|
+
export { stlLoader, threeMfLoader, DEFAULT_MODEL_LOADERS, isModelScene, resolveModelScene } from './loaders.js';
|
|
11
15
|
export { computeCacheKey, defaultEnvId } from './cache-key.js';
|
|
12
16
|
export { DEFAULT_LIMITS, resolveLimits, ModelParseError } from './limits.js';
|
|
13
17
|
export { IDENTITY_MAT4 } from './scene-model.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ModelLimits } from './limits.js';
|
|
2
|
+
import type { ModelScene } from './scene-model.js';
|
|
3
|
+
/**
|
|
4
|
+
* Format-agnostic parse options carried through the registry. A loader that has no use for a field
|
|
5
|
+
* ignores it; the bag stays generic so registering a new `kind` never widens the public seam.
|
|
6
|
+
*/
|
|
7
|
+
export interface ModelLoadOptions {
|
|
8
|
+
/**
|
|
9
|
+
* 3MF `paint_color` palette override (hex `#RRGGBB` per 0-based slot), instead of the file's own
|
|
10
|
+
* `project_settings.config`. Ignored by loaders that don't consume it (e.g. STL).
|
|
11
|
+
*/
|
|
12
|
+
filamentPalette?: readonly (string | undefined)[];
|
|
13
|
+
}
|
|
14
|
+
/** A loader turns source bytes of one `kind` into the neutral {@link ModelScene} (DD-018 §4.1). */
|
|
15
|
+
export interface ModelLoader {
|
|
16
|
+
/** `'stl' | '3mf' | (future) 'obj' | 'step' | 'ply' | …` — an open string, not a closed union. */
|
|
17
|
+
readonly kind: string;
|
|
18
|
+
parse(bytes: Uint8Array, limits?: ModelLimits, opts?: ModelLoadOptions): ModelScene | Promise<ModelScene>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The viewer/still input: raw bytes tagged by an open `kind`, or an already-built {@link ModelScene}.
|
|
22
|
+
* `kind` is NOT an STL|3MF union — new formats register a {@link ModelLoader}.
|
|
23
|
+
*/
|
|
24
|
+
export type ModelSourceInput = {
|
|
25
|
+
kind: string;
|
|
26
|
+
bytes: Uint8Array | ArrayBuffer;
|
|
27
|
+
} | ModelScene;
|
|
28
|
+
/** STL loader (binary + ASCII); no colour/structure, so the honest single-object, no-material case. */
|
|
29
|
+
export declare const stlLoader: ModelLoader;
|
|
30
|
+
/** 3MF loader — multi-object/material incl. production `paint_color` multicolor (RR-005). */
|
|
31
|
+
export declare const threeMfLoader: ModelLoader;
|
|
32
|
+
/** The v1 default registry: STL + 3MF. */
|
|
33
|
+
export declare const DEFAULT_MODEL_LOADERS: readonly ModelLoader[];
|
|
34
|
+
/** True for a pre-built {@link ModelScene} input (has `objects`, carries no `kind` tag). */
|
|
35
|
+
export declare function isModelScene(input: ModelSourceInput): input is ModelScene;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve any {@link ModelSourceInput} to a {@link ModelScene} through the registry: a pre-built scene
|
|
38
|
+
* passes through; a `{kind, bytes}` input dispatches to the matching loader. Throws
|
|
39
|
+
* {@link ModelParseError} `E_MODEL_UNSUPPORTED_KIND` when no loader is registered for the `kind` (DD §6).
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveModelScene(input: ModelSourceInput, loaders?: readonly ModelLoader[], limits?: ModelLimits, opts?: ModelLoadOptions): Promise<ModelScene>;
|
|
42
|
+
//# sourceMappingURL=loaders.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loaders.d.ts","sourceRoot":"","sources":["../src/loaders.ts"],"names":[],"mappings":"AAWA,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;CACnD;AAED,mGAAmG;AACnG,MAAM,WAAW,WAAW;IAC1B,kGAAkG;IAClG,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;CAC3G;AAED;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAAA;CAAE,GAAG,UAAU,CAAC;AAE9F,uGAAuG;AACvG,eAAO,MAAM,SAAS,EAAE,WAGvB,CAAC;AAEF,6FAA6F;AAC7F,eAAO,MAAM,aAAa,EAAE,WAI3B,CAAC;AAEF,0CAA0C;AAC1C,eAAO,MAAM,qBAAqB,EAAE,SAAS,WAAW,EAA+B,CAAC;AAExF,4FAA4F;AAC5F,wBAAgB,YAAY,CAAC,KAAK,EAAE,gBAAgB,GAAG,KAAK,IAAI,UAAU,CAEzE;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,gBAAgB,EACvB,OAAO,GAAE,SAAS,WAAW,EAA0B,EACvD,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,CAAC,EAAE,gBAAgB,GACtB,OAAO,CAAC,UAAU,CAAC,CAQrB"}
|
package/dist/loaders.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic model-source seam (DD-021 §4.1) — a loader registry keyed by an **open** `kind` string,
|
|
3
|
+
* so the public input is a generic model source rather than an `STL | 3MF` union. New formats
|
|
4
|
+
* (OBJ/STEP/PLY/…) become viewable by registering a {@link ModelLoader}, with no change to the viewer
|
|
5
|
+
* or still-render signatures.
|
|
6
|
+
*
|
|
7
|
+
* v1 registers `stlLoader` (`kind:'stl'`) and `threeMfLoader` (`kind:'3mf'`). Both `renderModelStill`
|
|
8
|
+
* (DD-018) and `createModelViewer` (DD-021) resolve their source through this one registry.
|
|
9
|
+
*/
|
|
10
|
+
import { parseStl } from './stl.js';
|
|
11
|
+
import { parse3mf } from './three-mf.js';
|
|
12
|
+
import { ModelParseError } from './limits.js';
|
|
13
|
+
/** STL loader (binary + ASCII); no colour/structure, so the honest single-object, no-material case. */
|
|
14
|
+
export const stlLoader = {
|
|
15
|
+
kind: 'stl',
|
|
16
|
+
parse: (bytes, limits) => parseStl(bytes, limits)
|
|
17
|
+
};
|
|
18
|
+
/** 3MF loader — multi-object/material incl. production `paint_color` multicolor (RR-005). */
|
|
19
|
+
export const threeMfLoader = {
|
|
20
|
+
kind: '3mf',
|
|
21
|
+
parse: (bytes, limits, opts) => parse3mf(bytes, limits, opts?.filamentPalette ? { filamentPalette: opts.filamentPalette } : undefined)
|
|
22
|
+
};
|
|
23
|
+
/** The v1 default registry: STL + 3MF. */
|
|
24
|
+
export const DEFAULT_MODEL_LOADERS = [stlLoader, threeMfLoader];
|
|
25
|
+
/** True for a pre-built {@link ModelScene} input (has `objects`, carries no `kind` tag). */
|
|
26
|
+
export function isModelScene(input) {
|
|
27
|
+
return input.objects !== undefined && input.kind === undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve any {@link ModelSourceInput} to a {@link ModelScene} through the registry: a pre-built scene
|
|
31
|
+
* passes through; a `{kind, bytes}` input dispatches to the matching loader. Throws
|
|
32
|
+
* {@link ModelParseError} `E_MODEL_UNSUPPORTED_KIND` when no loader is registered for the `kind` (DD §6).
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveModelScene(input, loaders = DEFAULT_MODEL_LOADERS, limits, opts) {
|
|
35
|
+
if (isModelScene(input))
|
|
36
|
+
return input;
|
|
37
|
+
const loader = loaders.find((l) => l.kind === input.kind);
|
|
38
|
+
if (loader === undefined) {
|
|
39
|
+
throw new ModelParseError('E_MODEL_UNSUPPORTED_KIND', `no model loader registered for kind '${input.kind}'`);
|
|
40
|
+
}
|
|
41
|
+
const bytes = input.bytes instanceof Uint8Array ? input.bytes : new Uint8Array(input.bytes);
|
|
42
|
+
return loader.parse(bytes, limits, opts);
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ModelContent` (DD-021 Phase 1) — the shared presentation **scene core**: given a three.js `Scene`,
|
|
3
|
+
* it adds the model root (Z-up→Y-up) and the studio light rig, then builds capability-honest meshes for
|
|
4
|
+
* a {@link ModelScene} (incl. per-triangle `paint_color` vertex colours) and reports the model's framing
|
|
5
|
+
* (center + radius) for the camera.
|
|
6
|
+
*
|
|
7
|
+
* It owns **no** GL renderer, camera, or controls — only scene content — so both the headless still
|
|
8
|
+
* (`ModelRenderer` → `renderModelStill`, DD-018) and the interactive viewer (`createModelViewer`,
|
|
9
|
+
* DD-021) build meshes and lighting through **one** path instead of parallel copies. The still keeps its
|
|
10
|
+
* own GL + camera; the viewer drives the shared `InteractiveStage`. Both hand this class a `Scene`.
|
|
11
|
+
*/
|
|
12
|
+
import { Scene, Vector3 } from 'three';
|
|
13
|
+
import type { ModelScene, RGB } from './scene-model.js';
|
|
14
|
+
/** The framing a {@link ModelScene} implies: scene-space center and a bounding radius (model coords). */
|
|
15
|
+
export interface ModelFraming {
|
|
16
|
+
center: Vector3;
|
|
17
|
+
radius: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Adds the model root + studio rig to a provided `Scene` and builds meshes into it. The root carries the
|
|
21
|
+
* same Z-up(model)→Y-up(scene) rotation as the toolpath renderer, so the shared framing helper places
|
|
22
|
+
* the camera identically. Lights live in scene space (not under the rotated root) so the key stays
|
|
23
|
+
* top-ish regardless of model orientation.
|
|
24
|
+
*/
|
|
25
|
+
export declare class ModelContent {
|
|
26
|
+
private readonly scene;
|
|
27
|
+
private readonly root;
|
|
28
|
+
private readonly disposables;
|
|
29
|
+
private framedModel;
|
|
30
|
+
constructor(scene: Scene);
|
|
31
|
+
/** Build meshes for the scene and record its framing. Replaces (and disposes) any previous meshes. */
|
|
32
|
+
setScene(scene: ModelScene): void;
|
|
33
|
+
/** The framing of the last-set scene (`null` before the first {@link setScene}). */
|
|
34
|
+
get framing(): ModelFraming | null;
|
|
35
|
+
private buildMesh;
|
|
36
|
+
private clearMeshes;
|
|
37
|
+
/** Dispose all built geometries/materials (leaves the lights; the owner disposes the `Scene`). */
|
|
38
|
+
dispose(): void;
|
|
39
|
+
}
|
|
40
|
+
/** Exposed for tests: the neutral default color used when a model declares no material. */
|
|
41
|
+
export declare const NEUTRAL_MATERIAL_COLOR: RGB;
|
|
42
|
+
//# sourceMappingURL=model-content.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model-content.d.ts","sourceRoot":"","sources":["../src/model-content.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAQL,KAAK,EACL,OAAO,EACR,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAe,UAAU,EAAE,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAKrE,yGAAyG;AACzG,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,qBAAa,YAAY;IAKX,OAAO,CAAC,QAAQ,CAAC,KAAK;IAJlC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAe;IACpC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD,OAAO,CAAC,WAAW,CAA6B;gBAEnB,KAAK,EAAE,KAAK;IAezC,sGAAsG;IACtG,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAWjC,oFAAoF;IACpF,IAAI,OAAO,IAAI,YAAY,GAAG,IAAI,CAEjC;IAED,OAAO,CAAC,SAAS;IAwBjB,OAAO,CAAC,WAAW;IAMnB,kGAAkG;IAClG,OAAO,IAAI,IAAI;CAGhB;AAED,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,EAAE,GAAmB,CAAC"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ModelContent` (DD-021 Phase 1) — the shared presentation **scene core**: given a three.js `Scene`,
|
|
3
|
+
* it adds the model root (Z-up→Y-up) and the studio light rig, then builds capability-honest meshes for
|
|
4
|
+
* a {@link ModelScene} (incl. per-triangle `paint_color` vertex colours) and reports the model's framing
|
|
5
|
+
* (center + radius) for the camera.
|
|
6
|
+
*
|
|
7
|
+
* It owns **no** GL renderer, camera, or controls — only scene content — so both the headless still
|
|
8
|
+
* (`ModelRenderer` → `renderModelStill`, DD-018) and the interactive viewer (`createModelViewer`,
|
|
9
|
+
* DD-021) build meshes and lighting through **one** path instead of parallel copies. The still keeps its
|
|
10
|
+
* own GL + camera; the viewer drives the shared `InteractiveStage`. Both hand this class a `Scene`.
|
|
11
|
+
*/
|
|
12
|
+
import { BufferAttribute, BufferGeometry, DirectionalLight, Group, HemisphereLight, Mesh, MeshStandardMaterial, Vector3 } from 'three';
|
|
13
|
+
/** Neutral default surface when the source declares no material (never a fabricated source color). */
|
|
14
|
+
const NEUTRAL_COLOR = [0.8, 0.8, 0.82];
|
|
15
|
+
/**
|
|
16
|
+
* Adds the model root + studio rig to a provided `Scene` and builds meshes into it. The root carries the
|
|
17
|
+
* same Z-up(model)→Y-up(scene) rotation as the toolpath renderer, so the shared framing helper places
|
|
18
|
+
* the camera identically. Lights live in scene space (not under the rotated root) so the key stays
|
|
19
|
+
* top-ish regardless of model orientation.
|
|
20
|
+
*/
|
|
21
|
+
export class ModelContent {
|
|
22
|
+
scene;
|
|
23
|
+
root = new Group();
|
|
24
|
+
disposables = [];
|
|
25
|
+
framedModel = null;
|
|
26
|
+
constructor(scene) {
|
|
27
|
+
this.scene = scene;
|
|
28
|
+
this.root.rotation.x = -Math.PI / 2;
|
|
29
|
+
this.scene.add(this.root);
|
|
30
|
+
// Studio rig (presentation-specific): soft hemisphere fill + a key directional + a low fill.
|
|
31
|
+
const hemi = new HemisphereLight(0xffffff, 0x9098a5, 2.0);
|
|
32
|
+
this.scene.add(hemi);
|
|
33
|
+
const key = new DirectionalLight(0xffffff, 1.6);
|
|
34
|
+
key.position.set(0.6, 1, 0.8);
|
|
35
|
+
this.scene.add(key);
|
|
36
|
+
const fill = new DirectionalLight(0xffffff, 0.5);
|
|
37
|
+
fill.position.set(-0.7, 0.3, -0.5);
|
|
38
|
+
this.scene.add(fill);
|
|
39
|
+
}
|
|
40
|
+
/** Build meshes for the scene and record its framing. Replaces (and disposes) any previous meshes. */
|
|
41
|
+
setScene(scene) {
|
|
42
|
+
this.clearMeshes();
|
|
43
|
+
for (const obj of scene.objects)
|
|
44
|
+
this.root.add(this.buildMesh(obj));
|
|
45
|
+
const [minX, minY, minZ] = scene.bounds.min;
|
|
46
|
+
const [maxX, maxY, maxZ] = scene.bounds.max;
|
|
47
|
+
const center = new Vector3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
|
48
|
+
const radius = Math.max(1e-3, center.distanceTo(new Vector3(minX, minY, minZ)));
|
|
49
|
+
this.framedModel = { center, radius };
|
|
50
|
+
}
|
|
51
|
+
/** The framing of the last-set scene (`null` before the first {@link setScene}). */
|
|
52
|
+
get framing() {
|
|
53
|
+
return this.framedModel;
|
|
54
|
+
}
|
|
55
|
+
buildMesh(obj) {
|
|
56
|
+
const g = new BufferGeometry();
|
|
57
|
+
g.setAttribute('position', new BufferAttribute(obj.geometry.positions, 3));
|
|
58
|
+
if (obj.geometry.indices !== undefined)
|
|
59
|
+
g.setIndex(new BufferAttribute(obj.geometry.indices, 1));
|
|
60
|
+
if (obj.geometry.normals !== undefined)
|
|
61
|
+
g.setAttribute('normal', new BufferAttribute(obj.geometry.normals, 3));
|
|
62
|
+
else
|
|
63
|
+
g.computeVertexNormals();
|
|
64
|
+
const mat = new MeshStandardMaterial({ roughness: 0.62, metalness: 0.0 });
|
|
65
|
+
if (obj.geometry.colors !== undefined) {
|
|
66
|
+
// A single mesh carrying multiple colors (per-triangle 3MF material) → vertex colors.
|
|
67
|
+
g.setAttribute('color', new BufferAttribute(obj.geometry.colors, 3));
|
|
68
|
+
mat.vertexColors = true;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const color = obj.material?.color ?? NEUTRAL_COLOR;
|
|
72
|
+
mat.color.setRGB(color[0], color[1], color[2]);
|
|
73
|
+
}
|
|
74
|
+
const mesh = new Mesh(g, mat);
|
|
75
|
+
mesh.matrixAutoUpdate = false;
|
|
76
|
+
mesh.matrix.fromArray(obj.transform);
|
|
77
|
+
this.disposables.push(g, mat);
|
|
78
|
+
return mesh;
|
|
79
|
+
}
|
|
80
|
+
clearMeshes() {
|
|
81
|
+
for (const d of this.disposables)
|
|
82
|
+
d.dispose();
|
|
83
|
+
this.disposables.length = 0;
|
|
84
|
+
this.root.clear();
|
|
85
|
+
}
|
|
86
|
+
/** Dispose all built geometries/materials (leaves the lights; the owner disposes the `Scene`). */
|
|
87
|
+
dispose() {
|
|
88
|
+
this.clearMeshes();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Exposed for tests: the neutral default color used when a model declares no material. */
|
|
92
|
+
export const NEUTRAL_MATERIAL_COLOR = NEUTRAL_COLOR;
|
package/dist/model-renderer.d.ts
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
* "stage" from `@chestnutlabs/gcode-renderer-three` (framing pose, GL builder, GL type contracts).
|
|
4
4
|
*
|
|
5
5
|
* Distinct from the toolpath renderer: no tubes/travel/layers — just the model, framed at a fixed 3/4
|
|
6
|
-
* angle under neutral studio lighting, on a transparent or themed background. Its
|
|
7
|
-
*
|
|
6
|
+
* angle under neutral studio lighting, on a transparent or themed background. Its scene content (root,
|
|
7
|
+
* studio lights, capability-honest meshes) is built by the shared {@link ModelContent} core (DD-021),
|
|
8
|
+
* so the still and the interactive viewer share one mesh/lighting/paint path; this class adds the
|
|
9
|
+
* headless GL + camera + framing on top.
|
|
8
10
|
*/
|
|
9
11
|
import { PerspectiveCamera, Scene } from 'three';
|
|
10
12
|
import { type GLRendererLike, type RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three';
|
|
11
|
-
import
|
|
13
|
+
import { NEUTRAL_MATERIAL_COLOR } from './model-content.js';
|
|
14
|
+
import type { ModelScene } from './scene-model.js';
|
|
12
15
|
/** Background: `'transparent'` (composite on a card) or a solid CSS color / 0xRRGGBB. */
|
|
13
16
|
export type ModelBackground = 'transparent' | string | number;
|
|
14
17
|
/** Preset presentation angle. v1 implements `'iso'` (the shared 3/4 pose); others are reserved. */
|
|
@@ -24,8 +27,7 @@ export declare class ModelRenderer {
|
|
|
24
27
|
readonly scene: Scene;
|
|
25
28
|
readonly camera: PerspectiveCamera;
|
|
26
29
|
private readonly gl;
|
|
27
|
-
private readonly
|
|
28
|
-
private readonly disposables;
|
|
30
|
+
private readonly content;
|
|
29
31
|
private width;
|
|
30
32
|
private height;
|
|
31
33
|
private disposed;
|
|
@@ -35,9 +37,7 @@ export declare class ModelRenderer {
|
|
|
35
37
|
resize(width: number, height: number): void;
|
|
36
38
|
render(): void;
|
|
37
39
|
dispose(): void;
|
|
38
|
-
private buildMesh;
|
|
39
|
-
private clearMeshes;
|
|
40
40
|
}
|
|
41
41
|
/** Exposed for tests: the neutral default color used when a model declares no material. */
|
|
42
|
-
export
|
|
42
|
+
export { NEUTRAL_MATERIAL_COLOR };
|
|
43
43
|
//# sourceMappingURL=model-renderer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model-renderer.d.ts","sourceRoot":"","sources":["../src/model-renderer.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"model-renderer.d.ts","sourceRoot":"","sources":["../src/model-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAS,iBAAiB,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AACxD,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACxB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAgB,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,yFAAyF;AACzF,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE9D,mGAAmG;AACnG,MAAM,MAAM,gBAAgB,GAAG,KAAK,CAAC;AAErC,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,CAAC,EAAE,eAAe,CAAC;IAC7B,wGAAwG;IACxG,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,cAAc,CAAC;IAChE,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED,qBAAa,aAAa;IACxB,QAAQ,CAAC,KAAK,QAAe;IAC7B,QAAQ,CAAC,MAAM,oBAA8C;IAC7D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiB;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,KAAK,CAAO;IACpB,OAAO,CAAC,MAAM,CAAO;IACrB,OAAO,CAAC,QAAQ,CAAS;gBAEb,IAAI,EAAE,oBAAoB;IActC,0FAA0F;IAC1F,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAWjC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAQ3C,MAAM,IAAI,IAAI;IAKd,OAAO,IAAI,IAAI;CAMhB;AAED,2FAA2F;AAC3F,OAAO,EAAE,sBAAsB,EAAE,CAAC"}
|
package/dist/model-renderer.js
CHANGED
|
@@ -3,19 +3,19 @@
|
|
|
3
3
|
* "stage" from `@chestnutlabs/gcode-renderer-three` (framing pose, GL builder, GL type contracts).
|
|
4
4
|
*
|
|
5
5
|
* Distinct from the toolpath renderer: no tubes/travel/layers — just the model, framed at a fixed 3/4
|
|
6
|
-
* angle under neutral studio lighting, on a transparent or themed background. Its
|
|
7
|
-
*
|
|
6
|
+
* angle under neutral studio lighting, on a transparent or themed background. Its scene content (root,
|
|
7
|
+
* studio lights, capability-honest meshes) is built by the shared {@link ModelContent} core (DD-021),
|
|
8
|
+
* so the still and the interactive viewer share one mesh/lighting/paint path; this class adds the
|
|
9
|
+
* headless GL + camera + framing on top.
|
|
8
10
|
*/
|
|
9
|
-
import {
|
|
11
|
+
import { Color, PerspectiveCamera, Scene } from 'three';
|
|
10
12
|
import { createDefaultGLRenderer, framingFromCenterRadius } from '@chestnutlabs/gcode-renderer-three';
|
|
11
|
-
|
|
12
|
-
const NEUTRAL_COLOR = [0.8, 0.8, 0.82];
|
|
13
|
+
import { ModelContent, NEUTRAL_MATERIAL_COLOR } from './model-content.js';
|
|
13
14
|
export class ModelRenderer {
|
|
14
15
|
scene = new Scene();
|
|
15
16
|
camera = new PerspectiveCamera(50, 1, 0.1, 100_000);
|
|
16
17
|
gl;
|
|
17
|
-
|
|
18
|
-
disposables = [];
|
|
18
|
+
content;
|
|
19
19
|
width = 300;
|
|
20
20
|
height = 300;
|
|
21
21
|
disposed = false;
|
|
@@ -27,35 +27,19 @@ export class ModelRenderer {
|
|
|
27
27
|
alpha: transparent,
|
|
28
28
|
preserveDrawingBuffer: opts.preserveDrawingBuffer ?? true
|
|
29
29
|
});
|
|
30
|
-
// Same Z-up(printer/model) → Y-up(scene) convention as the toolpath renderer, so the shared
|
|
31
|
-
// framing helper places the camera identically.
|
|
32
|
-
this.root.rotation.x = -Math.PI / 2;
|
|
33
|
-
this.scene.add(this.root);
|
|
34
30
|
this.scene.background = transparent ? null : new Color(opts.background);
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
const hemi = new HemisphereLight(0xffffff, 0x9098a5, 2.0);
|
|
38
|
-
this.scene.add(hemi);
|
|
39
|
-
const key = new DirectionalLight(0xffffff, 1.6);
|
|
40
|
-
key.position.set(0.6, 1, 0.8);
|
|
41
|
-
this.scene.add(key);
|
|
42
|
-
const fill = new DirectionalLight(0xffffff, 0.5);
|
|
43
|
-
fill.position.set(-0.7, 0.3, -0.5);
|
|
44
|
-
this.scene.add(fill);
|
|
31
|
+
// Root + studio rig + mesh building live in the shared scene core (DD-021).
|
|
32
|
+
this.content = new ModelContent(this.scene);
|
|
45
33
|
}
|
|
46
34
|
/** Build meshes for the scene and frame the camera. Replaces any previously-set scene. */
|
|
47
35
|
setScene(scene) {
|
|
48
36
|
if (this.disposed)
|
|
49
37
|
return;
|
|
50
|
-
this.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const [maxX, maxY, maxZ] = scene.bounds.max;
|
|
56
|
-
const center = new Vector3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
|
57
|
-
const radius = Math.max(1e-3, center.distanceTo(new Vector3(minX, minY, minZ)));
|
|
58
|
-
const { target, position } = framingFromCenterRadius(center, radius);
|
|
38
|
+
this.content.setScene(scene);
|
|
39
|
+
const framing = this.content.framing;
|
|
40
|
+
if (framing === null)
|
|
41
|
+
return;
|
|
42
|
+
const { target, position } = framingFromCenterRadius(framing.center, framing.radius);
|
|
59
43
|
this.camera.position.copy(position);
|
|
60
44
|
this.camera.lookAt(target);
|
|
61
45
|
this.camera.updateProjectionMatrix();
|
|
@@ -76,40 +60,9 @@ export class ModelRenderer {
|
|
|
76
60
|
if (this.disposed)
|
|
77
61
|
return;
|
|
78
62
|
this.disposed = true;
|
|
79
|
-
this.
|
|
63
|
+
this.content.dispose();
|
|
80
64
|
this.gl.dispose();
|
|
81
65
|
}
|
|
82
|
-
buildMesh(obj) {
|
|
83
|
-
const g = new BufferGeometry();
|
|
84
|
-
g.setAttribute('position', new BufferAttribute(obj.geometry.positions, 3));
|
|
85
|
-
if (obj.geometry.indices !== undefined)
|
|
86
|
-
g.setIndex(new BufferAttribute(obj.geometry.indices, 1));
|
|
87
|
-
if (obj.geometry.normals !== undefined)
|
|
88
|
-
g.setAttribute('normal', new BufferAttribute(obj.geometry.normals, 3));
|
|
89
|
-
else
|
|
90
|
-
g.computeVertexNormals();
|
|
91
|
-
const mat = new MeshStandardMaterial({ roughness: 0.62, metalness: 0.0 });
|
|
92
|
-
if (obj.geometry.colors !== undefined) {
|
|
93
|
-
// A single mesh carrying multiple colors (per-triangle 3MF material) → vertex colors.
|
|
94
|
-
g.setAttribute('color', new BufferAttribute(obj.geometry.colors, 3));
|
|
95
|
-
mat.vertexColors = true;
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
const color = obj.material?.color ?? NEUTRAL_COLOR;
|
|
99
|
-
mat.color.setRGB(color[0], color[1], color[2]);
|
|
100
|
-
}
|
|
101
|
-
const mesh = new Mesh(g, mat);
|
|
102
|
-
mesh.matrixAutoUpdate = false;
|
|
103
|
-
mesh.matrix.fromArray(obj.transform);
|
|
104
|
-
this.disposables.push(g, mat);
|
|
105
|
-
return mesh;
|
|
106
|
-
}
|
|
107
|
-
clearMeshes() {
|
|
108
|
-
for (const d of this.disposables)
|
|
109
|
-
d.dispose();
|
|
110
|
-
this.disposables.length = 0;
|
|
111
|
-
this.root.clear();
|
|
112
|
-
}
|
|
113
66
|
}
|
|
114
67
|
/** Exposed for tests: the neutral default color used when a model declares no material. */
|
|
115
|
-
export
|
|
68
|
+
export { NEUTRAL_MATERIAL_COLOR };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type CameraMode, type CameraState, type CameraView, type GLRendererLike, type InteractiveStageOptions, type RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three';
|
|
2
|
+
import type { Confidence } from '@chestnutlabs/toolpath-core';
|
|
3
|
+
import { type ModelLoader, type ModelSourceInput } from './loaders.js';
|
|
4
|
+
import { type ModelLimits } from './limits.js';
|
|
5
|
+
import type { ModelBackground, PresentationView } from './model-renderer.js';
|
|
6
|
+
import type { ModelBounds } from './scene-model.js';
|
|
7
|
+
export interface ModelViewerOptions {
|
|
8
|
+
/** Loader registry (open `kind`). Default: `[stlLoader, threeMfLoader]`. */
|
|
9
|
+
loaders?: readonly ModelLoader[];
|
|
10
|
+
/** `'transparent'` (default) | CSS colour | `0xRRGGBB` (DD-018). */
|
|
11
|
+
background?: ModelBackground;
|
|
12
|
+
/** DD-020 interaction-aware quality; default `'auto'` (the interactive default). */
|
|
13
|
+
interactionQuality?: 'off' | 'auto';
|
|
14
|
+
/** Initial camera projection; default `'perspective'`. */
|
|
15
|
+
cameraMode?: CameraMode;
|
|
16
|
+
/** Source-model triangle / byte caps (DD-018 `ModelLimits`). */
|
|
17
|
+
limits?: ModelLimits;
|
|
18
|
+
/** 3MF `paint_color` palette override (hex `#RRGGBB` per 0-based slot); parity with `renderModelStill`. */
|
|
19
|
+
filamentPalette?: readonly (string | undefined)[];
|
|
20
|
+
/** Injected GL (tests / exotic hosts). Default: the shared stage builder with alpha on. */
|
|
21
|
+
createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike;
|
|
22
|
+
/** Injected orbit controls (tests). Default: three `OrbitControls` via the stage. */
|
|
23
|
+
createControls?: NonNullable<InteractiveStageOptions['createControls']>;
|
|
24
|
+
}
|
|
25
|
+
/** What the source actually carried, surfaced on `ready` (never fabricated — DD-001). */
|
|
26
|
+
export interface ModelReadyInfo {
|
|
27
|
+
objectCount: number;
|
|
28
|
+
materials: Confidence;
|
|
29
|
+
bounds: ModelBounds;
|
|
30
|
+
}
|
|
31
|
+
export type ModelViewerEvent = {
|
|
32
|
+
type: 'ready';
|
|
33
|
+
info: ModelReadyInfo;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'camera-changed';
|
|
36
|
+
state: CameraState;
|
|
37
|
+
} | {
|
|
38
|
+
type: 'error';
|
|
39
|
+
code: string;
|
|
40
|
+
message: string;
|
|
41
|
+
} | {
|
|
42
|
+
type: 'renderer-unsupported';
|
|
43
|
+
feature: string;
|
|
44
|
+
message: string;
|
|
45
|
+
} | {
|
|
46
|
+
type: 'context-lost';
|
|
47
|
+
} | {
|
|
48
|
+
type: 'context-restored';
|
|
49
|
+
};
|
|
50
|
+
export interface ModelViewer {
|
|
51
|
+
/** Parse → build → frame → `ready`. Async (3MF unzips). Overlapping calls are last-wins. */
|
|
52
|
+
setSource(input: ModelSourceInput): Promise<ModelReadyInfo>;
|
|
53
|
+
/** Snap to a preset orientation (shared with the toolpath camera). */
|
|
54
|
+
setView(view: PresentationView | CameraView): void;
|
|
55
|
+
/** Current camera pose, or `null` before a source is set / when the renderer is unsupported. */
|
|
56
|
+
getCameraState(): CameraState | null;
|
|
57
|
+
setCameraState(state: CameraState): void;
|
|
58
|
+
setBackground(bg: ModelBackground): void;
|
|
59
|
+
setInteractionQuality(mode: 'off' | 'auto'): void;
|
|
60
|
+
resize(width: number, height: number): void;
|
|
61
|
+
/** Re-fit the camera to the current model bounds. */
|
|
62
|
+
frame(): void;
|
|
63
|
+
onEvent(cb: (e: ModelViewerEvent) => void): () => void;
|
|
64
|
+
dispose(): void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Create an interactive model viewer bound to a live `<canvas>`. If WebGL is unavailable the handle is
|
|
68
|
+
* still returned but inert; it emits a `renderer-unsupported` event (flushed to the first `onEvent`
|
|
69
|
+
* subscriber) so the consumer can fall back to a `renderModelStill` image (DD-021 §6).
|
|
70
|
+
*/
|
|
71
|
+
export declare function createModelViewer(canvas: RenderTargetCanvas, options?: ModelViewerOptions): ModelViewer;
|
|
72
|
+
//# sourceMappingURL=model-viewer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model-viewer.d.ts","sourceRoot":"","sources":["../src/model-viewer.ts"],"names":[],"mappings":"AAeA,OAAO,EAGL,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACxB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,EAA4C,KAAK,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACjH,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,KAAK,EAAE,WAAW,EAAc,MAAM,kBAAkB,CAAC;AAEhE,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IACjC,oEAAoE;IACpE,UAAU,CAAC,EAAE,eAAe,CAAC;IAC7B,oFAAoF;IACpF,kBAAkB,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACpC,0DAA0D;IAC1D,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,gEAAgE;IAChE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,2GAA2G;IAC3G,eAAe,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAClD,2FAA2F;IAC3F,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,cAAc,CAAC;IAChE,qFAAqF;IACrF,cAAc,CAAC,EAAE,WAAW,CAAC,uBAAuB,CAAC,gBAAgB,CAAC,CAAC,CAAC;CACzE;AAED,yFAAyF;AACzF,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,UAAU,CAAC;IACtB,MAAM,EAAE,WAAW,CAAC;CAErB;AAED,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,cAAc,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,sBAAsB,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAClE;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GACxB;IAAE,IAAI,EAAE,kBAAkB,CAAA;CAAE,CAAC;AAEjC,MAAM,WAAW,WAAW;IAC1B,4FAA4F;IAC5F,SAAS,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC5D,sEAAsE;IACtE,OAAO,CAAC,IAAI,EAAE,gBAAgB,GAAG,UAAU,GAAG,IAAI,CAAC;IACnD,gGAAgG;IAChG,cAAc,IAAI,WAAW,GAAG,IAAI,CAAC;IACrC,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IACzC,aAAa,CAAC,EAAE,EAAE,eAAe,GAAG,IAAI,CAAC;IACzC,qBAAqB,CAAC,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,IAAI,CAAC;IAClD,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,qDAAqD;IACrD,KAAK,IAAI,IAAI,CAAC;IACd,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,gBAAgB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACvD,OAAO,IAAI,IAAI,CAAC;CACjB;AAMD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,kBAAkB,EAAE,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAoI3G"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createModelViewer` (DD-021 Phase 1) — the **interactive** analogue of `renderModelStill`: orbit /
|
|
3
|
+
* zoom / pan a source model (STL / 3MF, incl. production `paint_color` multicolor) in the browser.
|
|
4
|
+
*
|
|
5
|
+
* It composes the pieces that already exist rather than building a parallel renderer:
|
|
6
|
+
* - the shared {@link InteractiveStage} (GL + dual camera + orbit/zoom/pan + context-loss recovery +
|
|
7
|
+
* DD-020 interaction quality) from `@chestnutlabs/gcode-renderer-three`,
|
|
8
|
+
* - the shared {@link ModelContent} scene core (root + studio lights + capability-honest meshes),
|
|
9
|
+
* - the open-`kind` {@link resolveModelScene} loader registry.
|
|
10
|
+
*
|
|
11
|
+
* It is a **distinct surface** from `ToolpathRenderer` / `<GcodePreview>` (no layers/travel/scrub/IR);
|
|
12
|
+
* capability tiers are passed through from the parsed `ModelScene`, never recomputed (DD-001 honesty).
|
|
13
|
+
* Interactive is a browser concern — headless/offscreen stills stay `renderModelStill` (DD-018 §4.3).
|
|
14
|
+
*/
|
|
15
|
+
import { Color, Scene } from 'three';
|
|
16
|
+
import { InteractiveStage, createDefaultGLRenderer } from '@chestnutlabs/gcode-renderer-three';
|
|
17
|
+
import { ModelContent } from './model-content.js';
|
|
18
|
+
import { DEFAULT_MODEL_LOADERS, resolveModelScene } from './loaders.js';
|
|
19
|
+
import { ModelParseError } from './limits.js';
|
|
20
|
+
function backgroundColor(bg) {
|
|
21
|
+
return bg === 'transparent' ? null : new Color(bg);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create an interactive model viewer bound to a live `<canvas>`. If WebGL is unavailable the handle is
|
|
25
|
+
* still returned but inert; it emits a `renderer-unsupported` event (flushed to the first `onEvent`
|
|
26
|
+
* subscriber) so the consumer can fall back to a `renderModelStill` image (DD-021 §6).
|
|
27
|
+
*/
|
|
28
|
+
export function createModelViewer(canvas, options = {}) {
|
|
29
|
+
const loaders = options.loaders ?? DEFAULT_MODEL_LOADERS;
|
|
30
|
+
const limits = options.limits;
|
|
31
|
+
const filamentPalette = options.filamentPalette;
|
|
32
|
+
const background = options.background ?? 'transparent';
|
|
33
|
+
const listeners = new Set();
|
|
34
|
+
// Events emitted before any subscriber attaches (e.g. renderer-unsupported at construction) are held
|
|
35
|
+
// and flushed to the first subscriber, so the create-then-onEvent consumer flow never drops them.
|
|
36
|
+
const pending = [];
|
|
37
|
+
const emit = (e) => {
|
|
38
|
+
if (listeners.size === 0) {
|
|
39
|
+
pending.push(e);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const l of listeners)
|
|
43
|
+
l(e);
|
|
44
|
+
};
|
|
45
|
+
const scene = new Scene();
|
|
46
|
+
scene.background = backgroundColor(background);
|
|
47
|
+
const content = new ModelContent(scene);
|
|
48
|
+
// Retained so context-loss recovery can rebuild meshes from the last-set scene.
|
|
49
|
+
let currentScene = null;
|
|
50
|
+
let disposed = false;
|
|
51
|
+
// Monotonic token for last-wins overlapping setSource (stale async results are discarded).
|
|
52
|
+
let sourceToken = 0;
|
|
53
|
+
let stage = null;
|
|
54
|
+
try {
|
|
55
|
+
stage = new InteractiveStage({
|
|
56
|
+
canvas,
|
|
57
|
+
scene,
|
|
58
|
+
cameraMode: options.cameraMode ?? 'perspective',
|
|
59
|
+
interactionQuality: options.interactionQuality ?? 'auto',
|
|
60
|
+
createRenderer: options.createRenderer ?? ((c) => createDefaultGLRenderer(c, { alpha: true })),
|
|
61
|
+
...(options.createControls ? { createControls: options.createControls } : {}),
|
|
62
|
+
onCameraChanged: (state) => emit({ type: 'camera-changed', state }),
|
|
63
|
+
onContextLost: () => emit({ type: 'context-lost' }),
|
|
64
|
+
onContextRestored: () => {
|
|
65
|
+
// GPU resources are gone; rebuild meshes from the retained scene and reframe, mirroring the
|
|
66
|
+
// toolpath renderer's recovery.
|
|
67
|
+
if (currentScene !== null && stage !== null) {
|
|
68
|
+
content.setScene(currentScene);
|
|
69
|
+
const f = content.framing;
|
|
70
|
+
if (f !== null)
|
|
71
|
+
stage.frameTo(f.center, f.radius);
|
|
72
|
+
}
|
|
73
|
+
emit({ type: 'context-restored' });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
emit({
|
|
79
|
+
type: 'renderer-unsupported',
|
|
80
|
+
feature: 'webgl2',
|
|
81
|
+
message: err instanceof Error ? err.message : String(err)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function setSource(input) {
|
|
85
|
+
const my = ++sourceToken;
|
|
86
|
+
if (stage === null || disposed) {
|
|
87
|
+
const message = disposed ? 'model viewer is disposed' : 'WebGL renderer unavailable';
|
|
88
|
+
emit({ type: 'error', code: 'E_MODEL_RENDERER_UNAVAILABLE', message });
|
|
89
|
+
throw new ModelParseError('E_MODEL_RENDERER_UNAVAILABLE', message);
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const parsed = await resolveModelScene(input, loaders, limits, filamentPalette ? { filamentPalette } : undefined);
|
|
93
|
+
const info = {
|
|
94
|
+
objectCount: parsed.objects.length,
|
|
95
|
+
materials: parsed.capabilities.materials,
|
|
96
|
+
bounds: parsed.bounds
|
|
97
|
+
};
|
|
98
|
+
// Last-wins: a newer setSource (or a dispose) superseded this one — discard without mutating.
|
|
99
|
+
if (my !== sourceToken || disposed || stage === null)
|
|
100
|
+
return info;
|
|
101
|
+
content.setScene(parsed);
|
|
102
|
+
currentScene = parsed;
|
|
103
|
+
const f = content.framing;
|
|
104
|
+
if (f !== null)
|
|
105
|
+
stage.frameTo(f.center, f.radius);
|
|
106
|
+
emit({ type: 'ready', info });
|
|
107
|
+
return info;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const code = err instanceof ModelParseError ? err.code : 'E_MODEL_PARSE';
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
// Loader/limit failures surface as an event, never thrown out of the render loop (DD §6) — but the
|
|
113
|
+
// returned promise still rejects so an awaiting caller sees the failure.
|
|
114
|
+
emit({ type: 'error', code, message });
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
setSource,
|
|
120
|
+
setView(view) {
|
|
121
|
+
stage?.setView(view);
|
|
122
|
+
},
|
|
123
|
+
getCameraState() {
|
|
124
|
+
return stage ? stage.getCameraState() : null;
|
|
125
|
+
},
|
|
126
|
+
setCameraState(state) {
|
|
127
|
+
stage?.setCameraState(state);
|
|
128
|
+
},
|
|
129
|
+
setBackground(bg) {
|
|
130
|
+
scene.background = backgroundColor(bg);
|
|
131
|
+
stage?.render();
|
|
132
|
+
},
|
|
133
|
+
setInteractionQuality(mode) {
|
|
134
|
+
stage?.setInteractionQuality(mode);
|
|
135
|
+
},
|
|
136
|
+
resize(width, height) {
|
|
137
|
+
stage?.resize(width, height);
|
|
138
|
+
},
|
|
139
|
+
frame() {
|
|
140
|
+
const f = content.framing;
|
|
141
|
+
if (f !== null)
|
|
142
|
+
stage?.frameTo(f.center, f.radius);
|
|
143
|
+
},
|
|
144
|
+
onEvent(cb) {
|
|
145
|
+
listeners.add(cb);
|
|
146
|
+
if (pending.length > 0) {
|
|
147
|
+
const flushed = pending.splice(0, pending.length);
|
|
148
|
+
for (const e of flushed)
|
|
149
|
+
cb(e);
|
|
150
|
+
}
|
|
151
|
+
return () => listeners.delete(cb);
|
|
152
|
+
},
|
|
153
|
+
dispose() {
|
|
154
|
+
if (disposed)
|
|
155
|
+
return;
|
|
156
|
+
disposed = true;
|
|
157
|
+
stage?.dispose();
|
|
158
|
+
content.dispose();
|
|
159
|
+
listeners.clear();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -15,7 +15,11 @@ import type { GLRendererLike, RenderTargetCanvas } from '@chestnutlabs/gcode-ren
|
|
|
15
15
|
import { type ModelBackground, type PresentationView } from './model-renderer.js';
|
|
16
16
|
import type { ModelScene } from './scene-model.js';
|
|
17
17
|
import type { ModelLimits } from './limits.js';
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* A source model: STL bytes, 3MF bytes, or a pre-built `ModelScene`. This concrete union is the
|
|
20
|
+
* backward-compatible still-render input; it flows through the shared open-`kind` loader registry
|
|
21
|
+
* (`ModelSourceInput`, DD-021 §4.1), so any registered `kind` is also accepted.
|
|
22
|
+
*/
|
|
19
23
|
export type ModelSource = {
|
|
20
24
|
kind: 'stl';
|
|
21
25
|
bytes: Uint8Array | ArrayBuffer;
|
|
@@ -33,6 +37,12 @@ export interface RenderModelStillOptions {
|
|
|
33
37
|
view?: PresentationView;
|
|
34
38
|
createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike;
|
|
35
39
|
limits?: ModelLimits;
|
|
40
|
+
/**
|
|
41
|
+
* Override the 3MF source-model filament palette (hex `#RRGGBB` per 0-based slot) used to colour
|
|
42
|
+
* `paint_color` facets, instead of the file's own `project_settings.config`. Niche: for consumers
|
|
43
|
+
* re-rendering a file with a corrected/richer palette. Ignored for STL and pre-built scenes.
|
|
44
|
+
*/
|
|
45
|
+
filamentPalette?: readonly (string | undefined)[];
|
|
36
46
|
/** Override the environment id folded into the cache key (default: the `three` revision). */
|
|
37
47
|
envId?: string;
|
|
38
48
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render-model-still.d.ts","sourceRoot":"","sources":["../src/render-model-still.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7F,OAAO,EAAiB,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"render-model-still.d.ts","sourceRoot":"","sources":["../src/render-model-still.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7F,OAAO,EAAiB,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAEjG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C;;;;GAIG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAAA;CAAE,GAChD,UAAU,CAAC;AAEf,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,UAAU,CAAC,EAAE,eAAe,CAAC;IAC7B,qCAAqC;IACrC,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,cAAc,CAAC;IAChE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAClD,6FAA6F;IAC7F,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,sGAAsG;IACtG,SAAS,EAAE,UAAU,CAAC;IACtB,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkBD;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,sBAAsB,CAAC,CAiCjC"}
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { ModelRenderer } from './model-renderer.js';
|
|
2
|
-
import {
|
|
3
|
-
import { parse3mf } from './three-mf.js';
|
|
2
|
+
import { isModelScene, resolveModelScene } from './loaders.js';
|
|
4
3
|
import { computeCacheKey } from './cache-key.js';
|
|
5
|
-
function
|
|
6
|
-
return s.objects !== undefined && s.kind === undefined;
|
|
7
|
-
}
|
|
8
|
-
async function toScene(source, limits) {
|
|
4
|
+
async function toScene(source, limits, filamentPalette) {
|
|
9
5
|
if (isModelScene(source)) {
|
|
10
6
|
// No raw bytes for a pre-built scene: key off a stable structural summary instead.
|
|
11
7
|
const summary = JSON.stringify({ n: source.objects.length, b: source.bounds, c: source.capabilities });
|
|
12
8
|
return { scene: source, sourceBytes: new TextEncoder().encode(summary) };
|
|
13
9
|
}
|
|
10
|
+
// Dispatch through the shared open-`kind` loader registry (DD-021 §4.1) — same parse result as before.
|
|
14
11
|
const bytes = source.bytes instanceof Uint8Array ? source.bytes : new Uint8Array(source.bytes);
|
|
15
|
-
const scene = source
|
|
12
|
+
const scene = await resolveModelScene(source, undefined, limits, filamentPalette ? { filamentPalette } : undefined);
|
|
16
13
|
return { scene, sourceBytes: bytes };
|
|
17
14
|
}
|
|
18
15
|
/**
|
|
@@ -26,7 +23,7 @@ export async function renderModelStill(source, options) {
|
|
|
26
23
|
const height = options.height ?? canvas.height;
|
|
27
24
|
const background = options.background ?? 'transparent';
|
|
28
25
|
const view = options.view ?? 'iso';
|
|
29
|
-
const { scene, sourceBytes } = await toScene(source, options.limits);
|
|
26
|
+
const { scene, sourceBytes } = await toScene(source, options.limits, options.filamentPalette);
|
|
30
27
|
const renderer = new ModelRenderer({
|
|
31
28
|
canvas,
|
|
32
29
|
background,
|
|
@@ -37,7 +34,7 @@ export async function renderModelStill(source, options) {
|
|
|
37
34
|
renderer.resize(width, height);
|
|
38
35
|
renderer.setScene(scene);
|
|
39
36
|
renderer.render();
|
|
40
|
-
const optionsJson = JSON.stringify({ width, height, background, view });
|
|
37
|
+
const optionsJson = JSON.stringify({ width, height, background, view, palette: options.filamentPalette });
|
|
41
38
|
const cacheKey = computeCacheKey(sourceBytes, optionsJson, options.envId);
|
|
42
39
|
return {
|
|
43
40
|
canvas,
|
package/dist/three-mf.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { type ModelScene } from './scene-model.js';
|
|
2
2
|
import { type ModelLimits } from './limits.js';
|
|
3
|
-
export
|
|
3
|
+
export interface Parse3mfOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Override the source-model filament palette (hex `#RRGGBB` per 0-based slot) used to colour
|
|
6
|
+
* `paint_color` facets, instead of reading it from `project_settings.config`. For consumers that
|
|
7
|
+
* already hold a corrected/richer palette (e.g. re-rendering a sliced `.gcode.3mf`). Ignored when
|
|
8
|
+
* empty; the file's own palette is still read.
|
|
9
|
+
*/
|
|
10
|
+
filamentPalette?: readonly (string | undefined)[];
|
|
11
|
+
}
|
|
12
|
+
export declare function parse3mf(source: Uint8Array | ArrayBuffer, limits?: ModelLimits, opts?: Parse3mfOptions): Promise<ModelScene>;
|
|
4
13
|
//# sourceMappingURL=three-mf.d.ts.map
|
package/dist/three-mf.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"AAqBA,OAAO,EAKL,KAAK,UAAU,EAEhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAkC,KAAK,WAAW,EAAuB,MAAM,aAAa,CAAC;AA4VpG,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;CACnD;AAKD,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,UAAU,GAAG,WAAW,EAChC,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,UAAU,CAAC,CAwJrB"}
|
package/dist/three-mf.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* `<components><component>` (with `p:path`), and `<build><item>` transforms. Textures and non-color
|
|
13
13
|
* material properties are ignored (disclosed via capability honesty), never fetched (no network).
|
|
14
14
|
*/
|
|
15
|
-
import { readDirectory, extractEntry, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
|
|
15
|
+
import { readDirectory, extractEntry, filamentColoursFromSettings, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
|
|
16
16
|
import { IDENTITY_MAT4 } from './scene-model.js';
|
|
17
17
|
import { ModelParseError, resolveLimits } from './limits.js';
|
|
18
18
|
/** millimeter is the 3MF default; convert others to mm. */
|
|
@@ -56,6 +56,75 @@ function srgbHexToLinear(hex) {
|
|
|
56
56
|
const b = to(4);
|
|
57
57
|
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : [r, g, b];
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Decode a Bambu/Orca `paint_color` facet-paint attribute (clean-room from the observed format —
|
|
61
|
+
* see RR-005, NOT from Orca/PrusaSlicer GPL source). The value is a **little-endian** stream of 4-bit
|
|
62
|
+
* nibble tokens forming a recursive triangle-split tree:
|
|
63
|
+
*
|
|
64
|
+
* - `token & 0b11` = split-sides: `0` ⇒ a **leaf**, otherwise the triangle is split into
|
|
65
|
+
* `split_sides + 1` children (2/3/4) that follow, decoded depth-first (`token >> 2` is the split
|
|
66
|
+
* side — irrelevant to a presentation render).
|
|
67
|
+
* - a leaf's state is `token >> 2`, with `3` escaping into the following nibble (`3 + next`).
|
|
68
|
+
*
|
|
69
|
+
* **State 0 = the object's default extruder (unpainted); state s ≥ 1 = filament index s − 1.**
|
|
70
|
+
* Returns the dominant leaf state plus whether the facet was subdivided (so the caller can flatten it
|
|
71
|
+
* to one colour and disclose `approximated`), or `null` if the value is malformed/truncated.
|
|
72
|
+
*/
|
|
73
|
+
function decodePaintState(hex) {
|
|
74
|
+
const clean = hex.trim();
|
|
75
|
+
if (clean.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
const nibbles = [];
|
|
78
|
+
for (let i = clean.length - 1; i >= 0; i--) {
|
|
79
|
+
const n = parseInt(clean[i], 16);
|
|
80
|
+
if (Number.isNaN(n))
|
|
81
|
+
return null;
|
|
82
|
+
nibbles.push(n);
|
|
83
|
+
}
|
|
84
|
+
let pos = 0;
|
|
85
|
+
const states = [];
|
|
86
|
+
const next = () => {
|
|
87
|
+
if (pos >= nibbles.length)
|
|
88
|
+
throw new ModelParseError('E_MODEL_PARSE', 'paint_color underflow');
|
|
89
|
+
return nibbles[pos++];
|
|
90
|
+
};
|
|
91
|
+
const node = (depth) => {
|
|
92
|
+
if (depth > 16)
|
|
93
|
+
throw new ModelParseError('E_MODEL_PARSE', 'paint_color too deep');
|
|
94
|
+
const t = next();
|
|
95
|
+
const ss = t & 0b11;
|
|
96
|
+
if (ss === 0) {
|
|
97
|
+
const s2 = t >> 2;
|
|
98
|
+
states.push(s2 === 3 ? 3 + next() : s2);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (let i = 0; i <= ss; i++)
|
|
102
|
+
node(depth + 1);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
node(0);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (states.length === 0)
|
|
112
|
+
return null;
|
|
113
|
+
// Dominant leaf state — subdivided facets are a tiny fraction of any real model, so flattening them
|
|
114
|
+
// to their most-common state (and reporting `approximated`) is a faithful presentation trade-off.
|
|
115
|
+
const counts = new Map();
|
|
116
|
+
let best = states[0];
|
|
117
|
+
let bestN = 0;
|
|
118
|
+
for (const s of states) {
|
|
119
|
+
const c = (counts.get(s) ?? 0) + 1;
|
|
120
|
+
counts.set(s, c);
|
|
121
|
+
if (c > bestN) {
|
|
122
|
+
bestN = c;
|
|
123
|
+
best = s;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { state: best, subdivided: states.length > 1 };
|
|
127
|
+
}
|
|
59
128
|
function parseTransform(s) {
|
|
60
129
|
if (s === undefined)
|
|
61
130
|
return null;
|
|
@@ -125,7 +194,15 @@ function parseModelPart(xml, lim) {
|
|
|
125
194
|
else {
|
|
126
195
|
const a = attrs(raw);
|
|
127
196
|
curObjId = a.id ?? null;
|
|
128
|
-
curObj = {
|
|
197
|
+
curObj = {
|
|
198
|
+
vertices: [],
|
|
199
|
+
tris: [],
|
|
200
|
+
triColorIdx: [],
|
|
201
|
+
triPid: [],
|
|
202
|
+
triPaint: [],
|
|
203
|
+
components: [],
|
|
204
|
+
name: a.name
|
|
205
|
+
};
|
|
129
206
|
if (a.pid !== undefined)
|
|
130
207
|
curObj.objPid = a.pid;
|
|
131
208
|
if (a.pindex !== undefined)
|
|
@@ -160,6 +237,7 @@ function parseModelPart(xml, lim) {
|
|
|
160
237
|
}
|
|
161
238
|
const pid = a.pid ?? curObj.objPid ?? null;
|
|
162
239
|
curObj.triPid.push(pid);
|
|
240
|
+
curObj.triPaint.push(a.paint_color ?? null);
|
|
163
241
|
if (a.p1 !== undefined) {
|
|
164
242
|
const p1 = Number(a.p1);
|
|
165
243
|
const p2 = a.p2 !== undefined ? Number(a.p2) : p1;
|
|
@@ -195,7 +273,26 @@ function bakeMesh(obj, part, chain, id, st, lim) {
|
|
|
195
273
|
const positions = new Float32Array(triCount * 9);
|
|
196
274
|
let colors = null;
|
|
197
275
|
let objHasColor = false;
|
|
276
|
+
// A mesh painted with Bambu/Orca `paint_color` (only meaningful when we have a filament palette).
|
|
277
|
+
const objHasPaint = st.paintPalette.length > 0 && obj.triPaint.some((p) => p !== null);
|
|
198
278
|
for (let ti = 0; ti < triCount; ti++) {
|
|
279
|
+
// Paint path: one filament colour for the whole triangle. Painted facets decode to a state; the
|
|
280
|
+
// unpainted facets of a painted mesh take the object's default-extruder colour (never left blank).
|
|
281
|
+
let paintCol;
|
|
282
|
+
if (objHasPaint) {
|
|
283
|
+
let idx = st.defaultExtruderIdx;
|
|
284
|
+
const paint = obj.triPaint[ti];
|
|
285
|
+
if (paint !== null) {
|
|
286
|
+
const dec = decodePaintState(paint);
|
|
287
|
+
if (dec !== null) {
|
|
288
|
+
idx = dec.state === 0 ? st.defaultExtruderIdx : dec.state - 1;
|
|
289
|
+
if (dec.subdivided)
|
|
290
|
+
st.anyApprox = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
paintCol = st.paintPalette[idx] ?? st.paintPalette[st.defaultExtruderIdx];
|
|
294
|
+
}
|
|
295
|
+
// Standard 3MF material path (basematerials / colorgroup + pid) — fallback when there's no paint.
|
|
199
296
|
const pid = obj.triPid[ti];
|
|
200
297
|
const palette = pid !== null ? part.palettes.get(pid) : undefined;
|
|
201
298
|
const cidx = obj.triColorIdx[ti];
|
|
@@ -218,16 +315,14 @@ function bakeMesh(obj, part, chain, id, st, lim) {
|
|
|
218
315
|
st.max[1] = y;
|
|
219
316
|
if (z > st.max[2])
|
|
220
317
|
st.max[2] = z;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
objHasColor = true;
|
|
230
|
-
}
|
|
318
|
+
const col = paintCol ?? (palette !== undefined && cidx !== null ? (palette[cidx[k]] ?? palette[0]) : undefined);
|
|
319
|
+
if (col !== undefined) {
|
|
320
|
+
if (colors === null)
|
|
321
|
+
colors = new Float32Array(triCount * 9);
|
|
322
|
+
colors[o] = col[0];
|
|
323
|
+
colors[o + 1] = col[1];
|
|
324
|
+
colors[o + 2] = col[2];
|
|
325
|
+
objHasColor = true;
|
|
231
326
|
}
|
|
232
327
|
}
|
|
233
328
|
}
|
|
@@ -239,7 +334,8 @@ function bakeMesh(obj, part, chain, id, st, lim) {
|
|
|
239
334
|
built.name = obj.name;
|
|
240
335
|
st.built.push(built);
|
|
241
336
|
}
|
|
242
|
-
|
|
337
|
+
const hexPaletteToLinear = (hexes) => hexes.map((hex) => (hex !== undefined ? (srgbHexToLinear(hex) ?? [0.8, 0.8, 0.82]) : [0.8, 0.8, 0.82]));
|
|
338
|
+
export async function parse3mf(source, limits, opts) {
|
|
243
339
|
const lim = resolveLimits(limits);
|
|
244
340
|
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
|
245
341
|
if (bytes.byteLength === 0)
|
|
@@ -297,10 +393,50 @@ export async function parse3mf(source, limits) {
|
|
|
297
393
|
throw e;
|
|
298
394
|
throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
|
|
299
395
|
}
|
|
396
|
+
// Source-model filament palette for Bambu/Orca `paint_color`: it lives in `project_settings.config`
|
|
397
|
+
// (`filament_colour`), NOT the model XML, so the render is self-contained and needs no slicer output.
|
|
398
|
+
let paintPalette = [];
|
|
399
|
+
const override = opts?.filamentPalette;
|
|
400
|
+
if (override !== undefined && override.length > 0) {
|
|
401
|
+
// Consumer-supplied palette wins over the file's own (e.g. a corrected slicer palette).
|
|
402
|
+
paintPalette = hexPaletteToLinear(override);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const settingsEntry = entryByName('metadata/project_settings.config');
|
|
406
|
+
if (settingsEntry !== undefined) {
|
|
407
|
+
try {
|
|
408
|
+
const json = new TextDecoder().decode(await extractEntry(bytes, settingsEntry, climits.maxMetadataBytes));
|
|
409
|
+
const settings = JSON.parse(json);
|
|
410
|
+
paintPalette = hexPaletteToLinear(filamentColoursFromSettings(settings));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// Malformed/oversized settings → no palette; paint stays capability-honest `unavailable`.
|
|
414
|
+
paintPalette = [];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Default extruder (1-based) for a painted mesh's unpainted facets; from `model_settings.config`, else 1.
|
|
419
|
+
let defaultExtruderIdx = 0;
|
|
420
|
+
const modelSettingsEntry = entryByName('metadata/model_settings.config');
|
|
421
|
+
if (modelSettingsEntry !== undefined) {
|
|
422
|
+
try {
|
|
423
|
+
const xml = new TextDecoder().decode(await extractEntry(bytes, modelSettingsEntry, climits.maxMetadataBytes));
|
|
424
|
+
const m = /key="extruder"\s+value="(\d+)"/i.exec(xml);
|
|
425
|
+
const n = m !== null ? parseInt(m[1], 10) : NaN;
|
|
426
|
+
if (Number.isFinite(n) && n >= 1)
|
|
427
|
+
defaultExtruderIdx = n - 1;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
defaultExtruderIdx = 0;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
300
433
|
const st = {
|
|
301
434
|
built: [],
|
|
302
435
|
anyColor: false,
|
|
303
436
|
anyTransform: false,
|
|
437
|
+
anyApprox: false,
|
|
438
|
+
paintPalette,
|
|
439
|
+
defaultExtruderIdx,
|
|
304
440
|
min: [Infinity, Infinity, Infinity],
|
|
305
441
|
max: [-Infinity, -Infinity, -Infinity],
|
|
306
442
|
totalTris: 0
|
|
@@ -343,7 +479,8 @@ export async function parse3mf(source, limits) {
|
|
|
343
479
|
objects: st.built,
|
|
344
480
|
bounds,
|
|
345
481
|
capabilities: {
|
|
346
|
-
|
|
482
|
+
// `approximated` when any colour came from flattening a subdivided `paint_color` facet.
|
|
483
|
+
materials: st.anyApprox ? 'approximated' : st.anyColor ? 'known' : 'unavailable',
|
|
347
484
|
transforms: st.anyTransform ? 'known' : 'unavailable',
|
|
348
485
|
multiObject: st.built.length > 1 ? 'known' : 'unavailable'
|
|
349
486
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chestnutlabs/gcode-model-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"test": "vitest run"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@chestnutlabs/gcode-containers": "0.
|
|
55
|
-
"@chestnutlabs/gcode-renderer-three": "0.
|
|
56
|
-
"@chestnutlabs/toolpath-core": "0.
|
|
54
|
+
"@chestnutlabs/gcode-containers": "0.9.0",
|
|
55
|
+
"@chestnutlabs/gcode-renderer-three": "0.9.0",
|
|
56
|
+
"@chestnutlabs/toolpath-core": "0.9.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/three": "0.178.0"
|