@chestnutlabs/gcode-model-renderer 0.10.0 → 0.12.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/dist/limits.d.ts CHANGED
@@ -9,11 +9,19 @@ export interface ModelLimits {
9
9
  maxObjects?: number;
10
10
  /** Max source bytes accepted before parsing. Default 256 MiB. */
11
11
  maxSourceBytes?: number;
12
+ /**
13
+ * Max total instance placements across the scene (DD-022): a source 3MF that reuses meshes via
14
+ * production-extension components / repeated build items is bounded on placement count, so an "instance
15
+ * bomb" (a tiny master referenced millions of times) is rejected cheaply from the structure — before any
16
+ * large geometry part is decompressed. Default 50,000.
17
+ */
18
+ maxInstances?: number;
12
19
  }
13
20
  export interface ResolvedLimits {
14
21
  maxTriangles: number;
15
22
  maxObjects: number;
16
23
  maxSourceBytes: number;
24
+ maxInstances: number;
17
25
  }
18
26
  export declare const DEFAULT_LIMITS: ResolvedLimits;
19
27
  export declare function resolveLimits(limits?: ModelLimits): ResolvedLimits;
@@ -1 +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"}
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;IACxB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,cAAc,EAAE,cAK5B,CAAC;AAEF,wBAAgB,aAAa,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,cAAc,CAOlE;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 CHANGED
@@ -1,13 +1,15 @@
1
1
  export const DEFAULT_LIMITS = {
2
2
  maxTriangles: 5_000_000,
3
3
  maxObjects: 10_000,
4
- maxSourceBytes: 256 * 1024 * 1024
4
+ maxSourceBytes: 256 * 1024 * 1024,
5
+ maxInstances: 50_000
5
6
  };
6
7
  export function resolveLimits(limits) {
7
8
  return {
8
9
  maxTriangles: limits?.maxTriangles ?? DEFAULT_LIMITS.maxTriangles,
9
10
  maxObjects: limits?.maxObjects ?? DEFAULT_LIMITS.maxObjects,
10
- maxSourceBytes: limits?.maxSourceBytes ?? DEFAULT_LIMITS.maxSourceBytes
11
+ maxSourceBytes: limits?.maxSourceBytes ?? DEFAULT_LIMITS.maxSourceBytes,
12
+ maxInstances: limits?.maxInstances ?? DEFAULT_LIMITS.maxInstances
11
13
  };
12
14
  }
13
15
  /** A bounded, structured model-parse failure (DD-018 §6). */
@@ -1 +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"}
1
+ {"version":3,"file":"model-content.d.ts","sourceRoot":"","sources":["../src/model-content.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAWL,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;IAoCjB,OAAO,CAAC,WAAW;IAMnB,kGAAkG;IAClG,OAAO,IAAI,IAAI;CAGhB;AAED,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,EAAE,GAAmB,CAAC"}
@@ -9,7 +9,7 @@
9
9
  * DD-021) build meshes and lighting through **one** path instead of parallel copies. The still keeps its
10
10
  * own GL + camera; the viewer drives the shared `InteractiveStage`. Both hand this class a `Scene`.
11
11
  */
12
- import { BufferAttribute, BufferGeometry, DirectionalLight, Group, HemisphereLight, Mesh, MeshStandardMaterial, Vector3 } from 'three';
12
+ import { BufferAttribute, BufferGeometry, DirectionalLight, Group, HemisphereLight, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial, Vector3 } from 'three';
13
13
  /** Neutral default surface when the source declares no material (never a fabricated source color). */
14
14
  const NEUTRAL_COLOR = [0.8, 0.8, 0.82];
15
15
  /**
@@ -71,10 +71,21 @@ export class ModelContent {
71
71
  const color = obj.material?.color ?? NEUTRAL_COLOR;
72
72
  mat.color.setRGB(color[0], color[1], color[2]);
73
73
  }
74
+ this.disposables.push(g, mat);
75
+ // A master reused across placements (DD-022) → one geometry upload, drawn once per instance matrix.
76
+ if (obj.instances !== undefined && obj.instances.length > 1) {
77
+ const inst = new InstancedMesh(g, mat, obj.instances.length);
78
+ const m = new Matrix4();
79
+ for (let i = 0; i < obj.instances.length; i++) {
80
+ inst.setMatrixAt(i, m.fromArray(obj.instances[i]));
81
+ }
82
+ inst.instanceMatrix.needsUpdate = true;
83
+ this.disposables.push(inst);
84
+ return inst;
85
+ }
74
86
  const mesh = new Mesh(g, mat);
75
87
  mesh.matrixAutoUpdate = false;
76
88
  mesh.matrix.fromArray(obj.transform);
77
- this.disposables.push(g, mat);
78
89
  return mesh;
79
90
  }
80
91
  clearMeshes() {
@@ -3,7 +3,7 @@ import type { Confidence } from '@chestnutlabs/toolpath-core';
3
3
  import { type ModelLoader, type ModelSourceInput } from './loaders.js';
4
4
  import { type ModelLimits } from './limits.js';
5
5
  import type { ModelBackground, PresentationView } from './model-renderer.js';
6
- import type { ModelBounds } from './scene-model.js';
6
+ import { type ModelBounds } from './scene-model.js';
7
7
  export interface ModelViewerOptions {
8
8
  /** Loader registry (open `kind`). Default: `[stlLoader, threeMfLoader]`. */
9
9
  loaders?: readonly ModelLoader[];
@@ -27,6 +27,17 @@ export interface ModelReadyInfo {
27
27
  objectCount: number;
28
28
  materials: Confidence;
29
29
  bounds: ModelBounds;
30
+ /**
31
+ * Total instance placements drawn (DD-022): > `objectCount` when the source reused geometry, for an "N
32
+ * copies" badge. 1 per object when nothing is reused.
33
+ */
34
+ instancedCount: number;
35
+ /**
36
+ * Every-Nth-triangle decimation applied to fit the LOD budget (1 = none). Field-parallel to the toolpath
37
+ * `decimationApplied` so a card badges "simplified for size" the same way. Always 1 until model LOD lands
38
+ * (DD-022 Phase 2); reserved so the field is stable.
39
+ */
40
+ decimationApplied: number;
30
41
  }
31
42
  export type ModelViewerEvent = {
32
43
  type: 'ready';
@@ -1 +1 @@
1
- {"version":3,"file":"model-viewer.d.ts","sourceRoot":"","sources":["../src/model-viewer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"model-viewer.d.ts","sourceRoot":"","sources":["../src/model-viewer.ts"],"names":[],"mappings":"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"}
@@ -17,6 +17,7 @@ import { InteractiveStage, createDefaultGLRenderer } from '@chestnutlabs/gcode-r
17
17
  import { ModelContent } from './model-content.js';
18
18
  import { DEFAULT_MODEL_LOADERS, resolveModelScene } from './loaders.js';
19
19
  import { ModelParseError } from './limits.js';
20
+ import { sceneInstanceCount } from './scene-model.js';
20
21
  function backgroundColor(bg) {
21
22
  return bg === 'transparent' ? null : new Color(bg);
22
23
  }
@@ -93,7 +94,9 @@ export function createModelViewer(canvas, options = {}) {
93
94
  const info = {
94
95
  objectCount: parsed.objects.length,
95
96
  materials: parsed.capabilities.materials,
96
- bounds: parsed.bounds
97
+ bounds: parsed.bounds,
98
+ instancedCount: sceneInstanceCount(parsed),
99
+ decimationApplied: 1
97
100
  };
98
101
  // Last-wins: a newer setSource (or a dispose) superseded this one — discard without mutating.
99
102
  if (my !== sourceToken || disposed || stage === null)
@@ -13,7 +13,7 @@
13
13
  import type { Confidence } from '@chestnutlabs/toolpath-core';
14
14
  import type { GLRendererLike, RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three';
15
15
  import { type ModelBackground, type PresentationView } from './model-renderer.js';
16
- import type { ModelScene } from './scene-model.js';
16
+ import { type ModelScene } from './scene-model.js';
17
17
  import type { ModelLimits } from './limits.js';
18
18
  /**
19
19
  * A source model: STL bytes, 3MF bytes, or a pre-built `ModelScene`. This concrete union is the
@@ -53,6 +53,17 @@ export interface RenderModelStillResult {
53
53
  objectCount: number;
54
54
  /** Whether source colors/materials were real — `'unavailable'` means the neutral default was used. */
55
55
  materials: Confidence;
56
+ /**
57
+ * Total instance placements drawn (DD-022): > `objectCount` when the source reused geometry (a
58
+ * full-sheet plate of repeated copies), for an "N copies" badge. 1 per object when nothing is reused.
59
+ */
60
+ instancedCount: number;
61
+ /**
62
+ * Every-Nth-triangle decimation applied to fit the LOD budget (1 = none, layer/geometry detail full).
63
+ * Field-parallel to the toolpath `RenderStillResult.decimationApplied` so a card badges "simplified for
64
+ * size" the same way. Always 1 until model LOD lands (DD-022 Phase 2); reserved so the field is stable.
65
+ */
66
+ decimationApplied: number;
56
67
  /** Stable identity for caching: `hash(source) + options + envId`. */
57
68
  cacheKey: string;
58
69
  }
@@ -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;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
+ {"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,EAAsB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEvE,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;;;OAGG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,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,CAmCjC"}
@@ -1,5 +1,6 @@
1
1
  import { ModelRenderer } from './model-renderer.js';
2
2
  import { isModelScene, resolveModelScene } from './loaders.js';
3
+ import { sceneInstanceCount } from './scene-model.js';
3
4
  import { computeCacheKey } from './cache-key.js';
4
5
  async function toScene(source, limits, filamentPalette) {
5
6
  if (isModelScene(source)) {
@@ -42,6 +43,8 @@ export async function renderModelStill(source, options) {
42
43
  height,
43
44
  objectCount: scene.objects.length,
44
45
  materials: scene.capabilities.materials,
46
+ instancedCount: sceneInstanceCount(scene),
47
+ decimationApplied: 1,
45
48
  cacheKey
46
49
  };
47
50
  }
@@ -45,9 +45,21 @@ export interface ModelObject {
45
45
  id: string;
46
46
  /** Source-declared object name when present. */
47
47
  name?: string;
48
+ /**
49
+ * Geometry in the object's own local space when {@link instances} is present (the placements position
50
+ * it); otherwise positioned by {@link transform}. A bare STL / single-placement object leaves it as the
51
+ * source geometry.
52
+ */
48
53
  geometry: MeshGeometry;
49
- /** Object→scene transform (identity for STL). */
54
+ /** Object→scene transform (identity for STL). For an instanced master, equals `instances[0]`. */
50
55
  transform: Mat4;
56
+ /**
57
+ * All scene-space placements of this master mesh — production-extension components / repeated build
58
+ * items that reuse the same geometry (DD-022). Present only when the source reused the mesh (length ≥ 2);
59
+ * absent ⇒ a single placement at {@link transform}. A renderer draws one geometry upload across these
60
+ * transforms (GPU instancing), so memory scales with unique geometry, not copy count.
61
+ */
62
+ instances?: Mat4[];
51
63
  /** Omitted ⇒ no source material ⇒ neutral default render + `capabilities.materials: 'unavailable'`. */
52
64
  material?: ModelMaterial;
53
65
  }
@@ -63,8 +75,17 @@ export interface ModelScene {
63
75
  transforms: Confidence;
64
76
  /** `'known'` when the source declared more than one object. */
65
77
  multiObject: Confidence;
78
+ /** `'known'` when the source reused geometry via instances (more placements than unique masters), so
79
+ * the scene preserved it as GPU instancing (DD-022); `'unavailable'` otherwise. */
80
+ instanced: Confidence;
66
81
  };
67
82
  }
68
83
  /** The identity transform (column-major), for single-object sources. */
69
84
  export declare const IDENTITY_MAT4: Mat4;
85
+ /**
86
+ * Total placements drawn across a scene (DD-022): the sum of each object's instance count (a reused
87
+ * master counts its `instances`, a single-placement object counts 1). Reported on `ready` / the still
88
+ * result so a consumer can badge "N copies".
89
+ */
90
+ export declare function sceneInstanceCount(scene: ModelScene): number;
70
91
  //# sourceMappingURL=scene-model.d.ts.map
@@ -1 +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"}
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;;;;OAIG;IACH,QAAQ,EAAE,YAAY,CAAC;IACvB,iGAAiG;IACjG,SAAS,EAAE,IAAI,CAAC;IAChB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,IAAI,EAAE,CAAC;IACnB,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;QACxB;4FACoF;QACpF,SAAS,EAAE,UAAU,CAAC;KACvB,CAAC;CACH;AAED,wEAAwE;AACxE,eAAO,MAAM,aAAa,EAAE,IAAuD,CAAC;AAEpF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAI5D"}
@@ -1,2 +1,13 @@
1
1
  /** The identity transform (column-major), for single-object sources. */
2
2
  export const IDENTITY_MAT4 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
3
+ /**
4
+ * Total placements drawn across a scene (DD-022): the sum of each object's instance count (a reused
5
+ * master counts its `instances`, a single-placement object counts 1). Reported on `ready` / the still
6
+ * result so a consumer can badge "N copies".
7
+ */
8
+ export function sceneInstanceCount(scene) {
9
+ let n = 0;
10
+ for (const o of scene.objects)
11
+ n += o.instances?.length ?? 1;
12
+ return n;
13
+ }
package/dist/stl.d.ts.map CHANGED
@@ -1 +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"}
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,CAwD3F"}
package/dist/stl.js CHANGED
@@ -61,7 +61,12 @@ export function parseStl(source, limits) {
61
61
  return {
62
62
  objects: [{ id: 'stl', geometry, transform: IDENTITY_MAT4 }],
63
63
  bounds,
64
- capabilities: { materials: 'unavailable', transforms: 'unavailable', multiObject: 'unavailable' }
64
+ capabilities: {
65
+ materials: 'unavailable',
66
+ transforms: 'unavailable',
67
+ multiObject: 'unavailable',
68
+ instanced: 'unavailable'
69
+ }
65
70
  };
66
71
  }
67
72
  function boundsOf(positions) {
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"AAqBA,OAAO,EAML,KAAK,UAAU,EAEhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAkC,KAAK,WAAW,EAAuB,MAAM,aAAa,CAAC;AAkbpG,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;CACnD;AA4GD,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,UAAU,GAAG,WAAW,EAChC,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,UAAU,CAAC,CAyKrB"}
package/dist/three-mf.js CHANGED
@@ -139,12 +139,64 @@ function applyTransform(v, x, y, z) {
139
139
  v[2] * x + v[5] * y + v[8] * z + v[11]
140
140
  ];
141
141
  }
142
- /** Apply a chain of transforms in order (innermost first): comp → … → build item. */
143
- function applyChain(chain, x, y, z) {
144
- let p = [x, y, z];
145
- for (const t of chain)
146
- p = applyTransform(t, p[0], p[1], p[2]);
147
- return p;
142
+ /** The identity 3MF row-vector affine (12 values). */
143
+ const IDENTITY_AFFINE = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
144
+ /**
145
+ * Compose two 3MF affines so the result applies `inner` first, then `outer`:
146
+ * `compose(outer, inner)(p) = outer(inner(p))`. Used to fold a component transform into the running
147
+ * placement matrix while descending the assembly tree, without baking vertices (DD-022 instancing).
148
+ */
149
+ function composeAffine(outer, inner) {
150
+ const lin = (x, y, z) => [
151
+ outer[0] * x + outer[3] * y + outer[6] * z,
152
+ outer[1] * x + outer[4] * y + outer[7] * z,
153
+ outer[2] * x + outer[5] * y + outer[8] * z
154
+ ];
155
+ const c0 = lin(inner[0], inner[1], inner[2]);
156
+ const c1 = lin(inner[3], inner[4], inner[5]);
157
+ const c2 = lin(inner[6], inner[7], inner[8]);
158
+ const t = lin(inner[9], inner[10], inner[11]);
159
+ return [
160
+ c0[0],
161
+ c0[1],
162
+ c0[2],
163
+ c1[0],
164
+ c1[1],
165
+ c1[2],
166
+ c2[0],
167
+ c2[1],
168
+ c2[2],
169
+ t[0] + outer[9],
170
+ t[1] + outer[10],
171
+ t[2] + outer[11]
172
+ ];
173
+ }
174
+ /** A 3MF row-vector affine (12 values), or null = identity, → a column-major {@link Mat4} (three layout). */
175
+ function affineToMat4(v) {
176
+ if (v === null)
177
+ return IDENTITY_MAT4;
178
+ return [v[0], v[1], v[2], 0, v[3], v[4], v[5], 0, v[6], v[7], v[8], 0, v[9], v[10], v[11], 1];
179
+ }
180
+ /** Expand `st.min`/`st.max` by a local AABB [min,max] transformed by `affine` (its 8 corners). */
181
+ function expandBoundsByBox(st, bmin, bmax, affine) {
182
+ for (let c = 0; c < 8; c++) {
183
+ const x = (c & 1) === 0 ? bmin[0] : bmax[0];
184
+ const y = (c & 2) === 0 ? bmin[1] : bmax[1];
185
+ const z = (c & 4) === 0 ? bmin[2] : bmax[2];
186
+ const [wx, wy, wz] = applyTransform(affine, x, y, z);
187
+ if (wx < st.min[0])
188
+ st.min[0] = wx;
189
+ if (wy < st.min[1])
190
+ st.min[1] = wy;
191
+ if (wz < st.min[2])
192
+ st.min[2] = wz;
193
+ if (wx > st.max[0])
194
+ st.max[0] = wx;
195
+ if (wy > st.max[1])
196
+ st.max[1] = wy;
197
+ if (wz > st.max[2])
198
+ st.max[2] = wz;
199
+ }
148
200
  }
149
201
  /** Parse ONE model part's XML into objects/palettes/items (no cross-part resolution). */
150
202
  function parseModelPart(xml, lim) {
@@ -263,8 +315,22 @@ function parseModelPart(xml, lim) {
263
315
  }
264
316
  return { objects, palettes, unitScale, items };
265
317
  }
266
- /** Bake one mesh object (its own triangles) into a `ModelObject`, applying the transform chain. */
267
- function bakeMesh(obj, part, chain, id, st, lim) {
318
+ /**
319
+ * Place one mesh object (DD-022 instancing). The FIRST time a master `${partKey}#${objectid}` is reached
320
+ * its geometry is built once in LOCAL space (unit-scaled, un-baked); every reach — first and repeats —
321
+ * records the placement `matrix` as an instance and expands the scene bounds by the master's local AABB
322
+ * transformed by that matrix. So memory + the triangle budget scale with unique geometry, not copy count.
323
+ */
324
+ function placeMaster(obj, part, matrix, key, st, lim) {
325
+ const affine = matrix ?? IDENTITY_AFFINE;
326
+ const mat4 = affineToMat4(matrix);
327
+ const existing = st.masters.get(key);
328
+ if (existing !== undefined) {
329
+ existing.instances.push(mat4);
330
+ st.totalInstances++;
331
+ expandBoundsByBox(st, existing.localMin, existing.localMax, affine);
332
+ return;
333
+ }
268
334
  const triCount = obj.tris.length / 3;
269
335
  st.totalTris += triCount;
270
336
  if (st.totalTris > lim.maxTriangles) {
@@ -273,6 +339,8 @@ function bakeMesh(obj, part, chain, id, st, lim) {
273
339
  const positions = new Float32Array(triCount * 9);
274
340
  let colors = null;
275
341
  let objHasColor = false;
342
+ const localMin = [Infinity, Infinity, Infinity];
343
+ const localMax = [-Infinity, -Infinity, -Infinity];
276
344
  // A mesh painted with Bambu/Orca `paint_color` (only meaningful when we have a filament palette).
277
345
  const objHasPaint = st.paintPalette.length > 0 && obj.triPaint.some((p) => p !== null);
278
346
  for (let ti = 0; ti < triCount; ti++) {
@@ -298,23 +366,26 @@ function bakeMesh(obj, part, chain, id, st, lim) {
298
366
  const cidx = obj.triColorIdx[ti];
299
367
  for (let k = 0; k < 3; k++) {
300
368
  const vi = obj.tris[ti * 3 + k] * 3;
301
- const [x, y, z] = applyChain(chain, obj.vertices[vi] * part.unitScale, obj.vertices[vi + 1] * part.unitScale, obj.vertices[vi + 2] * part.unitScale);
369
+ // LOCAL geometry: unit scale only — the placement matrix (an instance transform) does the rest.
370
+ const x = obj.vertices[vi] * part.unitScale;
371
+ const y = obj.vertices[vi + 1] * part.unitScale;
372
+ const z = obj.vertices[vi + 2] * part.unitScale;
302
373
  const o = (ti * 3 + k) * 3;
303
374
  positions[o] = x;
304
375
  positions[o + 1] = y;
305
376
  positions[o + 2] = z;
306
- if (x < st.min[0])
307
- st.min[0] = x;
308
- if (y < st.min[1])
309
- st.min[1] = y;
310
- if (z < st.min[2])
311
- st.min[2] = z;
312
- if (x > st.max[0])
313
- st.max[0] = x;
314
- if (y > st.max[1])
315
- st.max[1] = y;
316
- if (z > st.max[2])
317
- st.max[2] = z;
377
+ if (x < localMin[0])
378
+ localMin[0] = x;
379
+ if (y < localMin[1])
380
+ localMin[1] = y;
381
+ if (z < localMin[2])
382
+ localMin[2] = z;
383
+ if (x > localMax[0])
384
+ localMax[0] = x;
385
+ if (y > localMax[1])
386
+ localMax[1] = y;
387
+ if (z > localMax[2])
388
+ localMax[2] = z;
318
389
  const col = paintCol ?? (palette !== undefined && cidx !== null ? (palette[cidx[k]] ?? palette[0]) : undefined);
319
390
  if (col !== undefined) {
320
391
  if (colors === null)
@@ -329,12 +400,101 @@ function bakeMesh(obj, part, chain, id, st, lim) {
329
400
  if (objHasColor)
330
401
  st.anyColor = true;
331
402
  const geometry = colors !== null ? { positions, colors } : { positions };
332
- const built = { id, geometry, transform: IDENTITY_MAT4 };
403
+ const object = { id: key, geometry, transform: mat4 };
333
404
  if (obj.name !== undefined)
334
- built.name = obj.name;
335
- st.built.push(built);
405
+ object.name = obj.name;
406
+ st.masters.set(key, { object, instances: [mat4], localMin, localMax });
407
+ st.built.push(object);
408
+ st.totalInstances++;
409
+ expandBoundsByBox(st, localMin, localMax, affine);
336
410
  }
337
411
  const hexPaletteToLinear = (hexes) => hexes.map((hex) => (hex !== undefined ? (srgbHexToLinear(hex) ?? [0.8, 0.8, 0.82]) : [0.8, 0.8, 0.82]));
412
+ /**
413
+ * Conservative UPPER bound on the XML bytes a single 3MF triangle (plus its amortized share of a shared
414
+ * vertex) occupies — used to turn an external part's uncompressed byte size into a LOWER bound on its
415
+ * triangle count (DD-022 §5/§13). Deliberately generous so the estimate never *over*-counts triangles and
416
+ * so never false-rejects a file that would actually fit; a file only fast-rejects when even this
417
+ * under-count exceeds the budget. Borderline files fall through to the exact parse.
418
+ */
419
+ const EST_MAX_BYTES_PER_TRIANGLE = 256;
420
+ /**
421
+ * Safety margin on the byte estimate (DD-022 §13): only *fast*-reject when the estimate is comfortably
422
+ * over the triangle budget, so a `.model` part padded with non-triangle bytes (comments/metadata) is not
423
+ * false-rejected. A file estimated between 1× and this multiple of the budget falls through to the exact
424
+ * per-triangle parse, which enforces the real limit. Genuinely oversize plates (many × a large master)
425
+ * are far past this and still reject in sub-second time.
426
+ */
427
+ const EST_REJECT_MARGIN = 2;
428
+ /**
429
+ * Fast structural cost estimate (DD-022 Phase 0), run after the (tiny, for production-extension files)
430
+ * main part is parsed but BEFORE any large external geometry part is decompressed. Walks the build-item /
431
+ * component tree counting **placements** (all of them) and a lower-bound **unique**-triangle estimate:
432
+ * - a mesh master's triangles are counted **once per unique master** (already parsed), NOT per placement;
433
+ * - an external `p:path` part is estimated from its ZIP-directory uncompressed size / the conservative
434
+ * bytes-per-triangle bound, counted **once per unique path** (a safe under-count), treated as a leaf.
435
+ * Because instancing renders each master once (DD-022 Phase 1), the triangle estimate must be UNIQUE — a
436
+ * full-sheet plate of ~40 copies costs its ~1 master, not ~40× baked, so it is NOT rejected here. The
437
+ * placement count still guards an instance bomb (`maxInstances`). Throws {@link ModelParseError} for a
438
+ * clear instance-bomb / oversize file so it rejects in sub-second time instead of ~10 s of decompress-and-
439
+ * bake; files near the budget fall through to the exact per-triangle parse.
440
+ */
441
+ function estimateSceneCost(mainPart, entryByName, lim) {
442
+ let instanceCount = 0;
443
+ let estTriangles = 0;
444
+ // Unique masters already counted toward the triangle estimate (keyed by objectid / external path), so a
445
+ // reused master is counted once — matching the instanced render, not the old per-placement bake.
446
+ const countedMasters = new Set();
447
+ const checkInstances = () => {
448
+ if (instanceCount > lim.maxInstances) {
449
+ throw new ModelParseError('E_MODEL_TOO_MANY_INSTANCES', `3MF declares more than ${lim.maxInstances} instance placements`);
450
+ }
451
+ };
452
+ const visit = (part, objectid, depth) => {
453
+ if (depth > MAX_DEPTH)
454
+ return;
455
+ const obj = part.objects.get(objectid);
456
+ if (obj === undefined)
457
+ return;
458
+ if (obj.tris.length > 0) {
459
+ instanceCount++;
460
+ const key = `local:${objectid}`;
461
+ if (!countedMasters.has(key)) {
462
+ countedMasters.add(key);
463
+ estTriangles += obj.tris.length / 3;
464
+ }
465
+ checkInstances();
466
+ }
467
+ for (const comp of obj.components) {
468
+ if (comp.path !== undefined) {
469
+ // External production-extension part: estimate from its directory size WITHOUT decompressing it.
470
+ const norm = comp.path.replace(/^\//, '').toLowerCase();
471
+ const entry = entryByName(norm);
472
+ if (entry !== undefined) {
473
+ instanceCount++;
474
+ if (!countedMasters.has(norm)) {
475
+ countedMasters.add(norm);
476
+ estTriangles += entry.uncompressedSize / EST_MAX_BYTES_PER_TRIANGLE;
477
+ }
478
+ checkInstances();
479
+ }
480
+ }
481
+ else {
482
+ visit(part, comp.objectid, depth + 1); // local sub-object (already-parsed geometry)
483
+ }
484
+ }
485
+ };
486
+ const roots = mainPart.items.length > 0
487
+ ? mainPart.items
488
+ : [...mainPart.objects.keys()].map((objectid) => ({ objectid, transform: null }));
489
+ for (const inst of roots)
490
+ visit(mainPart, inst.objectid, 0);
491
+ if (instanceCount > lim.maxInstances) {
492
+ throw new ModelParseError('E_MODEL_TOO_MANY_INSTANCES', `3MF declares more than ${lim.maxInstances} instance placements`);
493
+ }
494
+ if (estTriangles > lim.maxTriangles * EST_REJECT_MARGIN) {
495
+ throw new ModelParseError('E_MODEL_TOO_MANY_TRIANGLES', `3MF's estimated ${Math.round(estTriangles)} triangles exceed the limit ${lim.maxTriangles}`);
496
+ }
497
+ }
338
498
  export async function parse3mf(source, limits, opts) {
339
499
  const lim = resolveLimits(limits);
340
500
  const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
@@ -393,6 +553,10 @@ export async function parse3mf(source, limits, opts) {
393
553
  throw e;
394
554
  throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
395
555
  }
556
+ // Fast structural cost estimate (DD-022 Phase 0): reject a clear instance-bomb / oversize plate now,
557
+ // from the main part's structure + the ZIP directory, BEFORE decompressing any large external geometry
558
+ // part in `resolve` — sub-second instead of ~10 s of decompress-and-bake.
559
+ estimateSceneCost(mainPart, entryByName, lim);
396
560
  // Source-model filament palette for Bambu/Orca `paint_color`: it lives in `project_settings.config`
397
561
  // (`filament_colour`), NOT the model XML, so the render is self-contained and needs no slicer output.
398
562
  let paintPalette = [];
@@ -432,6 +596,7 @@ export async function parse3mf(source, limits, opts) {
432
596
  }
433
597
  const st = {
434
598
  built: [],
599
+ masters: new Map(),
435
600
  anyColor: false,
436
601
  anyTransform: false,
437
602
  anyApprox: false,
@@ -439,41 +604,50 @@ export async function parse3mf(source, limits, opts) {
439
604
  defaultExtruderIdx,
440
605
  min: [Infinity, Infinity, Infinity],
441
606
  max: [-Infinity, -Infinity, -Infinity],
442
- totalTris: 0
607
+ totalTris: 0,
608
+ totalInstances: 0
443
609
  };
444
- // Resolve an object (mesh and/or components) within `part`, applying the transform chain.
445
- const resolve = async (part, objectid, chain, id, depth) => {
610
+ // Resolve an object (mesh and/or components) within `part`, folding component transforms into the
611
+ // running placement `matrix` (DD-022) instead of baking vertices. `partKey` identifies the part so the
612
+ // same `${partKey}#${objectid}` master, reached via different placements, is instanced not copied.
613
+ const resolve = async (part, objectid, matrix, partKey, depth) => {
446
614
  if (depth > MAX_DEPTH)
447
615
  return;
448
616
  const obj = part.objects.get(objectid);
449
617
  if (obj === undefined)
450
618
  return;
451
619
  if (obj.tris.length > 0)
452
- bakeMesh(obj, part, chain, id, st, lim);
453
- for (let ci = 0; ci < obj.components.length; ci++) {
454
- const comp = obj.components[ci];
620
+ placeMaster(obj, part, matrix, `${partKey}#${objectid}`, st, lim);
621
+ for (const comp of obj.components) {
455
622
  const targetPart = comp.path !== undefined ? await getPartByPath(comp.path) : part;
456
623
  if (targetPart === null)
457
624
  continue;
458
- // Apply the component transform FIRST (child space → this object), then the existing chain.
459
- const childChain = comp.transform !== null ? [comp.transform, ...chain] : chain;
625
+ // Fold the component transform (child → this object) into the placement, then descend.
626
+ const childMatrix = comp.transform !== null ? composeAffine(matrix ?? IDENTITY_AFFINE, comp.transform) : matrix;
460
627
  if (comp.transform !== null)
461
628
  st.anyTransform = true;
462
- await resolve(targetPart, comp.objectid, childChain, `${id}.${ci}`, depth + 1);
629
+ const childKey = comp.path !== undefined ? comp.path.replace(/^\//, '').toLowerCase() : partKey;
630
+ await resolve(targetPart, comp.objectid, childMatrix, childKey, depth + 1);
463
631
  }
464
632
  };
465
633
  // With no <build>, resolve every declared object at identity (some minimal 3MFs omit build).
466
- const instances = mainPart.items.length > 0
634
+ const mainKey = mainEntry.name.toLowerCase();
635
+ const roots = mainPart.items.length > 0
467
636
  ? mainPart.items
468
637
  : [...mainPart.objects.keys()].map((objectid) => ({ objectid, transform: null }));
469
- for (const inst of instances) {
470
- const chain = inst.transform !== null ? [inst.transform] : [];
638
+ for (const inst of roots) {
471
639
  if (inst.transform !== null)
472
640
  st.anyTransform = true;
473
- await resolve(mainPart, inst.objectid, chain, inst.objectid, 0);
641
+ await resolve(mainPart, inst.objectid, inst.transform, mainKey, 0);
474
642
  }
475
643
  if (st.built.length === 0)
476
644
  throw new ModelParseError('E_MODEL_EMPTY', '3MF contains no renderable geometry');
645
+ // Attach the instance list to any master reached more than once (a single placement keeps its
646
+ // `transform` and omits `instances`, per the contract).
647
+ for (const rec of st.masters.values()) {
648
+ if (rec.instances.length > 1)
649
+ rec.object.instances = rec.instances;
650
+ }
477
651
  const bounds = { min: st.min, max: st.max };
478
652
  return {
479
653
  objects: st.built,
@@ -482,7 +656,9 @@ export async function parse3mf(source, limits, opts) {
482
656
  // `approximated` when any colour came from flattening a subdivided `paint_color` facet.
483
657
  materials: st.anyApprox ? 'approximated' : st.anyColor ? 'known' : 'unavailable',
484
658
  transforms: st.anyTransform ? 'known' : 'unavailable',
485
- multiObject: st.built.length > 1 ? 'known' : 'unavailable'
659
+ multiObject: st.built.length > 1 ? 'known' : 'unavailable',
660
+ // Instancing preserved when there are more placements than unique masters.
661
+ instanced: st.totalInstances > st.masters.size ? 'known' : 'unavailable'
486
662
  }
487
663
  };
488
664
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chestnutlabs/gcode-model-renderer",
3
- "version": "0.10.0",
3
+ "version": "0.12.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.10.0",
55
- "@chestnutlabs/gcode-renderer-three": "0.10.0",
56
- "@chestnutlabs/toolpath-core": "0.10.0"
54
+ "@chestnutlabs/gcode-containers": "0.12.0",
55
+ "@chestnutlabs/gcode-renderer-three": "0.12.0",
56
+ "@chestnutlabs/toolpath-core": "0.12.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/three": "0.178.0"