partforge 0.146.0 → 0.146.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.146.0",
3
+ "version": "0.146.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,87 +1,175 @@
1
1
  // Minimal 3MF reader, the read twin of threemf.js's writer. 3MF is an OPC
2
- // package (a zip) holding an XML model; unzip (fflate), find the model part,
3
- // extract vertices/triangles/per-item transforms and the model unit, and
4
- // merge every build item into one soup-free indexed mesh in millimetres.
2
+ // package (a zip) holding one or more XML model parts; unzip (fflate), find the
3
+ // ROOT model part, extract vertices/triangles, resolve <components> and per-item
4
+ // transforms, and merge every build item into one soup-free indexed mesh in
5
+ // millimetres.
6
+ //
7
+ // Slicer project files (Bambu Studio, Orca, PrusaSlicer) keep no mesh in the
8
+ // root part at all: its objects are <components> pointing, through the
9
+ // Production extension's `p:path`, at meshes in other parts
10
+ // (3D/Objects/object_1.model). Reading only one part — or only top-level
11
+ // <object><mesh> — finds nothing in exactly the files people export most.
5
12
  //
6
13
  // Regex-based extraction, NOT a DOM parse — workers have no DOMParser and the
7
14
  // worker graph must stay DOM-free (test/worker-layering.test.js enforces
8
15
  // this transitively). Scope: geometry only — materials, colors and beam
9
- // lattices are ignored, and only top-level <object><mesh> content is read
10
- // (no <components> nesting).
16
+ // lattices are ignored.
11
17
  import { unzipSync } from "fflate";
12
18
 
13
19
  const UNIT_MM = { micron: 0.001, millimeter: 1, centimeter: 10, inch: 25.4, foot: 304.8, meter: 1000 };
20
+ const MAX_COMPONENT_DEPTH = 32;
21
+ const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
14
22
 
15
- export function parse3MF(bytes) {
16
- const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
17
- let files;
18
- try {
19
- files = unzipSync(u8);
20
- } catch (e) {
21
- throw new Error(`3mf import: not a readable zip archive (${e?.message || e})`);
23
+ // Part names are compared without a leading slash and case-insensitively: OPC
24
+ // names are case-insensitive, and references carry a leading "/" that zip
25
+ // entry names do not.
26
+ const partKey = (name) => name.replace(/^\/+/, "").toLowerCase();
27
+
28
+ // Transforms are 12 numbers, row-major 4x3 with the translation in the last
29
+ // row, per the 3MF core spec (applied to a row vector: v' = v*M, i.e.
30
+ // x' = x*m00 + y*m10 + z*m20 + m30). The translation is in the units of the
31
+ // part that holds the transform, so it is scaled to mm on read; the linear
32
+ // part is unitless.
33
+ function readTransform(tag, scale) {
34
+ const raw = tag.match(/\btransform="([^"]+)"/)?.[1];
35
+ if (!raw) return IDENTITY;
36
+ const t = raw.trim().split(/\s+/).map(Number);
37
+ if (t.length !== 12 || t.some((n) => !Number.isFinite(n))) return IDENTITY;
38
+ return [...t.slice(0, 9), t[9] * scale, t[10] * scale, t[11] * scale];
39
+ }
40
+
41
+ // Child first, then parent: v * C * P.
42
+ function compose(c, p) {
43
+ const r = new Array(12);
44
+ for (let row = 0; row < 4; row++) {
45
+ for (let col = 0; col < 3; col++) {
46
+ r[row * 3 + col] =
47
+ c[row * 3] * p[col] + c[row * 3 + 1] * p[3 + col] + c[row * 3 + 2] * p[6 + col] + (row === 3 ? p[9 + col] : 0);
48
+ }
22
49
  }
23
- const modelPath = Object.keys(files).find((f) => f.toLowerCase().endsWith(".model"));
24
- if (!modelPath) throw new Error("3mf import: archive has no 3D model part (*.model)");
25
- const xml = new TextDecoder().decode(files[modelPath]);
50
+ return r;
51
+ }
52
+
53
+ // The OPC/Production-extension `path` attribute, whatever its namespace prefix.
54
+ const readPath = (tag) => tag.match(/\s(?:[\w.-]+:)?path="([^"]+)"/)?.[1] ?? null;
55
+
56
+ function rootModelPath(files, keys) {
57
+ const rels = files.get("_rels/.rels");
58
+ if (rels) {
59
+ const xml = new TextDecoder().decode(rels);
60
+ for (const m of xml.matchAll(/<Relationship\b[^>]*>/g)) {
61
+ const type = m[0].match(/\bType="([^"]+)"/)?.[1] ?? "";
62
+ const target = m[0].match(/\bTarget="([^"]+)"/)?.[1];
63
+ if (target && /\/3dmodel$/i.test(type) && files.has(partKey(target))) return partKey(target);
64
+ }
65
+ }
66
+ if (files.has("3d/3dmodel.model")) return "3d/3dmodel.model";
67
+ return keys.find((k) => k.endsWith(".model")) ?? null;
68
+ }
26
69
 
70
+ function parseModelPart(xml, key) {
27
71
  const unit = xml.match(/<model\b[^>]*\bunit="([^"]+)"/)?.[1] ?? "millimeter";
28
72
  const scale = UNIT_MM[unit];
29
73
  if (!scale) throw new Error(`3mf import: unknown unit "${unit}"`);
30
74
 
31
- // objects: id -> { P: number[] (already scaled to mm), I: number[] }
75
+ // objects: id -> { type, mesh: {P (mm), I} | null, components: [{key, id, t}] }
32
76
  const objects = new Map();
33
- const objRe = /<object\b[^>]*\bid="(\d+)"[^>]*>([\s\S]*?)<\/object>/g;
77
+ const objRe = /<object\b([^>]*)>([\s\S]*?)<\/object>/g;
34
78
  for (let m; (m = objRe.exec(xml)); ) {
35
- const [, id, body] = m;
79
+ const [, attrs, body] = m;
80
+ const id = attrs.match(/\bid="(\d+)"/)?.[1];
81
+ if (!id) continue;
82
+ const type = attrs.match(/\btype="([^"]+)"/)?.[1] ?? "model";
36
83
  const P = [], I = [];
37
84
  const vRe = /<vertex\b[^>]*\bx="([^"]+)"[^>]*\by="([^"]+)"[^>]*\bz="([^"]+)"/g;
38
85
  for (let v; (v = vRe.exec(body)); ) P.push(+v[1] * scale, +v[2] * scale, +v[3] * scale);
39
86
  const tRe = /<triangle\b[^>]*\bv1="(\d+)"[^>]*\bv2="(\d+)"[^>]*\bv3="(\d+)"/g;
40
87
  for (let t; (t = tRe.exec(body)); ) I.push(+t[1], +t[2], +t[3]);
41
- if (I.length) objects.set(id, { P, I });
88
+ const components = [];
89
+ for (const c of body.matchAll(/<component\b[^>]*>/g)) {
90
+ const cid = c[0].match(/\bobjectid="(\d+)"/)?.[1];
91
+ if (!cid) continue;
92
+ const path = readPath(c[0]);
93
+ components.push({ key: path ? partKey(path) : key, id: cid, t: readTransform(c[0], scale) });
94
+ }
95
+ objects.set(id, { type, mesh: I.length ? { P, I } : null, components });
42
96
  }
43
- if (objects.size === 0) throw new Error("3mf import: model contains no mesh geometry");
44
97
 
45
- // Build items: <item objectid="N" transform="m00 m01 m02 m10 m11 m12 m20 m21
46
- // m22 m30 m31 m32"/> — row-major 4x3, translation in the last row, per the
47
- // 3MF core spec (the transform is applied to a row vector: v' = v*M, i.e.
48
- // x' = x*m00 + y*m10 + z*m20 + m30, and so on). No <build>, or an object
49
- // with no matching item, falls back to identity placement.
50
- //
51
- // Attributes are pulled independently from each <item> tag (rather than in
52
- // one fixed-order regex) because `objectid` and `transform` can appear in
53
- // either order and a single ordered pattern with an optional middle group
54
- // can match the tag while silently leaving `transform` uncaptured.
98
+ // Build items. Attributes are pulled independently from each <item> tag
99
+ // (rather than in one fixed-order regex) because `objectid` and `transform`
100
+ // can appear in either order and a single ordered pattern with an optional
101
+ // middle group can match the tag while silently leaving `transform`
102
+ // uncaptured. Items may be self-closing or carry children (metadata).
55
103
  const items = [];
56
- const itemTagRe = /<item\b[^>]*\/>/g;
57
- for (let m; (m = itemTagRe.exec(xml)); ) {
58
- const tag = m[0];
59
- const id = tag.match(/\bobjectid="(\d+)"/)?.[1];
104
+ const build = xml.match(/<build\b[^>]*>([\s\S]*?)<\/build>/)?.[1] ?? "";
105
+ for (const m of build.matchAll(/<item\b[^>]*>/g)) {
106
+ const id = m[0].match(/\bobjectid="(\d+)"/)?.[1];
60
107
  if (!id) continue;
61
- const t = tag.match(/\btransform="([^"]+)"/)?.[1];
62
- items.push({ id, t: t ? t.trim().split(/\s+/).map(Number) : null });
108
+ const path = readPath(m[0]);
109
+ items.push({ key: path ? partKey(path) : key, id, t: readTransform(m[0], scale) });
110
+ }
111
+ return { objects, items };
112
+ }
113
+
114
+ export function parse3MF(bytes) {
115
+ const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
116
+ let raw;
117
+ try {
118
+ raw = unzipSync(u8);
119
+ } catch (e) {
120
+ throw new Error(`3mf import: not a readable zip archive (${e?.message || e})`, { cause: e });
63
121
  }
64
- const chosen = items.length ? items : [...objects.keys()].map((id) => ({ id, t: null }));
122
+ const files = new Map(Object.entries(raw).map(([name, data]) => [partKey(name), data]));
123
+ const root = rootModelPath(files, [...files.keys()]);
124
+ if (!root) throw new Error("3mf import: archive has no 3D model part (*.model)");
125
+
126
+ const parts = new Map();
127
+ const partFor = (key) => {
128
+ if (!parts.has(key)) {
129
+ const data = files.get(key);
130
+ if (!data) throw new Error(`3mf import: a component references a missing model part (/${key})`);
131
+ parts.set(key, parseModelPart(new TextDecoder().decode(data), key));
132
+ }
133
+ return parts.get(key);
134
+ };
65
135
 
66
136
  const V = [], Tr = [];
67
- for (const { id, t } of chosen) {
68
- const o = objects.get(id);
69
- if (!o) continue;
137
+ const emit = ({ P, I }, t) => {
70
138
  const base = V.length / 3;
71
- for (let i = 0; i < o.P.length; i += 3) {
72
- let x = o.P[i], y = o.P[i + 1], z = o.P[i + 2];
73
- if (t) {
74
- // Translation components (m30 m31 m32) are expressed in model units
75
- // per the 3MF spec, same as vertex coordinates — scale them to mm too
76
- // so they combine correctly with the already-scaled x/y/z above.
77
- const x2 = t[0] * x + t[3] * y + t[6] * z + t[9] * scale;
78
- const y2 = t[1] * x + t[4] * y + t[7] * z + t[10] * scale;
79
- const z2 = t[2] * x + t[5] * y + t[8] * z + t[11] * scale;
80
- x = x2; y = y2; z = z2;
81
- }
82
- V.push(x, y, z);
139
+ for (let i = 0; i < P.length; i += 3) {
140
+ const x = P[i], y = P[i + 1], z = P[i + 2];
141
+ V.push(
142
+ t[0] * x + t[3] * y + t[6] * z + t[9],
143
+ t[1] * x + t[4] * y + t[7] * z + t[10],
144
+ t[2] * x + t[5] * y + t[8] * z + t[11],
145
+ );
83
146
  }
84
- for (const idx of o.I) Tr.push(base + idx);
147
+ for (const idx of I) Tr.push(base + idx);
148
+ };
149
+ const place = (key, id, t, stack) => {
150
+ const ref = `${key}#${id}`;
151
+ if (stack.includes(ref) || stack.length > MAX_COMPONENT_DEPTH) {
152
+ throw new Error("3mf import: components reference each other in a loop");
153
+ }
154
+ const o = partFor(key).objects.get(id);
155
+ if (!o) return;
156
+ // Support and "other" objects are not part of the printed shape.
157
+ if (o.type === "support" || o.type === "other") return;
158
+ if (o.mesh) emit(o.mesh, t);
159
+ for (const c of o.components) place(c.key, c.id, compose(c.t, t), [...stack, ref]);
160
+ };
161
+
162
+ // No <build> items: every object in the root part that no component uses,
163
+ // at identity.
164
+ const rootPart = partFor(root);
165
+ let items = rootPart.items;
166
+ if (!items.length) {
167
+ const used = new Set();
168
+ for (const o of rootPart.objects.values()) for (const c of o.components) if (c.key === root) used.add(c.id);
169
+ items = [...rootPart.objects.keys()].filter((id) => !used.has(id)).map((id) => ({ key: root, id, t: IDENTITY }));
85
170
  }
171
+ for (const { key, id, t } of items) place(key, id, t, []);
172
+
173
+ if (Tr.length === 0) throw new Error("3mf import: model contains no mesh geometry");
86
174
  return { positions: Float32Array.from(V), indices: Uint32Array.from(Tr) };
87
175
  }