dsh-cad 0.16.2 → 0.17.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,215 +1,318 @@
1
- /**
2
- * The per-instance pastel palette: instance i takes palette[i % n] both in the
3
- * composed 3D scene and in the assembly-tree swatches, so a part's color chip
4
- * always matches its solid. Stable per list position (the worker's insertion
5
- * order), like the reference CAD assembly panels.
6
- */
7
- export const INSTANCE_PALETTE = [
8
- 0xd98e8e, // rose
9
- 0x9fd4a8, // green
10
- 0xc3a6e8, // violet
11
- 0xe8d58c, // sand
12
- 0x8fd0c9, // teal
13
- 0xf0b7c5, // pink
14
- 0xa8c68f, // olive
15
- 0x9fb8e8, // periwinkle
16
- 0xe8a87c, // orange
17
- 0x7fc8bc, // jade
18
- 0xc9a2d8, // lilac
19
- 0x8fb3d9, // steel blue
20
- ];
21
- /** The palette color of the instance at `index` in the assembly list. */
22
- export function instanceColor(index) {
23
- return INSTANCE_PALETTE[index % INSTANCE_PALETTE.length];
24
- }
25
- /**
26
- * Instance display names with the ·N dedup suffix — the single source for
27
- * mesh names in the composed scene AND part names in the assembly tree, so a
28
- * tree row always names its mesh (highlight linking relies on it).
29
- */
30
- export function assemblyDisplayNames(instances) {
31
- const nameUse = new Map();
32
- return instances.map((instance) => {
33
- const seen = nameUse.get(instance.name) ?? 0;
34
- nameUse.set(instance.name, seen + 1);
35
- return seen === 0 ? instance.name : `${instance.name}·${seen + 1}`;
36
- });
37
- }
38
- function rotationMatrix(rotate) {
39
- const toRad = (deg) => (deg * Math.PI) / 180;
40
- const [rx, ry, rz] = rotate.map(toRad);
41
- const cx = Math.cos(rx), sx = Math.sin(rx);
42
- const cy = Math.cos(ry), sy = Math.sin(ry);
43
- const cz = Math.cos(rz), sz = Math.sin(rz);
44
- // Row-major 3×3: Rx·Ry·Rz
45
- return new Float64Array([
46
- cy * cz, -cy * sz, sy,
47
- cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy,
48
- sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy,
49
- ]);
50
- }
51
- function transformMesh(mesh, translate, rotate) {
52
- const isIdentity = translate.every((v) => v === 0) && rotate.every((v) => v === 0);
53
- if (isIdentity)
54
- return mesh;
55
- const m = rotationMatrix(rotate);
56
- const [tx, ty, tz] = translate;
57
- const positions = new Float32Array(mesh.positions.length);
58
- for (let i = 0; i + 2 < mesh.positions.length; i += 3) {
59
- const x = mesh.positions[i];
60
- const y = mesh.positions[i + 1];
61
- const z = mesh.positions[i + 2];
62
- positions[i] = m[0] * x + m[1] * y + m[2] * z + tx;
63
- positions[i + 1] = m[3] * x + m[4] * y + m[5] * z + ty;
64
- positions[i + 2] = m[6] * x + m[7] * y + m[8] * z + tz;
65
- }
66
- // Normals rotate with the same linear part (translation never applies).
67
- let normals;
68
- if (mesh.normals !== undefined) {
69
- normals = new Float32Array(mesh.normals.length);
70
- for (let i = 0; i + 2 < mesh.normals.length; i += 3) {
71
- const x = mesh.normals[i];
72
- const y = mesh.normals[i + 1];
73
- const z = mesh.normals[i + 2];
74
- normals[i] = m[0] * x + m[1] * y + m[2] * z;
75
- normals[i + 1] = m[3] * x + m[4] * y + m[5] * z;
76
- normals[i + 2] = m[6] * x + m[7] * y + m[8] * z;
77
- }
78
- }
79
- return { ...mesh, positions, normals };
80
- }
81
- /**
82
- * Compose the assembly scene: one transformed mesh copy per instance, colored
83
- * by list position (see INSTANCE_PALETTE). Instances referencing bodies
84
- * missing from `bodies` (consumed by a later boolean) are skipped — the
85
- * worker's live list already filters them, this is the replay-side double
86
- * guard. Duplicate names get a ·N suffix (assemblyDisplayNames).
87
- */
88
- export function composeAssemblyMeshes(bodies, instances) {
89
- const meshes = [];
90
- const names = assemblyDisplayNames(instances);
91
- for (let index = 0; index < instances.length; index++) {
92
- const instance = instances[index];
93
- const body = bodies.get(instance.bodyId);
94
- if (body === undefined)
95
- continue;
96
- meshes.push(transformMesh({ ...body, name: names[index], color: instanceColor(index) }, instance.translate, instance.rotate));
97
- }
98
- return meshes;
99
- }
100
- /**
101
- * Rebuild the assembly state purely from the persisted op log — the worker
102
- * keeps the same data live (Map insertion order = push order here), so the
103
- * tree endpoint serves restart-safe data without a worker round-trip.
104
- */
105
- export function foldAssemblyState(ops, bodyNames) {
106
- const instances = [];
107
- let entities = [];
108
- let constraints = [];
109
- const liveBodyIds = new Set();
110
- for (const op of ops) {
111
- switch (op.kind) {
112
- case 'create_prim':
113
- case 'extrude_profile':
114
- case 'loft':
115
- case 'revolve':
116
- case 'sweep':
117
- liveBodyIds.add(op.bodyId);
118
- break;
119
- case 'pattern': {
120
- // Mirror the worker's unique-copy rule so replay-derived ids match
121
- // (repeat patterns of the same target uniquify against live ids).
122
- const count = Math.max(1, Math.trunc(op.count));
123
- for (let i = 1; i < count; i++) {
124
- let seq = i;
125
- let id = `${op.target}p${seq}`;
126
- while (liveBodyIds.has(id))
127
- id = `${op.target}p${++seq}`;
128
- liveBodyIds.add(id);
129
- }
130
- break;
131
- }
132
- case 'delete':
133
- liveBodyIds.delete(op.target);
134
- break;
135
- case 'boolean':
136
- for (const tool of op.tools)
137
- liveBodyIds.delete(tool);
138
- break;
139
- case 'assembly_insert':
140
- instances.push({
141
- instanceId: op.instanceId,
142
- bodyId: op.bodyId,
143
- name: op.name ?? bodyNames[op.bodyId] ?? op.bodyId,
144
- translate: op.translate ?? [0, 0, 0],
145
- rotate: op.rotate ?? [0, 0, 0],
146
- });
147
- break;
148
- case 'assembly_transform': {
149
- const instance = instances.find((entry) => entry.instanceId === op.instanceId);
150
- if (instance !== undefined) {
151
- if (op.translate !== undefined)
152
- instance.translate = op.translate;
153
- if (op.rotate !== undefined)
154
- instance.rotate = op.rotate;
155
- }
156
- break;
157
- }
158
- case 'assembly_remove': {
159
- const index = instances.findIndex((entry) => entry.instanceId === op.instanceId);
160
- if (index !== -1)
161
- instances.splice(index, 1);
162
- break;
163
- }
164
- case 'constraints':
165
- // The last declaration wins (a clear records an empty model).
166
- entities = op.model.entities;
167
- constraints = op.model.constraints;
168
- break;
169
- default:
170
- break;
171
- }
172
- }
173
- return { instances, entities, constraints, liveBodyIds };
174
- }
175
- /** Build the assembly-tree payload from a restored document. */
176
- export function assemblyTreePayload(doc) {
177
- const { instances, entities, constraints, liveBodyIds } = foldAssemblyState(doc.ops, doc.bodyNames);
178
- const names = assemblyDisplayNames(instances);
179
- const nameByInstanceId = new Map(instances.map((instance, index) => [instance.instanceId, names[index]]));
180
- const entityName = (id) => {
181
- if (typeof id !== 'number')
182
- return undefined;
183
- const entity = entities.find((entry) => entry.id === id);
184
- if (entity?.instance !== undefined)
185
- return nameByInstanceId.get(entity.instance) ?? entity.instance;
186
- return `#${id}`;
187
- };
188
- return {
189
- docId: doc.docId,
190
- version: doc.version,
191
- parts: instances.map((instance, index) => ({
192
- instanceId: instance.instanceId,
193
- bodyId: instance.bodyId,
194
- name: names[index],
195
- color: instanceColor(index),
196
- // Missing only when BOTH witnesses lack the body: liveBodyIds (folded
197
- // from the op log — covers pattern copies in documents written before
198
- // copy names were recorded, issue #6) and bodyNames (the recorded name
199
- // manifest).
200
- missing: !liveBodyIds.has(instance.bodyId) && doc.bodyNames[instance.bodyId] === undefined,
201
- })),
202
- constraints: constraints.map((constraint) => {
203
- const entry = { id: constraint.id, type: String(constraint.kind.type ?? 'constraint') };
204
- if (constraint.label !== undefined)
205
- entry.label = constraint.label;
206
- const a = entityName(constraint.kind.a);
207
- const b = entityName(constraint.kind.b);
208
- if (a !== undefined)
209
- entry.a = a;
210
- if (b !== undefined)
211
- entry.b = b;
212
- return entry;
213
- }),
214
- };
215
- }
1
+ import { applyConnectValue, mergeJointValue, normalizeJointSpec, jointValueText } from './joints.js';
2
+ /**
3
+ * The per-instance pastel palette: instance i takes palette[i % n] both in the
4
+ * composed 3D scene and in the assembly-tree swatches, so a part's color chip
5
+ * always matches its solid. Stable per list position (the worker's insertion
6
+ * order), like the reference CAD assembly panels.
7
+ */
8
+ export const INSTANCE_PALETTE = [
9
+ 0xd98e8e, // rose
10
+ 0x9fd4a8, // green
11
+ 0xc3a6e8, // violet
12
+ 0xe8d58c, // sand
13
+ 0x8fd0c9, // teal
14
+ 0xf0b7c5, // pink
15
+ 0xa8c68f, // olive
16
+ 0x9fb8e8, // periwinkle
17
+ 0xe8a87c, // orange
18
+ 0x7fc8bc, // jade
19
+ 0xc9a2d8, // lilac
20
+ 0x8fb3d9, // steel blue
21
+ ];
22
+ /** The palette color of the instance at `index` in the assembly list. */
23
+ export function instanceColor(index) {
24
+ return INSTANCE_PALETTE[index % INSTANCE_PALETTE.length];
25
+ }
26
+ /**
27
+ * Instance display names with the ·N dedup suffix — the single source for
28
+ * mesh names in the composed scene AND part names in the assembly tree, so a
29
+ * tree row always names its mesh (highlight linking relies on it).
30
+ */
31
+ export function assemblyDisplayNames(instances) {
32
+ const nameUse = new Map();
33
+ return instances.map((instance) => {
34
+ const seen = nameUse.get(instance.name) ?? 0;
35
+ nameUse.set(instance.name, seen + 1);
36
+ return seen === 0 ? instance.name : `${instance.name}·${seen + 1}`;
37
+ });
38
+ }
39
+ function rotationMatrix(rotate) {
40
+ const toRad = (deg) => (deg * Math.PI) / 180;
41
+ const [rx, ry, rz] = rotate.map(toRad);
42
+ const cx = Math.cos(rx), sx = Math.sin(rx);
43
+ const cy = Math.cos(ry), sy = Math.sin(ry);
44
+ const cz = Math.cos(rz), sz = Math.sin(rz);
45
+ // Row-major 3×3: Rx·Ry·Rz
46
+ return new Float64Array([
47
+ cy * cz, -cy * sz, sy,
48
+ cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy,
49
+ sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy,
50
+ ]);
51
+ }
52
+ function transformMesh(mesh, translate, rotate) {
53
+ const isIdentity = translate.every((v) => v === 0) && rotate.every((v) => v === 0);
54
+ if (isIdentity)
55
+ return mesh;
56
+ const m = rotationMatrix(rotate);
57
+ const [tx, ty, tz] = translate;
58
+ const positions = new Float32Array(mesh.positions.length);
59
+ for (let i = 0; i + 2 < mesh.positions.length; i += 3) {
60
+ const x = mesh.positions[i];
61
+ const y = mesh.positions[i + 1];
62
+ const z = mesh.positions[i + 2];
63
+ positions[i] = m[0] * x + m[1] * y + m[2] * z + tx;
64
+ positions[i + 1] = m[3] * x + m[4] * y + m[5] * z + ty;
65
+ positions[i + 2] = m[6] * x + m[7] * y + m[8] * z + tz;
66
+ }
67
+ // Normals rotate with the same linear part (translation never applies).
68
+ let normals;
69
+ if (mesh.normals !== undefined) {
70
+ normals = new Float32Array(mesh.normals.length);
71
+ for (let i = 0; i + 2 < mesh.normals.length; i += 3) {
72
+ const x = mesh.normals[i];
73
+ const y = mesh.normals[i + 1];
74
+ const z = mesh.normals[i + 2];
75
+ normals[i] = m[0] * x + m[1] * y + m[2] * z;
76
+ normals[i + 1] = m[3] * x + m[4] * y + m[5] * z;
77
+ normals[i + 2] = m[6] * x + m[7] * y + m[8] * z;
78
+ }
79
+ }
80
+ return { ...mesh, positions, normals };
81
+ }
82
+ /**
83
+ * Compose the assembly scene: one transformed mesh copy per instance, colored
84
+ * by list position (see INSTANCE_PALETTE). Instances referencing bodies
85
+ * missing from `bodies` (consumed by a later boolean) are skipped — the
86
+ * worker's live list already filters them, this is the replay-side double
87
+ * guard. Duplicate names get a ·N suffix (assemblyDisplayNames). `explode`
88
+ * (from explodeOffsets) shifts each instance radially before composing —
89
+ * the exploded view, view-only math over the same placement data.
90
+ */
91
+ export function composeAssemblyMeshes(bodies, instances, explode) {
92
+ const meshes = [];
93
+ const names = assemblyDisplayNames(instances);
94
+ for (let index = 0; index < instances.length; index++) {
95
+ const instance = instances[index];
96
+ const body = bodies.get(instance.bodyId);
97
+ if (body === undefined)
98
+ continue;
99
+ const offset = explode?.get(instance.instanceId);
100
+ const translate = offset === undefined
101
+ ? instance.translate
102
+ : [instance.translate[0] + offset[0], instance.translate[1] + offset[1], instance.translate[2] + offset[2]];
103
+ meshes.push(transformMesh({ ...body, name: names[index], color: instanceColor(index) }, translate, instance.rotate));
104
+ }
105
+ return meshes;
106
+ }
107
+ /**
108
+ * Rebuild the assembly state purely from the persisted op log — the worker
109
+ * keeps the same data live (Map insertion order = push order here), so the
110
+ * tree endpoint serves restart-safe data without a worker round-trip.
111
+ *
112
+ * Joint ops are metadata: the placements they solve are written back through
113
+ * `assembly_transform` ops by the tool layer, so this fold carries specs and
114
+ * connections but computes no algebra — replay restores identical placements.
115
+ */
116
+ export function foldAssemblyState(ops, bodyNames) {
117
+ const instances = [];
118
+ let entities = [];
119
+ let constraints = [];
120
+ const liveBodyIds = new Set();
121
+ const joints = new Map();
122
+ const connections = [];
123
+ let explode = 0;
124
+ /** Defensive normalize — a corrupted joint op must not break the endpoint. */
125
+ const safeSpec = (raw) => {
126
+ try {
127
+ return normalizeJointSpec(raw, 'joint');
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ };
133
+ for (const op of ops) {
134
+ switch (op.kind) {
135
+ case 'create_prim':
136
+ case 'extrude_profile':
137
+ case 'loft':
138
+ case 'revolve':
139
+ case 'sweep':
140
+ liveBodyIds.add(op.bodyId);
141
+ break;
142
+ case 'pattern': {
143
+ // Mirror the worker's unique-copy rule so replay-derived ids match
144
+ // (repeat patterns of the same target uniquify against live ids).
145
+ const count = Math.max(1, Math.trunc(op.count));
146
+ for (let i = 1; i < count; i++) {
147
+ let seq = i;
148
+ let id = `${op.target}p${seq}`;
149
+ while (liveBodyIds.has(id))
150
+ id = `${op.target}p${++seq}`;
151
+ liveBodyIds.add(id);
152
+ }
153
+ break;
154
+ }
155
+ case 'delete':
156
+ liveBodyIds.delete(op.target);
157
+ break;
158
+ case 'boolean':
159
+ for (const tool of op.tools)
160
+ liveBodyIds.delete(tool);
161
+ break;
162
+ case 'assembly_insert':
163
+ instances.push({
164
+ instanceId: op.instanceId,
165
+ bodyId: op.bodyId,
166
+ name: op.name ?? bodyNames[op.bodyId] ?? op.bodyId,
167
+ translate: op.translate ?? [0, 0, 0],
168
+ rotate: op.rotate ?? [0, 0, 0],
169
+ });
170
+ break;
171
+ case 'assembly_transform': {
172
+ const instance = instances.find((entry) => entry.instanceId === op.instanceId);
173
+ if (instance !== undefined) {
174
+ if (op.translate !== undefined)
175
+ instance.translate = op.translate;
176
+ if (op.rotate !== undefined)
177
+ instance.rotate = op.rotate;
178
+ }
179
+ break;
180
+ }
181
+ case 'assembly_remove': {
182
+ const index = instances.findIndex((entry) => entry.instanceId === op.instanceId);
183
+ if (index !== -1)
184
+ instances.splice(index, 1);
185
+ joints.delete(op.instanceId);
186
+ // Connections referencing the removed instance (either side) go with it.
187
+ for (let i = connections.length - 1; i >= 0; i--) {
188
+ if (connections[i].child === op.instanceId || connections[i].parent === op.instanceId)
189
+ connections.splice(i, 1);
190
+ }
191
+ break;
192
+ }
193
+ case 'assembly_joint': {
194
+ const spec = safeSpec(op.joint);
195
+ if (spec !== null) {
196
+ const own = joints.get(op.instanceId) ?? {};
197
+ own[op.label] = spec;
198
+ joints.set(op.instanceId, own);
199
+ }
200
+ break;
201
+ }
202
+ case 'assembly_connect': {
203
+ connections.push({ child: op.instanceId, parent: op.parent, parentLabel: op.parentLabel, childLabel: op.childLabel });
204
+ if (op.value !== undefined) {
205
+ const parentSpec = joints.get(op.parent)?.[op.parentLabel];
206
+ const childSpec = joints.get(op.instanceId)?.[op.childLabel];
207
+ if (parentSpec !== undefined && childSpec !== undefined) {
208
+ try {
209
+ const applied = applyConnectValue(parentSpec, childSpec, op.value, `connect ${op.parentLabel}`);
210
+ joints.get(op.parent)[op.parentLabel] = applied.parent;
211
+ const childOwn = joints.get(op.instanceId);
212
+ if (childOwn !== undefined)
213
+ childOwn[op.childLabel] = applied.child;
214
+ }
215
+ catch { /* invalid connect value — the connection still records */ }
216
+ }
217
+ }
218
+ break;
219
+ }
220
+ case 'assembly_joint_value': {
221
+ const own = joints.get(op.instanceId);
222
+ const spec = own?.[op.label];
223
+ if (own !== undefined && spec !== undefined) {
224
+ try {
225
+ own[op.label] = mergeJointValue(spec, op.value, `joint ${op.label}`);
226
+ }
227
+ catch { /* invalid drive value — keep the previous spec */ }
228
+ }
229
+ break;
230
+ }
231
+ case 'assembly_explode': {
232
+ explode = typeof op.factor === 'number' && Number.isFinite(op.factor) ? op.factor : 0;
233
+ break;
234
+ }
235
+ case 'constraints':
236
+ // The last declaration wins (a clear records an empty model).
237
+ entities = op.model.entities;
238
+ constraints = op.model.constraints;
239
+ break;
240
+ default:
241
+ break;
242
+ }
243
+ }
244
+ return { instances, entities, constraints, liveBodyIds, joints, connections, explode };
245
+ }
246
+ /** Build the assembly-tree payload from a restored document. */
247
+ export function assemblyTreePayload(doc) {
248
+ const { instances, entities, constraints, liveBodyIds, joints, connections, explode } = foldAssemblyState(doc.ops, doc.bodyNames);
249
+ const names = assemblyDisplayNames(instances);
250
+ const nameByInstanceId = new Map(instances.map((instance, index) => [instance.instanceId, names[index]]));
251
+ const entityName = (id) => {
252
+ if (typeof id !== 'number')
253
+ return undefined;
254
+ const entity = entities.find((entry) => entry.id === id);
255
+ if (entity?.instance !== undefined)
256
+ return nameByInstanceId.get(entity.instance) ?? entity.instance;
257
+ return `#${id}`;
258
+ };
259
+ // One link string per joint side (a pairing renders on both its joints).
260
+ const linkOf = new Map();
261
+ for (const connection of connections) {
262
+ const parentName = nameByInstanceId.get(connection.parent) ?? connection.parent;
263
+ const childName = nameByInstanceId.get(connection.child) ?? connection.child;
264
+ const text = `${connection.parentLabel} ↔ ${connection.childLabel}`;
265
+ linkOf.set(`${connection.parent}\u0000${connection.parentLabel}`, `${parentName} ${text}`);
266
+ linkOf.set(`${connection.child}\u0000${connection.childLabel}`, `${text} ${childName}`);
267
+ }
268
+ const jointRows = [];
269
+ for (const instance of instances) {
270
+ const own = joints.get(instance.instanceId);
271
+ if (own === undefined)
272
+ continue;
273
+ for (const [label, spec] of Object.entries(own)) {
274
+ const row = {
275
+ instanceId: instance.instanceId,
276
+ label,
277
+ type: spec.type,
278
+ owner: nameByInstanceId.get(instance.instanceId) ?? instance.instanceId,
279
+ };
280
+ const value = jointValueText(spec);
281
+ if (value !== '')
282
+ row.value = value;
283
+ const link = linkOf.get(`${instance.instanceId}\u0000${label}`);
284
+ if (link !== undefined)
285
+ row.link = link;
286
+ jointRows.push(row);
287
+ }
288
+ }
289
+ return {
290
+ docId: doc.docId,
291
+ version: doc.version,
292
+ parts: instances.map((instance, index) => ({
293
+ instanceId: instance.instanceId,
294
+ bodyId: instance.bodyId,
295
+ name: names[index],
296
+ color: instanceColor(index),
297
+ // Missing only when BOTH witnesses lack the body: liveBodyIds (folded
298
+ // from the op log — covers pattern copies in documents written before
299
+ // copy names were recorded, issue #6) and bodyNames (the recorded name
300
+ // manifest).
301
+ missing: !liveBodyIds.has(instance.bodyId) && doc.bodyNames[instance.bodyId] === undefined,
302
+ })),
303
+ constraints: constraints.map((constraint) => {
304
+ const entry = { id: constraint.id, type: String(constraint.kind.type ?? 'constraint') };
305
+ if (constraint.label !== undefined)
306
+ entry.label = constraint.label;
307
+ const a = entityName(constraint.kind.a);
308
+ const b = entityName(constraint.kind.b);
309
+ if (a !== undefined)
310
+ entry.a = a;
311
+ if (b !== undefined)
312
+ entry.b = b;
313
+ return entry;
314
+ }),
315
+ joints: jointRows,
316
+ ...(Math.abs(explode) > 1e-12 ? { explode } : {}),
317
+ };
318
+ }
@@ -651,6 +651,79 @@ function createCadorangeAdapter(mods) {
651
651
  return C.exportStepAssembly([{ name: rootName, color: null, transform: null, children }])
652
652
  }
653
653
 
654
+ // ── structured STEP import (cadorange 0.10.0, occt.ts 2.0.0) ──────────────
655
+
656
+ /** Euler degrees out of a row-major 3×3 Rx·Ry·Rz rotation (gimbal ±90° ry). */
657
+ function matrixEuler(r) {
658
+ const sy = Math.min(1, Math.max(-1, r[2] || 0))
659
+ const ry = Math.asin(sy) * RAD2DEG
660
+ if (Math.abs(Math.abs(sy) - 1) < 1e-9) {
661
+ return [((sy > 0 ? 1 : -1) * Math.atan2(r[3] || 0, r[4] || 0)) * RAD2DEG, ry, 0]
662
+ }
663
+ return [
664
+ Math.atan2(-(r[5] || 0), r[8] || 0) * RAD2DEG,
665
+ ry,
666
+ Math.atan2(-(r[1] || 0), r[0] || 0) * RAD2DEG,
667
+ ]
668
+ }
669
+
670
+ /**
671
+ * Parse a STEP assembly into unique parts + placed leaves. Goes through
672
+ * `Assembly.fromStep` — occt.ts 2.0.0 returns world-baked leaf solids with
673
+ * parent-relative node transforms, and fromStep un-bakes them back to local
674
+ * geometry + world placements (its round-trip contract). Parts dedupe by
675
+ * rounded (volume, area, bbox) of the LOCAL solid — instanced products in
676
+ * the file map to one body referenced by N instances.
677
+ */
678
+ function importStepDocument(bytes) {
679
+ const doc = C.importStepAssembly(bytes)
680
+ const asm = C.Assembly.fromStep(doc)
681
+ const parts = []
682
+ const keyIndex = new Map()
683
+ const leaves = []
684
+ const round = (value) => Math.round(value * 1000) / 1000
685
+ for (const leaf of asm.leafNodes()) {
686
+ const solid = leaf.solid
687
+ if (solid === undefined) continue
688
+ const meta = solid.meta()
689
+ const bb = meta.bbox
690
+ const key = [
691
+ round(meta.volume), round(meta.area),
692
+ ...bb.min.map(round), ...bb.max.map(round),
693
+ ].join('|')
694
+ let index = keyIndex.get(key)
695
+ if (index === undefined) {
696
+ index = parts.length
697
+ keyIndex.set(key, index)
698
+ parts.push({ name: leaf.name ?? `part${index + 1}`, shape: solid, key })
699
+ }
700
+ // worldLoc: columns (xDir, zDir×xDir, zDir) + origin → placement matrix.
701
+ const world = leaf.worldLoc()
702
+ const x = world.xDir
703
+ const z = world.zDir
704
+ const y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]]
705
+ leaves.push({
706
+ part: index,
707
+ name: leaf.name ?? parts[index].name,
708
+ translate: [...world.origin],
709
+ rotate: matrixEuler([x[0], y[0], z[0], x[1], y[1], z[1], x[2], y[2], z[2]]),
710
+ })
711
+ }
712
+ if (parts.length === 0) throw new Error('the STEP file carries no solid geometry (empty or non-solid document)')
713
+ return { parts, leaves }
714
+ }
715
+
716
+ /** A session-owned copy of a shape (import materialization: every body
717
+ * gets its own kernel object so disposing one never dangles another). */
718
+ function copyShape(shape) {
719
+ const raw = occ.toRaw(asOcc(shape))
720
+ try {
721
+ return CO.fromRaw(raw)
722
+ } finally {
723
+ if (typeof raw.delete === 'function') raw.delete()
724
+ }
725
+ }
726
+
654
727
  // ── engineering-drawing HLR (same kernel session, no STEP round-trip) ──────
655
728
 
656
729
  /**
@@ -720,6 +793,8 @@ function createCadorangeAdapter(mods) {
720
793
  faceNormals,
721
794
  exportFile,
722
795
  exportStepDocument,
796
+ importStepDocument,
797
+ copyShape,
723
798
  hiddenLineViewsOf,
724
799
  sketchWirePoints,
725
800
  sketchFaceMesh,