@chestnutlabs/gcode-model-renderer 0.6.0 → 0.7.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.
@@ -1,5 +1,4 @@
1
1
  import { type ModelScene } from './scene-model.js';
2
2
  import { type ModelLimits } from './limits.js';
3
- /** Parse 3MF bytes into a {@link ModelScene}. Rejects with {@link ModelParseError} on failure. */
4
3
  export declare function parse3mf(source: Uint8Array | ArrayBuffer, limits?: ModelLimits): Promise<ModelScene>;
5
4
  //# sourceMappingURL=three-mf.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"AAoBA,OAAO,EAKL,KAAK,UAAU,EAEhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAkC,KAAK,WAAW,EAAuB,MAAM,aAAa,CAAC;AA8PpG,wBAAsB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,WAAW,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAkH1G"}
package/dist/three-mf.js CHANGED
@@ -1,14 +1,16 @@
1
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.
2
+ * 3MF → {@link ModelScene} (DD-018 Phase 2, + production-extension follow-up). Multi-object,
3
+ * per-object/per-triangle **solid** colors, build-item transforms, and the **3MF Production Extension**
4
+ * (Bambu/MakerWorld default): `<components>` whose `<component p:path=…>` reference external
5
+ * `/3D/Objects/*.model` parts. A multicolor model becomes a multicolor thumbnail without slicing.
4
6
  *
5
7
  * 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:
8
+ * `@chestnutlabs/gcode-containers` (DD-005 §7 — zip-bomb / traversal / size caps reused verbatim). Each
9
+ * model part is a small XML document parsed with a minimal, worker-safe scan (no `DOMParser`, which Web
10
+ * Workers lack; no dependency). Only the subset needed for a presentation render is read:
9
11
  * `<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
+ * `<components><component>` (with `p:path`), and `<build><item>` transforms. Textures and non-color
13
+ * material properties are ignored (disclosed via capability honesty), never fetched (no network).
12
14
  */
13
15
  import { readDirectory, extractEntry, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
14
16
  import { IDENTITY_MAT4 } from './scene-model.js';
@@ -22,6 +24,8 @@ const UNIT_TO_MM = {
22
24
  inch: 25.4,
23
25
  foot: 304.8
24
26
  };
27
+ /** Max component-resolution depth (guards cyclic / pathological assemblies). */
28
+ const MAX_DEPTH = 50;
25
29
  const TAG_RE = /<(\/?)([\w:.-]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
26
30
  const ATTR_RE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
27
31
  function localName(tag) {
@@ -52,48 +56,31 @@ function srgbHexToLinear(hex) {
52
56
  const b = to(4);
53
57
  return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : [r, g, b];
54
58
  }
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);
59
+ function parseTransform(s) {
60
+ if (s === undefined)
61
+ return null;
62
+ const v = s.trim().split(/\s+/).map(Number);
63
+ return v.length === 12 && v.every((n) => !Number.isNaN(n)) ? v : null;
75
64
  }
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.
65
+ /** Apply a 3MF row-vector affine (12 values) to a point. */
66
+ function applyTransform(v, x, y, z) {
67
+ return [
68
+ v[0] * x + v[3] * y + v[6] * z + v[9],
69
+ v[1] * x + v[4] * y + v[7] * z + v[10],
70
+ v[2] * x + v[5] * y + v[8] * z + v[11]
71
+ ];
72
+ }
73
+ /** Apply a chain of transforms in order (innermost first): comp → … → build item. */
74
+ function applyChain(chain, x, y, z) {
75
+ let p = [x, y, z];
76
+ for (const t of chain)
77
+ p = applyTransform(t, p[0], p[1], p[2]);
78
+ return p;
79
+ }
80
+ /** Parse ONE model part's XML into objects/palettes/items (no cross-part resolution). */
81
+ function parseModelPart(xml, lim) {
95
82
  let unitScale = 1;
96
- const palettes = new Map(); // resource id → colors
83
+ const palettes = new Map();
97
84
  const objects = new Map();
98
85
  const items = [];
99
86
  let curObj = null;
@@ -138,7 +125,7 @@ export async function parse3mf(source, limits) {
138
125
  else {
139
126
  const a = attrs(raw);
140
127
  curObjId = a.id ?? null;
141
- curObj = { vertices: [], tris: [], triColorIdx: [], triPid: [], name: a.name };
128
+ curObj = { vertices: [], tris: [], triColorIdx: [], triPid: [], components: [], name: a.name };
142
129
  if (a.pid !== undefined)
143
130
  curObj.objPid = a.pid;
144
131
  if (a.pindex !== undefined)
@@ -149,6 +136,18 @@ export async function parse3mf(source, limits) {
149
136
  }
150
137
  }
151
138
  }
139
+ else if (name === 'component' && curObj !== null) {
140
+ // Production extension: a sub-object, possibly in an external part via `p:path` (any prefix).
141
+ const a = attrs(raw);
142
+ const path = a['p:path'] ?? a.path;
143
+ if (a.objectid !== undefined) {
144
+ curObj.components.push({
145
+ ...(path !== undefined ? { path } : {}),
146
+ objectid: a.objectid,
147
+ transform: parseTransform(a.transform)
148
+ });
149
+ }
150
+ }
152
151
  else if (name === 'vertex' && curObj !== null) {
153
152
  const a = attrs(raw);
154
153
  curObj.vertices.push(Number(a.x) || 0, Number(a.y) || 0, Number(a.z) || 0);
@@ -161,7 +160,6 @@ export async function parse3mf(source, limits) {
161
160
  }
162
161
  const pid = a.pid ?? curObj.objPid ?? null;
163
162
  curObj.triPid.push(pid);
164
- // Per-vertex color indices p1/p2/p3 (fall back to p1 for all, then object pindex).
165
163
  if (a.p1 !== undefined) {
166
164
  const p1 = Number(a.p1);
167
165
  const p2 = a.p2 !== undefined ? Number(a.p2) : p1;
@@ -182,107 +180,172 @@ export async function parse3mf(source, limits) {
182
180
  else if (name === 'item' && inBuild && !closing) {
183
181
  const a = attrs(raw);
184
182
  if (a.objectid !== undefined)
185
- items.push({ objectid: a.objectid, transform: a.transform });
183
+ items.push({ objectid: a.objectid, transform: parseTransform(a.transform) });
186
184
  }
187
185
  }
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;
186
+ return { objects, palettes, unitScale, items };
195
187
  }
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
- }
188
+ /** Bake one mesh object (its own triangles) into a `ModelObject`, applying the transform chain. */
189
+ function bakeMesh(obj, part, chain, id, st, lim) {
190
+ const triCount = obj.tris.length / 3;
191
+ st.totalTris += triCount;
192
+ if (st.totalTris > lim.maxTriangles) {
193
+ throw new ModelParseError('E_MODEL_TOO_MANY_TRIANGLES', `3MF exceeds triangle limit ${lim.maxTriangles}`);
194
+ }
195
+ const positions = new Float32Array(triCount * 9);
196
+ let colors = null;
197
+ let objHasColor = false;
198
+ for (let ti = 0; ti < triCount; ti++) {
199
+ const pid = obj.triPid[ti];
200
+ const palette = pid !== null ? part.palettes.get(pid) : undefined;
201
+ const cidx = obj.triColorIdx[ti];
202
+ for (let k = 0; k < 3; k++) {
203
+ const vi = obj.tris[ti * 3 + k] * 3;
204
+ const [x, y, z] = applyChain(chain, obj.vertices[vi] * part.unitScale, obj.vertices[vi + 1] * part.unitScale, obj.vertices[vi + 2] * part.unitScale);
205
+ const o = (ti * 3 + k) * 3;
206
+ positions[o] = x;
207
+ positions[o + 1] = y;
208
+ positions[o + 2] = z;
209
+ if (x < st.min[0])
210
+ st.min[0] = x;
211
+ if (y < st.min[1])
212
+ st.min[1] = y;
213
+ if (z < st.min[2])
214
+ st.min[2] = z;
215
+ if (x > st.max[0])
216
+ st.max[0] = x;
217
+ if (y > st.max[1])
218
+ st.max[1] = y;
219
+ if (z > st.max[2])
220
+ st.max[2] = z;
221
+ if (palette !== undefined && cidx !== null) {
222
+ const col = palette[cidx[k]] ?? palette[0];
223
+ if (col !== undefined) {
224
+ if (colors === null)
225
+ colors = new Float32Array(triCount * 9);
226
+ colors[o] = col[0];
227
+ colors[o + 1] = col[1];
228
+ colors[o + 2] = col[2];
229
+ objHasColor = true;
265
230
  }
266
231
  }
267
232
  }
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
233
  }
276
- if (built.length === 0)
234
+ if (objHasColor)
235
+ st.anyColor = true;
236
+ const geometry = colors !== null ? { positions, colors } : { positions };
237
+ const built = { id, geometry, transform: IDENTITY_MAT4 };
238
+ if (obj.name !== undefined)
239
+ built.name = obj.name;
240
+ st.built.push(built);
241
+ }
242
+ export async function parse3mf(source, limits) {
243
+ const lim = resolveLimits(limits);
244
+ const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
245
+ if (bytes.byteLength === 0)
246
+ throw new ModelParseError('E_MODEL_EMPTY', '3MF source is empty');
247
+ if (bytes.byteLength > lim.maxSourceBytes) {
248
+ throw new ModelParseError('E_MODEL_TOO_LARGE', `3MF source ${bytes.byteLength} bytes exceeds limit ${lim.maxSourceBytes}`);
249
+ }
250
+ const climits = DEFAULT_CONTAINER_LIMITS;
251
+ let dir;
252
+ try {
253
+ dir = readDirectory(bytes, climits);
254
+ }
255
+ catch (e) {
256
+ throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
257
+ }
258
+ const entryByName = (nameLower) => dir.entries.find((e) => e.name.toLowerCase() === nameLower);
259
+ // Locate the primary model part: conventional path, then OPC StartPart, then any *.model.
260
+ let mainEntry = entryByName('3d/3dmodel.model');
261
+ if (mainEntry === undefined) {
262
+ const rels = entryByName('_rels/.rels');
263
+ if (rels !== undefined) {
264
+ const relXml = new TextDecoder().decode(await extractEntry(bytes, rels, climits.maxMetadataBytes));
265
+ const target = /Target="\/?([^"]+\.model)"/i.exec(relXml)?.[1];
266
+ if (target !== undefined)
267
+ mainEntry = entryByName(target.toLowerCase());
268
+ }
269
+ }
270
+ if (mainEntry === undefined)
271
+ mainEntry = dir.entries.find((e) => e.name.toLowerCase().endsWith('.model'));
272
+ if (mainEntry === undefined)
273
+ throw new ModelParseError('E_MODEL_PARSE', '3MF contains no 3D model part');
274
+ // Lazily parse model parts by absolute OPC path (production-extension components reference them).
275
+ const partCache = new Map();
276
+ const parseEntry = async (entry) => {
277
+ const key = entry.name.toLowerCase();
278
+ const cached = partCache.get(key);
279
+ if (cached !== undefined)
280
+ return cached;
281
+ const xml = new TextDecoder().decode(await extractEntry(bytes, entry, climits.maxExpandedBytesPerEntry));
282
+ const part = parseModelPart(xml, lim);
283
+ partCache.set(key, part);
284
+ return part;
285
+ };
286
+ const getPartByPath = async (path) => {
287
+ const norm = path.replace(/^\//, '').toLowerCase();
288
+ const entry = entryByName(norm);
289
+ return entry === undefined ? null : parseEntry(entry);
290
+ };
291
+ let mainPart;
292
+ try {
293
+ mainPart = await parseEntry(mainEntry);
294
+ }
295
+ catch (e) {
296
+ if (e instanceof ModelParseError)
297
+ throw e;
298
+ throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
299
+ }
300
+ const st = {
301
+ built: [],
302
+ anyColor: false,
303
+ anyTransform: false,
304
+ min: [Infinity, Infinity, Infinity],
305
+ max: [-Infinity, -Infinity, -Infinity],
306
+ totalTris: 0
307
+ };
308
+ // Resolve an object (mesh and/or components) within `part`, applying the transform chain.
309
+ const resolve = async (part, objectid, chain, id, depth) => {
310
+ if (depth > MAX_DEPTH)
311
+ return;
312
+ const obj = part.objects.get(objectid);
313
+ if (obj === undefined)
314
+ return;
315
+ if (obj.tris.length > 0)
316
+ bakeMesh(obj, part, chain, id, st, lim);
317
+ for (let ci = 0; ci < obj.components.length; ci++) {
318
+ const comp = obj.components[ci];
319
+ const targetPart = comp.path !== undefined ? await getPartByPath(comp.path) : part;
320
+ if (targetPart === null)
321
+ continue;
322
+ // Apply the component transform FIRST (child space → this object), then the existing chain.
323
+ const childChain = comp.transform !== null ? [comp.transform, ...chain] : chain;
324
+ if (comp.transform !== null)
325
+ st.anyTransform = true;
326
+ await resolve(targetPart, comp.objectid, childChain, `${id}.${ci}`, depth + 1);
327
+ }
328
+ };
329
+ // With no <build>, resolve every declared object at identity (some minimal 3MFs omit build).
330
+ const instances = mainPart.items.length > 0
331
+ ? mainPart.items
332
+ : [...mainPart.objects.keys()].map((objectid) => ({ objectid, transform: null }));
333
+ for (const inst of instances) {
334
+ const chain = inst.transform !== null ? [inst.transform] : [];
335
+ if (inst.transform !== null)
336
+ st.anyTransform = true;
337
+ await resolve(mainPart, inst.objectid, chain, inst.objectid, 0);
338
+ }
339
+ if (st.built.length === 0)
277
340
  throw new ModelParseError('E_MODEL_EMPTY', '3MF contains no renderable geometry');
278
- const bounds = { min, max };
341
+ const bounds = { min: st.min, max: st.max };
279
342
  return {
280
- objects: built,
343
+ objects: st.built,
281
344
  bounds,
282
345
  capabilities: {
283
- materials: anyColor ? 'known' : 'unavailable',
284
- transforms: anyTransform ? 'known' : 'unavailable',
285
- multiObject: built.length > 1 ? 'known' : 'unavailable'
346
+ materials: st.anyColor ? 'known' : 'unavailable',
347
+ transforms: st.anyTransform ? 'known' : 'unavailable',
348
+ multiObject: st.built.length > 1 ? 'known' : 'unavailable'
286
349
  }
287
350
  };
288
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chestnutlabs/gcode-model-renderer",
3
- "version": "0.6.0",
3
+ "version": "0.7.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.6.0",
55
- "@chestnutlabs/gcode-renderer-three": "0.6.0",
56
- "@chestnutlabs/toolpath-core": "0.6.0"
54
+ "@chestnutlabs/gcode-containers": "0.7.0",
55
+ "@chestnutlabs/gcode-renderer-three": "0.7.0",
56
+ "@chestnutlabs/toolpath-core": "0.7.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/three": "0.178.0"