@chestnutlabs/gcode-model-renderer 0.6.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.
@@ -0,0 +1,9 @@
1
+ /** The default environment id — the `three` revision, which gates render determinism. */
2
+ export declare function defaultEnvId(): string;
3
+ /**
4
+ * Compute a stable cache key. `optionsJson` should be a canonical serialization of the render options
5
+ * that affect output (size, background, view, lighting) — the caller passes it so only output-affecting
6
+ * fields are folded in. `envId` defaults to {@link defaultEnvId}.
7
+ */
8
+ export declare function computeCacheKey(sourceBytes: Uint8Array, optionsJson: string, envId?: string): string;
9
+ //# sourceMappingURL=cache-key.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache-key.d.ts","sourceRoot":"","sources":["../src/cache-key.ts"],"names":[],"mappings":"AAuCA,yFAAyF;AACzF,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,GAAE,MAAuB,GAAG,MAAM,CAKpH"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Deterministic cache key for a rendered still (DD-018 §4.3). A stable identity a consumer persists as
3
+ * thumbnail provenance and regenerates only when it changes: a hash of the source bytes, the
4
+ * output-affecting options, and an env id. The env id folds in the renderer/three version, because
5
+ * render output can change across three versions — determinism is promised **per environment**, not
6
+ * across GPUs/versions.
7
+ *
8
+ * The hash is FNV-1a (non-cryptographic): cache keys need determinism + change-sensitivity, not
9
+ * collision resistance. No dependencies, no Web Crypto (works identically in a worker/OffscreenCanvas).
10
+ */
11
+ import { REVISION } from 'three';
12
+ const FNV_OFFSET = 0x811c9dc5;
13
+ const FNV_PRIME = 0x01000193;
14
+ function fnv1aBytes(bytes, seed = FNV_OFFSET) {
15
+ let h = seed >>> 0;
16
+ for (let i = 0; i < bytes.length; i++) {
17
+ h ^= bytes[i];
18
+ h = Math.imul(h, FNV_PRIME);
19
+ }
20
+ return h >>> 0;
21
+ }
22
+ function fnv1aString(s, seed = FNV_OFFSET) {
23
+ let h = seed >>> 0;
24
+ for (let i = 0; i < s.length; i++) {
25
+ h ^= s.charCodeAt(i) & 0xff;
26
+ h = Math.imul(h, FNV_PRIME);
27
+ h ^= (s.charCodeAt(i) >> 8) & 0xff;
28
+ h = Math.imul(h, FNV_PRIME);
29
+ }
30
+ return h >>> 0;
31
+ }
32
+ function hex8(n) {
33
+ return (n >>> 0).toString(16).padStart(8, '0');
34
+ }
35
+ /** The default environment id — the `three` revision, which gates render determinism. */
36
+ export function defaultEnvId() {
37
+ return `three${REVISION}`;
38
+ }
39
+ /**
40
+ * Compute a stable cache key. `optionsJson` should be a canonical serialization of the render options
41
+ * that affect output (size, background, view, lighting) — the caller passes it so only output-affecting
42
+ * fields are folded in. `envId` defaults to {@link defaultEnvId}.
43
+ */
44
+ export function computeCacheKey(sourceBytes, optionsJson, envId = defaultEnvId()) {
45
+ const src = fnv1aBytes(sourceBytes);
46
+ const opt = fnv1aString(optionsJson, src); // chain so ordering matters
47
+ const env = fnv1aString(envId, opt);
48
+ return `mr1_${hex8(src)}${hex8(opt)}${hex8(env)}`;
49
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @chestnutlabs/gcode-model-renderer — Three.js **presentation** renderer for source models
3
+ * (STL now; 3MF multi-object/material next) — clean thumbnails/cards answering "what object is this?",
4
+ * distinct from toolpath inspection (DD-018). Sits on the shared render "stage" from
5
+ * `@chestnutlabs/gcode-renderer-three`. `three` is a peer dependency.
6
+ */
7
+ export { ModelRenderer, NEUTRAL_MATERIAL_COLOR } from './model-renderer.js';
8
+ export type { ModelRendererOptions, ModelBackground, PresentationView } from './model-renderer.js';
9
+ export { renderModelStill } from './render-model-still.js';
10
+ export type { ModelSource, RenderModelStillOptions, RenderModelStillResult } from './render-model-still.js';
11
+ export { parseStl } from './stl.js';
12
+ export { parse3mf } from './three-mf.js';
13
+ export { computeCacheKey, defaultEnvId } from './cache-key.js';
14
+ export { DEFAULT_LIMITS, resolveLimits, ModelParseError } from './limits.js';
15
+ export type { ModelLimits, ResolvedLimits } from './limits.js';
16
+ export { IDENTITY_MAT4 } from './scene-model.js';
17
+ export type { ModelScene, ModelObject, ModelMaterial, MeshGeometry, ModelBounds, RGB, Mat4 } from './scene-model.js';
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;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,QAAQ,EAAE,MAAM,UAAU,CAAC;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,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 ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @chestnutlabs/gcode-model-renderer — Three.js **presentation** renderer for source models
3
+ * (STL now; 3MF multi-object/material next) — clean thumbnails/cards answering "what object is this?",
4
+ * distinct from toolpath inspection (DD-018). Sits on the shared render "stage" from
5
+ * `@chestnutlabs/gcode-renderer-three`. `three` is a peer dependency.
6
+ */
7
+ export { ModelRenderer, NEUTRAL_MATERIAL_COLOR } from './model-renderer.js';
8
+ export { renderModelStill } from './render-model-still.js';
9
+ export { parseStl } from './stl.js';
10
+ export { parse3mf } from './three-mf.js';
11
+ export { computeCacheKey, defaultEnvId } from './cache-key.js';
12
+ export { DEFAULT_LIMITS, resolveLimits, ModelParseError } from './limits.js';
13
+ export { IDENTITY_MAT4 } from './scene-model.js';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Resource limits for untrusted source models (DD-018 §7). Source bytes are untrusted input; nothing
3
+ * is executed. Bounds are checked before large allocations so a hostile file fails cleanly.
4
+ */
5
+ export interface ModelLimits {
6
+ /** Max triangles across the whole scene. Default 5,000,000. */
7
+ maxTriangles?: number;
8
+ /** Max objects in a scene (3MF). Default 10,000. */
9
+ maxObjects?: number;
10
+ /** Max source bytes accepted before parsing. Default 256 MiB. */
11
+ maxSourceBytes?: number;
12
+ }
13
+ export interface ResolvedLimits {
14
+ maxTriangles: number;
15
+ maxObjects: number;
16
+ maxSourceBytes: number;
17
+ }
18
+ export declare const DEFAULT_LIMITS: ResolvedLimits;
19
+ export declare function resolveLimits(limits?: ModelLimits): ResolvedLimits;
20
+ /** A bounded, structured model-parse failure (DD-018 §6). */
21
+ export declare class ModelParseError extends Error {
22
+ readonly code: string;
23
+ constructor(code: string, message: string);
24
+ }
25
+ //# sourceMappingURL=limits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../src/limits.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,+DAA+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oDAAoD;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,cAAc,EAAE,cAI5B,CAAC;AAEF,wBAAgB,aAAa,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,cAAc,CAMlE;AAED,6DAA6D;AAC7D,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBACV,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAK1C"}
package/dist/limits.js ADDED
@@ -0,0 +1,21 @@
1
+ export const DEFAULT_LIMITS = {
2
+ maxTriangles: 5_000_000,
3
+ maxObjects: 10_000,
4
+ maxSourceBytes: 256 * 1024 * 1024
5
+ };
6
+ export function resolveLimits(limits) {
7
+ return {
8
+ maxTriangles: limits?.maxTriangles ?? DEFAULT_LIMITS.maxTriangles,
9
+ maxObjects: limits?.maxObjects ?? DEFAULT_LIMITS.maxObjects,
10
+ maxSourceBytes: limits?.maxSourceBytes ?? DEFAULT_LIMITS.maxSourceBytes
11
+ };
12
+ }
13
+ /** A bounded, structured model-parse failure (DD-018 §6). */
14
+ export class ModelParseError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = 'ModelParseError';
19
+ this.code = code;
20
+ }
21
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `ModelRenderer` (DD-018 §4.2) — a presentation render of a {@link ModelScene}, on the shared render
3
+ * "stage" from `@chestnutlabs/gcode-renderer-three` (framing pose, GL builder, GL type contracts).
4
+ *
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 lighting is its own
7
+ * (DD-018 §4.3): a presentation studio rig, not the toolpath light rig.
8
+ */
9
+ import { PerspectiveCamera, Scene } from 'three';
10
+ import { type GLRendererLike, type RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three';
11
+ import type { ModelScene, RGB } from './scene-model.js';
12
+ /** Background: `'transparent'` (composite on a card) or a solid CSS color / 0xRRGGBB. */
13
+ export type ModelBackground = 'transparent' | string | number;
14
+ /** Preset presentation angle. v1 implements `'iso'` (the shared 3/4 pose); others are reserved. */
15
+ export type PresentationView = 'iso';
16
+ export interface ModelRendererOptions {
17
+ canvas: RenderTargetCanvas;
18
+ background?: ModelBackground;
19
+ /** Injected GL (tests / exotic hosts). Default: the shared stage builder, alpha on for transparency. */
20
+ createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike;
21
+ preserveDrawingBuffer?: boolean;
22
+ }
23
+ export declare class ModelRenderer {
24
+ readonly scene: Scene;
25
+ readonly camera: PerspectiveCamera;
26
+ private readonly gl;
27
+ private readonly root;
28
+ private readonly disposables;
29
+ private width;
30
+ private height;
31
+ private disposed;
32
+ constructor(opts: ModelRendererOptions);
33
+ /** Build meshes for the scene and frame the camera. Replaces any previously-set scene. */
34
+ setScene(scene: ModelScene): void;
35
+ resize(width: number, height: number): void;
36
+ render(): void;
37
+ dispose(): void;
38
+ private buildMesh;
39
+ private clearMeshes;
40
+ }
41
+ /** Exposed for tests: the neutral default color used when a model declares no material. */
42
+ export declare const NEUTRAL_MATERIAL_COLOR: RGB;
43
+ //# sourceMappingURL=model-renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-renderer.d.ts","sourceRoot":"","sources":["../src/model-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EASL,iBAAiB,EACjB,KAAK,EAEN,MAAM,OAAO,CAAC;AACf,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACxB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAe,UAAU,EAAE,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAErE,yFAAyF;AACzF,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE9D,mGAAmG;AACnG,MAAM,MAAM,gBAAgB,GAAG,KAAK,CAAC;AAKrC,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,IAAI,CAAe;IACpC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD,OAAO,CAAC,KAAK,CAAO;IACpB,OAAO,CAAC,MAAM,CAAO;IACrB,OAAO,CAAC,QAAQ,CAAS;gBAEb,IAAI,EAAE,oBAAoB;IA2BtC,0FAA0F;IAC1F,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAgBjC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAQ3C,MAAM,IAAI,IAAI;IAKd,OAAO,IAAI,IAAI;IAOf,OAAO,CAAC,SAAS;IAwBjB,OAAO,CAAC,WAAW;CAKpB;AAED,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,EAAE,GAAmB,CAAC"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `ModelRenderer` (DD-018 §4.2) — a presentation render of a {@link ModelScene}, on the shared render
3
+ * "stage" from `@chestnutlabs/gcode-renderer-three` (framing pose, GL builder, GL type contracts).
4
+ *
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 lighting is its own
7
+ * (DD-018 §4.3): a presentation studio rig, not the toolpath light rig.
8
+ */
9
+ import { BufferAttribute, BufferGeometry, Color, DirectionalLight, Group, HemisphereLight, Mesh, MeshStandardMaterial, PerspectiveCamera, Scene, Vector3 } from 'three';
10
+ import { createDefaultGLRenderer, framingFromCenterRadius } from '@chestnutlabs/gcode-renderer-three';
11
+ /** Neutral default surface when the source declares no material (never a fabricated source color). */
12
+ const NEUTRAL_COLOR = [0.8, 0.8, 0.82];
13
+ export class ModelRenderer {
14
+ scene = new Scene();
15
+ camera = new PerspectiveCamera(50, 1, 0.1, 100_000);
16
+ gl;
17
+ root = new Group();
18
+ disposables = [];
19
+ width = 300;
20
+ height = 300;
21
+ disposed = false;
22
+ constructor(opts) {
23
+ const transparent = (opts.background ?? 'transparent') === 'transparent';
24
+ this.gl =
25
+ opts.createRenderer?.(opts.canvas) ??
26
+ createDefaultGLRenderer(opts.canvas, {
27
+ alpha: transparent,
28
+ preserveDrawingBuffer: opts.preserveDrawingBuffer ?? true
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
+ this.scene.background = transparent ? null : new Color(opts.background);
35
+ // Studio rig (presentation-specific): soft hemisphere fill + a key directional. Lights live in
36
+ // scene space (not under the rotated root) so the key stays top-ish regardless of model orientation.
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);
45
+ }
46
+ /** Build meshes for the scene and frame the camera. Replaces any previously-set scene. */
47
+ setScene(scene) {
48
+ if (this.disposed)
49
+ return;
50
+ this.clearMeshes();
51
+ for (const obj of scene.objects)
52
+ this.root.add(this.buildMesh(obj));
53
+ // Frame from the model's own bounds (model/printer coords), via the shared stage pose.
54
+ const [minX, minY, minZ] = scene.bounds.min;
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);
59
+ this.camera.position.copy(position);
60
+ this.camera.lookAt(target);
61
+ this.camera.updateProjectionMatrix();
62
+ }
63
+ resize(width, height) {
64
+ this.width = width;
65
+ this.height = height;
66
+ this.camera.aspect = height > 0 ? width / height : 1;
67
+ this.camera.updateProjectionMatrix();
68
+ this.gl.setSize(width, height, false);
69
+ }
70
+ render() {
71
+ if (this.disposed)
72
+ return;
73
+ this.gl.render(this.scene, this.camera);
74
+ }
75
+ dispose() {
76
+ if (this.disposed)
77
+ return;
78
+ this.disposed = true;
79
+ this.clearMeshes();
80
+ this.gl.dispose();
81
+ }
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
+ }
114
+ /** Exposed for tests: the neutral default color used when a model declares no material. */
115
+ export const NEUTRAL_MATERIAL_COLOR = NEUTRAL_COLOR;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `renderModelStill` (DD-018 §4.3) — the headless presentation-thumbnail preset, mirroring
3
+ * `renderStill` on the toolpath side. Parse a source model → build a {@link ModelScene} → render one
4
+ * deterministic frame → return the canvas + a stable cache key. The caller extracts pixels in its own
5
+ * environment (`OffscreenCanvas.convertToBlob`, `toDataURL`, `readPixels`).
6
+ *
7
+ * Environment: any Chromium-class WebGL2 context — an OffscreenCanvas in a Worker, or headless
8
+ * Chromium via ANGLE→SwiftShader (the software-GL path). A windowed GLX/GLFW path is out of scope.
9
+ *
10
+ * Determinism: same input + same environment ⇒ identical output. Cross-GPU/driver pixel identity is
11
+ * not promised; cache by the `cacheKey` on the returned {@link RenderModelStillResult}.
12
+ */
13
+ import type { Confidence } from '@chestnutlabs/toolpath-core';
14
+ import type { GLRendererLike, RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three';
15
+ import { type ModelBackground, type PresentationView } from './model-renderer.js';
16
+ import type { ModelScene } from './scene-model.js';
17
+ import type { ModelLimits } from './limits.js';
18
+ /** A source model: STL bytes, 3MF bytes, or a pre-built `ModelScene`. */
19
+ export type ModelSource = {
20
+ kind: 'stl';
21
+ bytes: Uint8Array | ArrayBuffer;
22
+ } | {
23
+ kind: '3mf';
24
+ bytes: Uint8Array | ArrayBuffer;
25
+ } | ModelScene;
26
+ export interface RenderModelStillOptions {
27
+ canvas: RenderTargetCanvas;
28
+ width?: number;
29
+ height?: number;
30
+ /** Background: `'transparent'` (default — composite on a card) or a solid CSS color / 0xRRGGBB. */
31
+ background?: ModelBackground;
32
+ /** Preset angle; default `'iso'`. */
33
+ view?: PresentationView;
34
+ createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike;
35
+ limits?: ModelLimits;
36
+ /** Override the environment id folded into the cache key (default: the `three` revision). */
37
+ envId?: string;
38
+ }
39
+ export interface RenderModelStillResult {
40
+ canvas: RenderTargetCanvas;
41
+ width: number;
42
+ height: number;
43
+ objectCount: number;
44
+ /** Whether source colors/materials were real — `'unavailable'` means the neutral default was used. */
45
+ materials: Confidence;
46
+ /** Stable identity for caching: `hash(source) + options + envId`. */
47
+ cacheKey: string;
48
+ }
49
+ /**
50
+ * Render one presentation still. Async because the 3MF path unzips with the container reader
51
+ * (`DecompressionStream`); the STL and pre-built-`ModelScene` paths resolve promptly. Mirrors the
52
+ * async `renderStill` on the toolpath side.
53
+ */
54
+ export declare function renderModelStill(source: ModelSource, options: RenderModelStillOptions): Promise<RenderModelStillResult>;
55
+ //# sourceMappingURL=render-model-still.d.ts.map
@@ -0,0 +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;AAGjG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,yEAAyE;AACzE,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,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;AAoBD;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,sBAAsB,CAAC,CAiCjC"}
@@ -0,0 +1,54 @@
1
+ import { ModelRenderer } from './model-renderer.js';
2
+ import { parseStl } from './stl.js';
3
+ import { parse3mf } from './three-mf.js';
4
+ import { computeCacheKey } from './cache-key.js';
5
+ function isModelScene(s) {
6
+ return s.objects !== undefined && s.kind === undefined;
7
+ }
8
+ async function toScene(source, limits) {
9
+ if (isModelScene(source)) {
10
+ // No raw bytes for a pre-built scene: key off a stable structural summary instead.
11
+ const summary = JSON.stringify({ n: source.objects.length, b: source.bounds, c: source.capabilities });
12
+ return { scene: source, sourceBytes: new TextEncoder().encode(summary) };
13
+ }
14
+ const bytes = source.bytes instanceof Uint8Array ? source.bytes : new Uint8Array(source.bytes);
15
+ const scene = source.kind === '3mf' ? await parse3mf(bytes, limits) : parseStl(bytes, limits);
16
+ return { scene, sourceBytes: bytes };
17
+ }
18
+ /**
19
+ * Render one presentation still. Async because the 3MF path unzips with the container reader
20
+ * (`DecompressionStream`); the STL and pre-built-`ModelScene` paths resolve promptly. Mirrors the
21
+ * async `renderStill` on the toolpath side.
22
+ */
23
+ export async function renderModelStill(source, options) {
24
+ const { canvas } = options;
25
+ const width = options.width ?? canvas.width;
26
+ const height = options.height ?? canvas.height;
27
+ const background = options.background ?? 'transparent';
28
+ const view = options.view ?? 'iso';
29
+ const { scene, sourceBytes } = await toScene(source, options.limits);
30
+ const renderer = new ModelRenderer({
31
+ canvas,
32
+ background,
33
+ ...(options.createRenderer ? { createRenderer: options.createRenderer } : {}),
34
+ preserveDrawingBuffer: true
35
+ });
36
+ try {
37
+ renderer.resize(width, height);
38
+ renderer.setScene(scene);
39
+ renderer.render();
40
+ const optionsJson = JSON.stringify({ width, height, background, view });
41
+ const cacheKey = computeCacheKey(sourceBytes, optionsJson, options.envId);
42
+ return {
43
+ canvas,
44
+ width,
45
+ height,
46
+ objectCount: scene.objects.length,
47
+ materials: scene.capabilities.materials,
48
+ cacheKey
49
+ };
50
+ }
51
+ finally {
52
+ renderer.dispose();
53
+ }
54
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The three-free presentation scene model (DD-018 §4.1).
3
+ *
4
+ * `ModelScene` is what `ModelRenderer` renders: one or more positioned objects with optional
5
+ * per-object material/color. It is **multi-object and material-capable from day one** — STL is the
6
+ * degenerate single-object, no-material case; 3MF (Phase 2) is the general multi-object/material case.
7
+ * Keeping the public shape general now means the STL slice cannot lock in a model that 3MF must replace.
8
+ *
9
+ * The types are deliberately three-free (plain numbers / typed arrays) so the package's public surface
10
+ * never leaks `three`; `ModelRenderer` builds three meshes/materials from a `ModelScene` internally.
11
+ */
12
+ import type { Confidence } from '@chestnutlabs/toolpath-core';
13
+ /** Linear RGB in 0..1. */
14
+ export type RGB = [number, number, number];
15
+ /** A 4×4 column-major transform (three's `Matrix4.elements` layout). Identity for a bare STL. */
16
+ export type Mat4 = readonly number[];
17
+ /** Axis-aligned bounds in scene units (mm assumed unless the source declares otherwise). */
18
+ export interface ModelBounds {
19
+ min: readonly [number, number, number];
20
+ max: readonly [number, number, number];
21
+ }
22
+ /** Triangle mesh geometry (three-free). Positions are required; normals/indices/colors are optional. */
23
+ export interface MeshGeometry {
24
+ /** Flat XYZ positions, length = 3·vertexCount. */
25
+ positions: Float32Array;
26
+ /** Optional flat XYZ normals (same length as positions); recomputed if absent. */
27
+ normals?: Float32Array;
28
+ /** Optional triangle indices; non-indexed (soup) when absent. */
29
+ indices?: Uint32Array;
30
+ /**
31
+ * Optional flat per-vertex **linear** RGB colors (length = positions.length), for a single mesh that
32
+ * carries multiple colors (per-triangle material assignment in 3MF). When present the renderer uses
33
+ * vertex colors; when absent the object's {@link ModelObject.material} (or the neutral default) applies.
34
+ */
35
+ colors?: Float32Array;
36
+ }
37
+ /** A source material for an object — solid color only in v1 (textures deferred, DD-018 §13). */
38
+ export interface ModelMaterial {
39
+ /** Base color when the source declared one; omit for "no declared material". */
40
+ color?: RGB;
41
+ }
42
+ /** One positioned object in a scene. */
43
+ export interface ModelObject {
44
+ /** Stable within a scene (3MF object id; `'stl'` for a bare STL). */
45
+ id: string;
46
+ /** Source-declared object name when present. */
47
+ name?: string;
48
+ geometry: MeshGeometry;
49
+ /** Object→scene transform (identity for STL). */
50
+ transform: Mat4;
51
+ /** Omitted ⇒ no source material ⇒ neutral default render + `capabilities.materials: 'unavailable'`. */
52
+ material?: ModelMaterial;
53
+ }
54
+ /** A presentation scene: one or more objects plus capability honesty about what the source carried. */
55
+ export interface ModelScene {
56
+ objects: ModelObject[];
57
+ bounds: ModelBounds;
58
+ /** What the source actually carried (DD-001 ethos — never fabricated). */
59
+ capabilities: {
60
+ /** `'known'` when the source assigned colors/materials, else `'unavailable'`. */
61
+ materials: Confidence;
62
+ /** `'known'` for real per-object transforms (3MF); `'unavailable'` for a bare STL. */
63
+ transforms: Confidence;
64
+ /** `'known'` when the source declared more than one object. */
65
+ multiObject: Confidence;
66
+ };
67
+ }
68
+ /** The identity transform (column-major), for single-object sources. */
69
+ export declare const IDENTITY_MAT4: Mat4;
70
+ //# sourceMappingURL=scene-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scene-model.d.ts","sourceRoot":"","sources":["../src/scene-model.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,0BAA0B;AAC1B,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAE3C,iGAAiG;AACjG,MAAM,MAAM,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC;AAErC,4FAA4F;AAC5F,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,GAAG,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;CACxC;AAED,wGAAwG;AACxG,MAAM,WAAW,YAAY;IAC3B,kDAAkD;IAClD,SAAS,EAAE,YAAY,CAAC;IACxB,kFAAkF;IAClF,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,iEAAiE;IACjE,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB;;;;OAIG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AAED,gGAAgG;AAChG,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,KAAK,CAAC,EAAE,GAAG,CAAC;CACb;AAED,wCAAwC;AACxC,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,gDAAgD;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,YAAY,CAAC;IACvB,iDAAiD;IACjD,SAAS,EAAE,IAAI,CAAC;IAChB,uGAAuG;IACvG,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED,uGAAuG;AACvG,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,MAAM,EAAE,WAAW,CAAC;IACpB,0EAA0E;IAC1E,YAAY,EAAE;QACZ,iFAAiF;QACjF,SAAS,EAAE,UAAU,CAAC;QACtB,sFAAsF;QACtF,UAAU,EAAE,UAAU,CAAC;QACvB,+DAA+D;QAC/D,WAAW,EAAE,UAAU,CAAC;KACzB,CAAC;CACH;AAED,wEAAwE;AACxE,eAAO,MAAM,aAAa,EAAE,IAAuD,CAAC"}
@@ -0,0 +1,2 @@
1
+ /** The identity transform (column-major), for single-object sources. */
2
+ export const IDENTITY_MAT4 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
package/dist/stl.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { type ModelScene } from './scene-model.js';
2
+ import { type ModelLimits } from './limits.js';
3
+ /** Parse STL bytes into a single-object {@link ModelScene}. Throws {@link ModelParseError} on failure. */
4
+ export declare function parseStl(source: Uint8Array | ArrayBuffer, limits?: ModelLimits): ModelScene;
5
+ //# sourceMappingURL=stl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stl.d.ts","sourceRoot":"","sources":["../src/stl.ts"],"names":[],"mappings":"AAQA,OAAO,EAAsD,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACvG,OAAO,EAAkC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAgB/E,0GAA0G;AAC1G,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,WAAW,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,UAAU,CAmD3F"}
package/dist/stl.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * STL → {@link ModelScene} (DD-018 Phase 1). Binary and ASCII, via three's `STLLoader`.
3
+ *
4
+ * STL carries no color/material and no object structure, so it is the degenerate single-object,
5
+ * no-material case: one object, identity transform, `materials: 'unavailable'`,
6
+ * `transforms: 'unavailable'`, `multiObject: 'unavailable'` — honest, never fabricated.
7
+ */
8
+ import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
9
+ import { IDENTITY_MAT4 } from './scene-model.js';
10
+ import { ModelParseError, resolveLimits } from './limits.js';
11
+ function toArrayBuffer(bytes) {
12
+ if (bytes instanceof ArrayBuffer)
13
+ return bytes;
14
+ // Copy out the exact view (a Uint8Array may be a partial window over a larger buffer).
15
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
16
+ }
17
+ /** Binary-STL triangle count from its header (bytes 80..84, little-endian), or null if not binary-shaped. */
18
+ function binaryTriangleCount(buf) {
19
+ if (buf.byteLength < 84)
20
+ return null;
21
+ const n = new DataView(buf).getUint32(80, true);
22
+ // Binary STL is exactly 84 + 50·n bytes. If that doesn't hold, it's ASCII (or malformed).
23
+ return 84 + n * 50 === buf.byteLength ? n : null;
24
+ }
25
+ /** Parse STL bytes into a single-object {@link ModelScene}. Throws {@link ModelParseError} on failure. */
26
+ export function parseStl(source, limits) {
27
+ const lim = resolveLimits(limits);
28
+ const buf = toArrayBuffer(source);
29
+ if (buf.byteLength === 0)
30
+ throw new ModelParseError('E_MODEL_EMPTY', 'STL source is empty');
31
+ if (buf.byteLength > lim.maxSourceBytes) {
32
+ throw new ModelParseError('E_MODEL_TOO_LARGE', `STL source ${buf.byteLength} bytes exceeds limit ${lim.maxSourceBytes}`);
33
+ }
34
+ // Pre-check binary triangle count before the loader allocates buffers for it.
35
+ const preTris = binaryTriangleCount(buf);
36
+ if (preTris !== null && preTris > lim.maxTriangles) {
37
+ throw new ModelParseError('E_MODEL_TOO_MANY_TRIANGLES', `STL declares ${preTris} triangles, exceeds limit ${lim.maxTriangles}`);
38
+ }
39
+ let geom;
40
+ try {
41
+ geom = new STLLoader().parse(buf);
42
+ }
43
+ catch (e) {
44
+ throw new ModelParseError('E_MODEL_PARSE', `STL parse failed: ${e.message}`);
45
+ }
46
+ const posAttr = geom.getAttribute('position');
47
+ if (posAttr === undefined || posAttr.count === 0) {
48
+ throw new ModelParseError('E_MODEL_EMPTY', 'STL contains no triangles');
49
+ }
50
+ const triangles = Math.floor(posAttr.count / 3);
51
+ if (triangles > lim.maxTriangles) {
52
+ geom.dispose();
53
+ throw new ModelParseError('E_MODEL_TOO_MANY_TRIANGLES', `STL has ${triangles} triangles, exceeds limit ${lim.maxTriangles}`);
54
+ }
55
+ const positions = new Float32Array(posAttr.array);
56
+ const normAttr = geom.getAttribute('normal');
57
+ const normals = normAttr !== undefined ? new Float32Array(normAttr.array) : undefined;
58
+ const bounds = boundsOf(positions);
59
+ geom.dispose();
60
+ const geometry = normals !== undefined ? { positions, normals } : { positions };
61
+ return {
62
+ objects: [{ id: 'stl', geometry, transform: IDENTITY_MAT4 }],
63
+ bounds,
64
+ capabilities: { materials: 'unavailable', transforms: 'unavailable', multiObject: 'unavailable' }
65
+ };
66
+ }
67
+ function boundsOf(positions) {
68
+ let minX = Infinity;
69
+ let minY = Infinity;
70
+ let minZ = Infinity;
71
+ let maxX = -Infinity;
72
+ let maxY = -Infinity;
73
+ let maxZ = -Infinity;
74
+ for (let i = 0; i < positions.length; i += 3) {
75
+ const x = positions[i];
76
+ const y = positions[i + 1];
77
+ const z = positions[i + 2];
78
+ if (x < minX)
79
+ minX = x;
80
+ if (y < minY)
81
+ minY = y;
82
+ if (z < minZ)
83
+ minZ = z;
84
+ if (x > maxX)
85
+ maxX = x;
86
+ if (y > maxY)
87
+ maxY = y;
88
+ if (z > maxZ)
89
+ maxZ = z;
90
+ }
91
+ return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
92
+ }
@@ -0,0 +1,5 @@
1
+ import { type ModelScene } from './scene-model.js';
2
+ import { type ModelLimits } from './limits.js';
3
+ /** Parse 3MF bytes into a {@link ModelScene}. Rejects with {@link ModelParseError} on failure. */
4
+ export declare function parse3mf(source: Uint8Array | ArrayBuffer, limits?: ModelLimits): Promise<ModelScene>;
5
+ //# sourceMappingURL=three-mf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"AAaA,OAAO,EAKL,KAAK,UAAU,EAEhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAkC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAyE/E,kGAAkG;AAClG,wBAAsB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,WAAW,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAoG1G"}
@@ -0,0 +1,288 @@
1
+ /**
2
+ * 3MF → {@link ModelScene} (DD-018 Phase 2). Multi-object, per-object/per-triangle **solid** colors,
3
+ * build-item transforms — a multicolor model becomes a multicolor thumbnail without slicing.
4
+ *
5
+ * 3MF is an OPC (ZIP) package; the ZIP is opened with the hardened, zero-dep reader from
6
+ * `@chestnutlabs/gcode-containers` (DD-005 §7 — zip-bomb / traversal / size caps reused verbatim). The
7
+ * 3D model part is a small XML document parsed here with a minimal, worker-safe scan (no `DOMParser`,
8
+ * which Web Workers lack; no dependency). Only the subset needed for a presentation render is read:
9
+ * `<vertices>/<triangles>`, object/triangle material refs, `<basematerials>`/`<m:colorgroup>` palettes,
10
+ * and `<build><item>` transforms. Textures and non-color material properties are ignored (disclosed via
11
+ * capability honesty), never fetched (no network).
12
+ */
13
+ import { readDirectory, extractEntry, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
14
+ import { IDENTITY_MAT4 } from './scene-model.js';
15
+ import { ModelParseError, resolveLimits } from './limits.js';
16
+ /** millimeter is the 3MF default; convert others to mm. */
17
+ const UNIT_TO_MM = {
18
+ micron: 0.001,
19
+ millimeter: 1,
20
+ centimeter: 10,
21
+ meter: 1000,
22
+ inch: 25.4,
23
+ foot: 304.8
24
+ };
25
+ const TAG_RE = /<(\/?)([\w:.-]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
26
+ const ATTR_RE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
27
+ function localName(tag) {
28
+ const i = tag.indexOf(':');
29
+ return i === -1 ? tag : tag.slice(i + 1);
30
+ }
31
+ function attrs(s) {
32
+ const out = {};
33
+ ATTR_RE.lastIndex = 0;
34
+ let m;
35
+ while ((m = ATTR_RE.exec(s)) !== null)
36
+ out[m[1]] = m[2] ?? m[3] ?? '';
37
+ return out;
38
+ }
39
+ /** sRGB "#RRGGBB(AA)" → linear RGB in 0..1 (alpha ignored). Returns null on a malformed value. */
40
+ function srgbHexToLinear(hex) {
41
+ const h = hex.trim().replace(/^#/, '');
42
+ if (h.length < 6)
43
+ return null;
44
+ const to = (o) => {
45
+ const c = parseInt(h.slice(o, o + 2), 16) / 255;
46
+ if (Number.isNaN(c))
47
+ return NaN;
48
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
49
+ };
50
+ const r = to(0);
51
+ const g = to(2);
52
+ const b = to(4);
53
+ return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : [r, g, b];
54
+ }
55
+ async function findModelXml(bytes) {
56
+ const limits = DEFAULT_CONTAINER_LIMITS;
57
+ const dir = readDirectory(bytes, limits);
58
+ // Prefer the conventional path, then the OPC-declared StartPart, then any *.model.
59
+ const byName = (pred) => dir.entries.find((e) => pred(e.name.toLowerCase()));
60
+ let entry = byName((n) => n === '3d/3dmodel.model');
61
+ if (entry === undefined) {
62
+ const rels = byName((n) => n === '_rels/.rels');
63
+ if (rels !== undefined) {
64
+ const relXml = new TextDecoder().decode(await extractEntry(bytes, rels, limits.maxMetadataBytes));
65
+ const target = /Target="\/?([^"]+\.model)"/i.exec(relXml)?.[1];
66
+ if (target !== undefined)
67
+ entry = byName((n) => n === target.toLowerCase());
68
+ }
69
+ }
70
+ if (entry === undefined)
71
+ entry = byName((n) => n.endsWith('.model'));
72
+ if (entry === undefined)
73
+ throw new ModelParseError('E_MODEL_PARSE', '3MF contains no 3D model part');
74
+ return extractEntry(bytes, entry, limits.maxExpandedBytesPerEntry);
75
+ }
76
+ /** Parse 3MF bytes into a {@link ModelScene}. Rejects with {@link ModelParseError} on failure. */
77
+ export async function parse3mf(source, limits) {
78
+ const lim = resolveLimits(limits);
79
+ const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
80
+ if (bytes.byteLength === 0)
81
+ throw new ModelParseError('E_MODEL_EMPTY', '3MF source is empty');
82
+ if (bytes.byteLength > lim.maxSourceBytes) {
83
+ throw new ModelParseError('E_MODEL_TOO_LARGE', `3MF source ${bytes.byteLength} bytes exceeds limit ${lim.maxSourceBytes}`);
84
+ }
85
+ let xml;
86
+ try {
87
+ xml = new TextDecoder().decode(await findModelXml(bytes));
88
+ }
89
+ catch (e) {
90
+ if (e instanceof ModelParseError)
91
+ throw e;
92
+ throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
93
+ }
94
+ // Single scan over the model XML.
95
+ let unitScale = 1;
96
+ const palettes = new Map(); // resource id → colors
97
+ const objects = new Map();
98
+ const items = [];
99
+ let curObj = null;
100
+ let curObjId = null;
101
+ let curPaletteId = null;
102
+ let inBuild = false;
103
+ TAG_RE.lastIndex = 0;
104
+ let t;
105
+ while ((t = TAG_RE.exec(xml)) !== null) {
106
+ const closing = t[1] === '/';
107
+ const name = localName(t[2]);
108
+ const selfClose = t[4] === '/';
109
+ const raw = t[3];
110
+ if (name === 'model' && !closing) {
111
+ const u = attrs(raw).unit;
112
+ if (u !== undefined && UNIT_TO_MM[u] !== undefined)
113
+ unitScale = UNIT_TO_MM[u];
114
+ }
115
+ else if (name === 'basematerials' || name === 'colorgroup') {
116
+ if (closing)
117
+ curPaletteId = null;
118
+ else {
119
+ curPaletteId = attrs(raw).id ?? null;
120
+ if (curPaletteId !== null)
121
+ palettes.set(curPaletteId, []);
122
+ }
123
+ }
124
+ else if ((name === 'base' || name === 'color') && curPaletteId !== null) {
125
+ const a = attrs(raw);
126
+ const c = srgbHexToLinear(a.displaycolor ?? a.color ?? '');
127
+ const arr = palettes.get(curPaletteId);
128
+ if (arr !== undefined)
129
+ arr.push(c ?? [0.8, 0.8, 0.82]);
130
+ }
131
+ else if (name === 'object') {
132
+ if (closing) {
133
+ if (curObj !== null && curObjId !== null)
134
+ objects.set(curObjId, curObj);
135
+ curObj = null;
136
+ curObjId = null;
137
+ }
138
+ else {
139
+ const a = attrs(raw);
140
+ curObjId = a.id ?? null;
141
+ curObj = { vertices: [], tris: [], triColorIdx: [], triPid: [], name: a.name };
142
+ if (a.pid !== undefined)
143
+ curObj.objPid = a.pid;
144
+ if (a.pindex !== undefined)
145
+ curObj.objPindex = Number(a.pindex);
146
+ if (selfClose) {
147
+ curObj = null;
148
+ curObjId = null;
149
+ }
150
+ }
151
+ }
152
+ else if (name === 'vertex' && curObj !== null) {
153
+ const a = attrs(raw);
154
+ curObj.vertices.push(Number(a.x) || 0, Number(a.y) || 0, Number(a.z) || 0);
155
+ }
156
+ else if (name === 'triangle' && curObj !== null) {
157
+ const a = attrs(raw);
158
+ curObj.tris.push(Number(a.v1) | 0, Number(a.v2) | 0, Number(a.v3) | 0);
159
+ if (curObj.tris.length / 3 > lim.maxTriangles) {
160
+ throw new ModelParseError('E_MODEL_TOO_MANY_TRIANGLES', `3MF exceeds triangle limit ${lim.maxTriangles}`);
161
+ }
162
+ const pid = a.pid ?? curObj.objPid ?? null;
163
+ curObj.triPid.push(pid);
164
+ // Per-vertex color indices p1/p2/p3 (fall back to p1 for all, then object pindex).
165
+ if (a.p1 !== undefined) {
166
+ const p1 = Number(a.p1);
167
+ const p2 = a.p2 !== undefined ? Number(a.p2) : p1;
168
+ const p3 = a.p3 !== undefined ? Number(a.p3) : p1;
169
+ curObj.triColorIdx.push([p1, p2, p3]);
170
+ }
171
+ else if (curObj.objPindex !== undefined) {
172
+ const p = curObj.objPindex;
173
+ curObj.triColorIdx.push([p, p, p]);
174
+ }
175
+ else {
176
+ curObj.triColorIdx.push(null);
177
+ }
178
+ }
179
+ else if (name === 'build') {
180
+ inBuild = !closing;
181
+ }
182
+ else if (name === 'item' && inBuild && !closing) {
183
+ const a = attrs(raw);
184
+ if (a.objectid !== undefined)
185
+ items.push({ objectid: a.objectid, transform: a.transform });
186
+ }
187
+ }
188
+ return assemble(objects, palettes, items, unitScale, lim.maxTriangles);
189
+ }
190
+ function parseTransform(s) {
191
+ if (s === undefined)
192
+ return null;
193
+ const v = s.trim().split(/\s+/).map(Number);
194
+ return v.length === 12 && v.every((n) => !Number.isNaN(n)) ? v : null;
195
+ }
196
+ /** Apply a 3MF row-vector affine (12 values) to a point. */
197
+ function applyTransform(v, x, y, z) {
198
+ return [
199
+ v[0] * x + v[3] * y + v[6] * z + v[9],
200
+ v[1] * x + v[4] * y + v[7] * z + v[10],
201
+ v[2] * x + v[5] * y + v[8] * z + v[11]
202
+ ];
203
+ }
204
+ function assemble(objects, palettes, items, unitScale, maxTriangles) {
205
+ // With no <build>, render every declared object at identity (some minimal 3MFs omit build).
206
+ const instances = items.length > 0 ? items : [...objects.keys()].map((objectid) => ({ objectid, transform: undefined }));
207
+ const built = [];
208
+ let anyColor = false;
209
+ let anyTransform = false;
210
+ const min = [Infinity, Infinity, Infinity];
211
+ const max = [-Infinity, -Infinity, -Infinity];
212
+ let totalTris = 0;
213
+ for (const inst of instances) {
214
+ const obj = objects.get(inst.objectid);
215
+ if (obj === undefined || obj.tris.length === 0)
216
+ continue;
217
+ const xform = parseTransform(inst.transform);
218
+ if (xform !== null)
219
+ anyTransform = true;
220
+ const triCount = obj.tris.length / 3;
221
+ totalTris += triCount;
222
+ if (totalTris > maxTriangles)
223
+ throw new ModelParseError('E_MODEL_TOO_MANY_TRIANGLES', `3MF exceeds triangle limit ${maxTriangles}`);
224
+ // Expand to a non-indexed mesh (per-triangle colors need independent vertices).
225
+ const positions = new Float32Array(triCount * 9);
226
+ let colors = null;
227
+ let objHasColor = false;
228
+ for (let ti = 0; ti < triCount; ti++) {
229
+ const pid = obj.triPid[ti];
230
+ const palette = pid !== null ? palettes.get(pid) : undefined;
231
+ const cidx = obj.triColorIdx[ti];
232
+ for (let k = 0; k < 3; k++) {
233
+ const vi = obj.tris[ti * 3 + k] * 3;
234
+ let x = obj.vertices[vi] * unitScale;
235
+ let y = obj.vertices[vi + 1] * unitScale;
236
+ let z = obj.vertices[vi + 2] * unitScale;
237
+ if (xform !== null)
238
+ [x, y, z] = applyTransform(xform, x, y, z);
239
+ const o = (ti * 3 + k) * 3;
240
+ positions[o] = x;
241
+ positions[o + 1] = y;
242
+ positions[o + 2] = z;
243
+ if (x < min[0])
244
+ min[0] = x;
245
+ if (y < min[1])
246
+ min[1] = y;
247
+ if (z < min[2])
248
+ min[2] = z;
249
+ if (x > max[0])
250
+ max[0] = x;
251
+ if (y > max[1])
252
+ max[1] = y;
253
+ if (z > max[2])
254
+ max[2] = z;
255
+ if (palette !== undefined && cidx !== null) {
256
+ const col = palette[cidx[k]] ?? palette[0];
257
+ if (col !== undefined) {
258
+ if (colors === null)
259
+ colors = new Float32Array(triCount * 9);
260
+ colors[o] = col[0];
261
+ colors[o + 1] = col[1];
262
+ colors[o + 2] = col[2];
263
+ objHasColor = true;
264
+ }
265
+ }
266
+ }
267
+ }
268
+ if (objHasColor)
269
+ anyColor = true;
270
+ const geometry = colors !== null ? { positions, colors } : { positions };
271
+ const built1 = { id: inst.objectid, geometry, transform: IDENTITY_MAT4 };
272
+ if (obj.name !== undefined)
273
+ built1.name = obj.name;
274
+ built.push(built1);
275
+ }
276
+ if (built.length === 0)
277
+ throw new ModelParseError('E_MODEL_EMPTY', '3MF contains no renderable geometry');
278
+ const bounds = { min, max };
279
+ return {
280
+ objects: built,
281
+ bounds,
282
+ capabilities: {
283
+ materials: anyColor ? 'known' : 'unavailable',
284
+ transforms: anyTransform ? 'known' : 'unavailable',
285
+ multiObject: built.length > 1 ? 'known' : 'unavailable'
286
+ }
287
+ };
288
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@chestnutlabs/gcode-model-renderer",
3
+ "version": "0.6.0",
4
+ "description": "Three.js presentation renderer for source models (STL/3MF) — clean thumbnails/cards, distinct from toolpath inspection (DD-018).",
5
+ "keywords": [
6
+ "3d-printing",
7
+ "stl",
8
+ "3mf",
9
+ "model",
10
+ "thumbnail",
11
+ "presentation",
12
+ "chestnutlabs",
13
+ "three",
14
+ "threejs",
15
+ "webgl",
16
+ "renderer",
17
+ "offscreen"
18
+ ],
19
+ "author": "Chestnut Labs",
20
+ "homepage": "https://github.com/ChestnutLabs/gcode-preview#readme",
21
+ "bugs": "https://github.com/ChestnutLabs/gcode-preview/issues",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/ChestnutLabs/gcode-preview.git",
25
+ "directory": "packages/gcode-model-renderer"
26
+ },
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "main": "./dist/index.js",
34
+ "module": "./dist/index.js",
35
+ "types": "./dist/index.d.ts",
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js"
40
+ }
41
+ },
42
+ "files": [
43
+ "dist"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.json",
50
+ "typecheck": "tsc -p tsconfig.json --noEmit",
51
+ "test": "vitest run"
52
+ },
53
+ "dependencies": {
54
+ "@chestnutlabs/gcode-containers": "0.6.0",
55
+ "@chestnutlabs/gcode-renderer-three": "0.6.0",
56
+ "@chestnutlabs/toolpath-core": "0.6.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/three": "0.178.0"
60
+ },
61
+ "peerDependencies": {
62
+ "three": "^0.178.0"
63
+ }
64
+ }