cyclecad 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1102 @@
1
+ /**
2
+ * Assembly Workspace Module — cycleCAD
3
+ *
4
+ * Manages assembly construction with components, mate constraints, joints, and motion.
5
+ * Phase 6 of the cycleCAD roadmap.
6
+ *
7
+ * Features:
8
+ * - Component management (add, remove, reposition)
9
+ * - Mate constraints (flush, mate, insert, angle, tangent)
10
+ * - Joint system (revolute, prismatic, cylindrical, ball, fixed, planar)
11
+ * - Iterative constraint solver with convergence checking
12
+ * - Joint animation with smooth keyframe interpolation
13
+ * - Explode/collapse assembly views
14
+ * - BOM generation and assembly statistics
15
+ * - Constraint and joint visualization markers
16
+ * - Assembly serialization (save/load)
17
+ * - Sub-assembly grouping
18
+ *
19
+ * @module assembly
20
+ */
21
+
22
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
23
+
24
+ /** @typedef {Object} Component - A part in the assembly */
25
+ /**
26
+ * @property {string} id - Unique component ID
27
+ * @property {string} name - Component name
28
+ * @property {THREE.Mesh} mesh - Three.js mesh object
29
+ * @property {THREE.Vector3} position - World position
30
+ * @property {THREE.Euler} rotation - World rotation
31
+ * @property {Array} constraints - Mate constraints involving this component
32
+ * @property {Array} joints - Joints involving this component
33
+ * @property {boolean} grounded - Is this component fixed (assembly origin)?
34
+ * @property {Object} originalTransform - Saved position/rotation for explode restore
35
+ * @property {string|null} parentSubAssembly - Sub-assembly ID if grouped
36
+ */
37
+
38
+ /** @typedef {Object} Mate - A mate constraint between two components */
39
+ /**
40
+ * @property {string} id - Unique mate ID
41
+ * @property {string} type - 'flush'|'mate'|'insert'|'angle'|'tangent'
42
+ * @property {string} comp1Id - First component ID
43
+ * @property {string} comp2Id - Second component ID
44
+ * @property {THREE.Vector3} face1Normal - Normal of first face
45
+ * @property {THREE.Vector3} face1Point - Point on first face
46
+ * @property {THREE.Vector3} face2Normal - Normal of second face
47
+ * @property {THREE.Vector3} face2Point - Point on second face
48
+ * @property {number} offset - Distance offset (for insert, mate)
49
+ * @property {number} angle - Angle for angle constraints
50
+ * @property {boolean} flip - Flip normal direction
51
+ * @property {number} tolerance - Convergence tolerance (mm)
52
+ */
53
+
54
+ /** @typedef {Object} Joint - A motion joint between two components */
55
+ /**
56
+ * @property {string} id - Unique joint ID
57
+ * @property {string} type - 'revolute'|'prismatic'|'cylindrical'|'ball'|'fixed'|'planar'
58
+ * @property {string} comp1Id - First component (origin)
59
+ * @property {string} comp2Id - Second component (moved)
60
+ * @property {THREE.Vector3} axis - Joint axis direction (normalized)
61
+ * @property {THREE.Vector3} point - Joint origin point (world)
62
+ * @property {Object} limits - { min, max } in radians (revolute) or mm (prismatic)
63
+ * @property {number} damping - Damping factor 0-1
64
+ * @property {number} currentValue - Current joint position (rad or mm)
65
+ * @property {Array} keyframes - Array of { value, transform } for saved positions
66
+ */
67
+
68
+ /**
69
+ * Assembly Workspace state and functions
70
+ * @namespace Assembly
71
+ */
72
+ const Assembly = {
73
+ // Assembly state
74
+ components: new Map(), // id → Component
75
+ mates: new Map(), // id → Mate
76
+ joints: new Map(), // id → Joint
77
+ subAssemblies: new Map(), // id → { name, componentIds }
78
+ constraintMarkers: [], // THREE.Object3D
79
+ jointAxisMarkers: [], // THREE.Object3D
80
+
81
+ // Scene integration
82
+ assemblyGroup: null, // THREE.Group for all assembly meshes
83
+ scene: null, // Reference to Three.js scene
84
+ ground: null, // ID of grounded component
85
+
86
+ // Configuration
87
+ solverMaxIterations: 50,
88
+ solverTolerance: 0.01, // mm
89
+ constraintColor: 0x00ff00, // Green for mates
90
+ jointColor: 0xffff00, // Yellow for joints
91
+
92
+ // Tracking
93
+ componentIdCounter: 0,
94
+ mateIdCounter: 0,
95
+ jointIdCounter: 0,
96
+ subAssemblyIdCounter: 0,
97
+
98
+ /**
99
+ * Initialize assembly workspace
100
+ * @param {THREE.Scene} scene - Three.js scene to attach to
101
+ * @returns {THREE.Group} assembly group for rendering
102
+ */
103
+ initAssembly(scene) {
104
+ this.scene = scene;
105
+ this.assemblyGroup = new THREE.Group();
106
+ this.assemblyGroup.name = 'Assembly';
107
+ scene.add(this.assemblyGroup);
108
+ console.log('[Assembly] Workspace initialized');
109
+ return this.assemblyGroup;
110
+ },
111
+
112
+ /**
113
+ * Add a component (part) to the assembly
114
+ * @param {THREE.Mesh} mesh - The part mesh
115
+ * @param {string} name - Display name
116
+ * @param {Object} options - { position: Vector3, rotation: Euler, grounded: boolean }
117
+ * @returns {string} component ID
118
+ */
119
+ addComponent(mesh, name, options = {}) {
120
+ const id = `comp_${this.componentIdCounter++}`;
121
+
122
+ const component = {
123
+ id,
124
+ name,
125
+ mesh: mesh.clone(),
126
+ position: options.position ? options.position.clone() : new THREE.Vector3(),
127
+ rotation: options.rotation ? options.rotation.clone() : new THREE.Euler(),
128
+ constraints: [],
129
+ joints: [],
130
+ grounded: options.grounded || false,
131
+ originalTransform: {
132
+ position: options.position ? options.position.clone() : new THREE.Vector3(),
133
+ rotation: options.rotation ? options.rotation.clone() : new THREE.Euler(),
134
+ },
135
+ parentSubAssembly: null,
136
+ };
137
+
138
+ // Apply transform to mesh
139
+ component.mesh.position.copy(component.position);
140
+ component.mesh.rotation.copy(component.rotation);
141
+ this.assemblyGroup.add(component.mesh);
142
+
143
+ this.components.set(id, component);
144
+ console.log(`[Assembly] Added component: ${name} (${id})`);
145
+ return id;
146
+ },
147
+
148
+ /**
149
+ * Remove a component from the assembly
150
+ * @param {string} componentId - ID of component to remove
151
+ * @returns {boolean} success
152
+ */
153
+ removeComponent(componentId) {
154
+ const component = this.components.get(componentId);
155
+ if (!component) return false;
156
+
157
+ // Remove associated mates and joints
158
+ component.constraints.forEach(mateId => this.removeMate(mateId));
159
+ component.joints.forEach(jointId => this.removeJoint(jointId));
160
+
161
+ // Remove mesh from scene
162
+ this.assemblyGroup.remove(component.mesh);
163
+ this.components.delete(componentId);
164
+
165
+ console.log(`[Assembly] Removed component: ${component.name}`);
166
+ return true;
167
+ },
168
+
169
+ /**
170
+ * Reposition a component
171
+ * @param {string} componentId - Component ID
172
+ * @param {THREE.Vector3} position - New position
173
+ * @param {THREE.Euler} rotation - New rotation
174
+ */
175
+ moveComponent(componentId, position, rotation) {
176
+ const component = this.components.get(componentId);
177
+ if (!component) return;
178
+
179
+ if (position) {
180
+ component.position.copy(position);
181
+ component.mesh.position.copy(position);
182
+ }
183
+ if (rotation) {
184
+ component.rotation.copy(rotation);
185
+ component.mesh.rotation.copy(rotation);
186
+ }
187
+ },
188
+
189
+ /**
190
+ * Ground a component (fix it as assembly origin)
191
+ * @param {string} componentId - Component ID to ground
192
+ */
193
+ groundComponent(componentId) {
194
+ const component = this.components.get(componentId);
195
+ if (!component) return;
196
+
197
+ // Unground previous ground
198
+ if (this.ground) {
199
+ const groundComp = this.components.get(this.ground);
200
+ if (groundComp) groundComp.grounded = false;
201
+ }
202
+
203
+ component.grounded = true;
204
+ this.ground = componentId;
205
+ console.log(`[Assembly] Grounded component: ${component.name}`);
206
+ },
207
+
208
+ /**
209
+ * Get component data
210
+ * @param {string} componentId - Component ID
211
+ * @returns {Component|null}
212
+ */
213
+ getComponent(componentId) {
214
+ return this.components.get(componentId) || null;
215
+ },
216
+
217
+ /**
218
+ * Get all components
219
+ * @returns {Component[]}
220
+ */
221
+ getAllComponents() {
222
+ return Array.from(this.components.values());
223
+ },
224
+
225
+ /**
226
+ * Add a mate constraint between two components
227
+ * @param {string} type - 'flush'|'mate'|'insert'|'angle'|'tangent'
228
+ * @param {string} comp1Id - First component ID
229
+ * @param {string} comp2Id - Second component ID
230
+ * @param {Object} params - { face1, face2, offset, angle, flip, tolerance }
231
+ * @returns {string} mate ID
232
+ */
233
+ addMate(type, comp1Id, comp2Id, params = {}) {
234
+ const comp1 = this.components.get(comp1Id);
235
+ const comp2 = this.components.get(comp2Id);
236
+ if (!comp1 || !comp2) return null;
237
+
238
+ const id = `mate_${this.mateIdCounter++}`;
239
+
240
+ const mate = {
241
+ id,
242
+ type,
243
+ comp1Id,
244
+ comp2Id,
245
+ face1Normal: params.face1Normal ? params.face1Normal.clone().normalize() : new THREE.Vector3(0, 0, 1),
246
+ face1Point: params.face1Point ? params.face1Point.clone() : new THREE.Vector3(),
247
+ face2Normal: params.face2Normal ? params.face2Normal.clone().normalize() : new THREE.Vector3(0, 0, -1),
248
+ face2Point: params.face2Point ? params.face2Point.clone() : new THREE.Vector3(),
249
+ offset: params.offset || 0,
250
+ angle: params.angle || 0,
251
+ flip: params.flip || false,
252
+ tolerance: params.tolerance || this.solverTolerance,
253
+ };
254
+
255
+ // Apply flip if requested
256
+ if (mate.flip) {
257
+ mate.face2Normal.multiplyScalar(-1);
258
+ }
259
+
260
+ this.mates.set(id, mate);
261
+ comp1.constraints.push(id);
262
+ comp2.constraints.push(id);
263
+
264
+ console.log(`[Assembly] Added ${type} mate between ${comp1.name} and ${comp2.name}`);
265
+ return id;
266
+ },
267
+
268
+ /**
269
+ * Remove a mate constraint
270
+ * @param {string} mateId - Mate ID
271
+ * @returns {boolean} success
272
+ */
273
+ removeMate(mateId) {
274
+ const mate = this.mates.get(mateId);
275
+ if (!mate) return false;
276
+
277
+ const comp1 = this.components.get(mate.comp1Id);
278
+ const comp2 = this.components.get(mate.comp2Id);
279
+
280
+ if (comp1) comp1.constraints = comp1.constraints.filter(id => id !== mateId);
281
+ if (comp2) comp2.constraints = comp2.constraints.filter(id => id !== mateId);
282
+
283
+ this.mates.delete(mateId);
284
+ return true;
285
+ },
286
+
287
+ /**
288
+ * Solve all mate constraints iteratively
289
+ * Uses Gauss-Seidel-style relaxation: fix grounded, move others to satisfy constraints
290
+ * @returns {Object} { converged: boolean, iterations: number, error: number }
291
+ */
292
+ solveMates() {
293
+ let converged = false;
294
+ let iteration = 0;
295
+ let maxError = Infinity;
296
+
297
+ while (iteration < this.solverMaxIterations && !converged) {
298
+ maxError = 0;
299
+
300
+ // For each mate constraint
301
+ for (const mate of this.mates.values()) {
302
+ const comp1 = this.components.get(mate.comp1Id);
303
+ const comp2 = this.components.get(mate.comp2Id);
304
+ if (!comp1 || !comp2) continue;
305
+
306
+ // Skip if both grounded
307
+ if (comp1.grounded && comp2.grounded) continue;
308
+
309
+ // Determine which to move
310
+ const moveComp = comp1.grounded ? comp2 : comp1;
311
+ const fixComp = comp1.grounded ? comp1 : comp2;
312
+ const moveMate = comp1.grounded ? mate : this._flipMate(mate);
313
+ const fixMate = comp1.grounded ? mate : this._flipMate(mate);
314
+
315
+ // Calculate correction
316
+ const correction = this._calculateMateCorrection(moveMate, fixMate, moveComp, fixComp);
317
+ maxError = Math.max(maxError, correction.distance);
318
+
319
+ // Apply correction
320
+ if (correction.distance > moveComp.constraints.length * 0.001) { // Only apply if error is significant
321
+ moveComp.position.add(correction.translation);
322
+ const targetRotation = new THREE.Quaternion().setFromAxisAngle(
323
+ correction.rotationAxis,
324
+ correction.rotationAngle
325
+ );
326
+ const q = new THREE.Quaternion().setFromEuler(moveComp.rotation);
327
+ q.multiplyQuaternions(targetRotation, q);
328
+ moveComp.rotation.setFromQuaternion(q);
329
+ }
330
+ }
331
+
332
+ // Update mesh transforms
333
+ for (const comp of this.components.values()) {
334
+ if (!comp.grounded) {
335
+ comp.mesh.position.copy(comp.position);
336
+ comp.mesh.rotation.copy(comp.rotation);
337
+ }
338
+ }
339
+
340
+ iteration++;
341
+ converged = maxError < this.solverTolerance;
342
+ }
343
+
344
+ const result = {
345
+ converged,
346
+ iterations: iteration,
347
+ error: maxError,
348
+ };
349
+ console.log(`[Assembly] Constraint solve: ${result.converged ? 'converged' : 'max iterations'} (${result.iterations}/${this.solverMaxIterations}, error=${result.error.toFixed(3)}mm)`);
350
+ return result;
351
+ },
352
+
353
+ /**
354
+ * Internal: Calculate mate constraint correction
355
+ * @private
356
+ */
357
+ _calculateMateCorrection(mate, fixMate, moveComp, fixComp) {
358
+ const correction = {
359
+ translation: new THREE.Vector3(),
360
+ rotationAxis: new THREE.Vector3(0, 0, 1),
361
+ rotationAngle: 0,
362
+ distance: 0,
363
+ };
364
+
365
+ // Transform face points to world space
366
+ const face1Point = mate.face1Point.clone().applyEuler(moveComp.rotation).add(moveComp.position);
367
+ const face2Point = fixMate.face2Point.clone().applyEuler(fixComp.rotation).add(fixComp.position);
368
+ const face1Normal = mate.face1Normal.clone().applyEuler(moveComp.rotation).normalize();
369
+ const face2Normal = fixMate.face2Normal.clone().applyEuler(fixComp.rotation).normalize();
370
+
371
+ const gap = face2Point.clone().sub(face1Point);
372
+ const distance = gap.length();
373
+ correction.distance = distance;
374
+
375
+ // Constraint type specific logic
376
+ switch (mate.type) {
377
+ case 'flush':
378
+ // Align normals, bring into contact
379
+ correction.translation.copy(gap);
380
+ correction.rotationAxis.crossVectors(face1Normal, face2Normal).normalize();
381
+ correction.rotationAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(face2Normal)))) * 0.1;
382
+ break;
383
+
384
+ case 'mate':
385
+ // Align normals opposite, bring into contact
386
+ const oppNormal = face2Normal.clone().multiplyScalar(-1);
387
+ correction.translation.copy(gap);
388
+ correction.rotationAxis.crossVectors(face1Normal, oppNormal).normalize();
389
+ correction.rotationAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(oppNormal)))) * 0.1;
390
+ break;
391
+
392
+ case 'insert':
393
+ // Coaxial + flush (axes aligned, faces touching)
394
+ correction.translation.copy(gap);
395
+ correction.rotationAxis.crossVectors(face1Normal, face2Normal).normalize();
396
+ correction.rotationAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(face2Normal)))) * 0.1;
397
+ break;
398
+
399
+ case 'angle':
400
+ // Maintain specified angle between normals
401
+ const targetAngle = mate.angle;
402
+ const currentAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(face2Normal))));
403
+ const angleDiff = currentAngle - targetAngle;
404
+ correction.translation.copy(gap.multiplyScalar(0.5));
405
+ correction.rotationAxis.crossVectors(face1Normal, face2Normal).normalize();
406
+ correction.rotationAngle = angleDiff * 0.05;
407
+ break;
408
+
409
+ case 'tangent':
410
+ // Face tangent to cylinder (distance = cylinder radius)
411
+ correction.translation.copy(gap.multiplyScalar(0.5));
412
+ correction.rotationAxis.copy(face1Normal);
413
+ correction.rotationAngle = 0;
414
+ break;
415
+ }
416
+
417
+ // Damping
418
+ correction.translation.multiplyScalar(0.3);
419
+ correction.rotationAngle *= 0.3;
420
+
421
+ return correction;
422
+ },
423
+
424
+ /**
425
+ * Internal: Flip mate constraint (swap comp1/comp2)
426
+ * @private
427
+ */
428
+ _flipMate(mate) {
429
+ return {
430
+ ...mate,
431
+ face1Point: mate.face2Point.clone(),
432
+ face1Normal: mate.face2Normal.clone(),
433
+ face2Point: mate.face1Point.clone(),
434
+ face2Normal: mate.face1Normal.clone(),
435
+ };
436
+ },
437
+
438
+ /**
439
+ * Add a motion joint between two components
440
+ * @param {string} type - 'revolute'|'prismatic'|'cylindrical'|'ball'|'fixed'|'planar'
441
+ * @param {string} comp1Id - First component (origin)
442
+ * @param {string} comp2Id - Second component (moved)
443
+ * @param {Object} params - { axis, point, limits: { min, max }, damping }
444
+ * @returns {string} joint ID
445
+ */
446
+ addJoint(type, comp1Id, comp2Id, params = {}) {
447
+ const comp1 = this.components.get(comp1Id);
448
+ const comp2 = this.components.get(comp2Id);
449
+ if (!comp1 || !comp2) return null;
450
+
451
+ const id = `joint_${this.jointIdCounter++}`;
452
+
453
+ const joint = {
454
+ id,
455
+ type,
456
+ comp1Id,
457
+ comp2Id,
458
+ axis: params.axis ? params.axis.clone().normalize() : new THREE.Vector3(0, 0, 1),
459
+ point: params.point ? params.point.clone() : new THREE.Vector3(),
460
+ limits: {
461
+ min: params.limits?.min ?? (type === 'revolute' || type === 'cylindrical' ? -Math.PI : -100),
462
+ max: params.limits?.max ?? (type === 'revolute' || type === 'cylindrical' ? Math.PI : 100),
463
+ },
464
+ damping: params.damping ?? 0.8,
465
+ currentValue: 0,
466
+ keyframes: [],
467
+ };
468
+
469
+ this.joints.set(id, joint);
470
+ comp1.joints.push(id);
471
+ comp2.joints.push(id);
472
+
473
+ console.log(`[Assembly] Added ${type} joint between ${comp1.name} and ${comp2.name}`);
474
+ return id;
475
+ },
476
+
477
+ /**
478
+ * Remove a joint
479
+ * @param {string} jointId - Joint ID
480
+ * @returns {boolean} success
481
+ */
482
+ removeJoint(jointId) {
483
+ const joint = this.joints.get(jointId);
484
+ if (!joint) return false;
485
+
486
+ const comp1 = this.components.get(joint.comp1Id);
487
+ const comp2 = this.components.get(joint.comp2Id);
488
+
489
+ if (comp1) comp1.joints = comp1.joints.filter(id => id !== jointId);
490
+ if (comp2) comp2.joints = comp2.joints.filter(id => id !== jointId);
491
+
492
+ this.joints.delete(jointId);
493
+ return true;
494
+ },
495
+
496
+ /**
497
+ * Get current joint position/angle
498
+ * @param {string} jointId - Joint ID
499
+ * @returns {number} current value (radians or mm)
500
+ */
501
+ getJointValue(jointId) {
502
+ const joint = this.joints.get(jointId);
503
+ return joint?.currentValue ?? 0;
504
+ },
505
+
506
+ /**
507
+ * Animate a joint to a target position over duration
508
+ * Uses requestAnimationFrame for smooth keyframe interpolation
509
+ * @param {string} jointId - Joint ID
510
+ * @param {number} targetValue - Target value (radians or mm)
511
+ * @param {number} duration - Duration in milliseconds
512
+ * @returns {Promise} resolves when animation completes
513
+ */
514
+ animateJoint(jointId, targetValue, duration = 500) {
515
+ return new Promise((resolve) => {
516
+ const joint = this.joints.get(jointId);
517
+ if (!joint) {
518
+ resolve();
519
+ return;
520
+ }
521
+
522
+ const comp2 = this.components.get(joint.comp2Id);
523
+ if (!comp2) {
524
+ resolve();
525
+ return;
526
+ }
527
+
528
+ // Clamp to limits
529
+ const clampedTarget = Math.max(joint.limits.min, Math.min(joint.limits.max, targetValue));
530
+
531
+ // Save initial state
532
+ const startValue = joint.currentValue;
533
+ const startPosition = comp2.position.clone();
534
+ const startRotation = comp2.rotation.clone();
535
+ const startTime = Date.now();
536
+
537
+ // Animation loop
538
+ const animationStep = () => {
539
+ const elapsed = Date.now() - startTime;
540
+ const progress = Math.min(1, elapsed / duration);
541
+
542
+ // Easing: ease-out cubic
543
+ const easeProgress = 1 - Math.pow(1 - progress, 3);
544
+
545
+ // Interpolate joint value
546
+ joint.currentValue = startValue + (clampedTarget - startValue) * easeProgress;
547
+
548
+ // Apply joint transformation
549
+ this._applyJointTransform(joint, comp2, startPosition, startRotation);
550
+
551
+ if (progress < 1) {
552
+ requestAnimationFrame(animationStep);
553
+ } else {
554
+ joint.currentValue = clampedTarget;
555
+ this._applyJointTransform(joint, comp2, startPosition, startRotation);
556
+ resolve();
557
+ }
558
+ };
559
+
560
+ requestAnimationFrame(animationStep);
561
+ });
562
+ },
563
+
564
+ /**
565
+ * Internal: Apply joint transformation to component
566
+ * @private
567
+ */
568
+ _applyJointTransform(joint, component, initialPosition, initialRotation) {
569
+ const transform = new THREE.Matrix4();
570
+ const translation = new THREE.Matrix4().makeTranslation(joint.point.x, joint.point.y, joint.point.z);
571
+ const invTranslation = new THREE.Matrix4().makeTranslation(-joint.point.x, -joint.point.y, -joint.point.z);
572
+
573
+ switch (joint.type) {
574
+ case 'revolute':
575
+ case 'cylindrical':
576
+ {
577
+ const rotation = new THREE.Matrix4().makeRotationAxis(joint.axis, joint.currentValue);
578
+ transform.multiplyMatrices(translation, rotation);
579
+ transform.multiplyMatrices(transform, invTranslation);
580
+ }
581
+ break;
582
+
583
+ case 'prismatic':
584
+ {
585
+ const translation2 = new THREE.Matrix4().makeTranslation(
586
+ joint.axis.x * joint.currentValue,
587
+ joint.axis.y * joint.currentValue,
588
+ joint.axis.z * joint.currentValue
589
+ );
590
+ transform.copy(translation2);
591
+ }
592
+ break;
593
+
594
+ case 'ball':
595
+ // Simplified: only rotation around Z
596
+ {
597
+ const rotation = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 0, 1), joint.currentValue);
598
+ transform.multiplyMatrices(translation, rotation);
599
+ transform.multiplyMatrices(transform, invTranslation);
600
+ }
601
+ break;
602
+
603
+ case 'planar':
604
+ {
605
+ const translation2 = new THREE.Matrix4().makeTranslation(
606
+ joint.axis.x * joint.currentValue,
607
+ joint.axis.y * joint.currentValue,
608
+ 0
609
+ );
610
+ transform.copy(translation2);
611
+ }
612
+ break;
613
+
614
+ case 'fixed':
615
+ break;
616
+ }
617
+
618
+ // Apply to initial position/rotation
619
+ const pos = initialPosition.clone().applyMatrix4(transform);
620
+ component.position.copy(pos);
621
+ component.mesh.position.copy(pos);
622
+
623
+ const rot = new THREE.Quaternion().setFromEuler(initialRotation);
624
+ const rotMatrix = new THREE.Matrix4().makeRotationFromQuaternion(rot);
625
+ rotMatrix.multiply(transform);
626
+ const resultRot = new THREE.Quaternion().setFromRotationMatrix(rotMatrix);
627
+ component.rotation.setFromQuaternion(resultRot);
628
+ component.mesh.rotation.setFromQuaternion(resultRot);
629
+ },
630
+
631
+ /**
632
+ * Explode assembly view (move parts outward)
633
+ * @param {number} factor - Explode distance factor (0-1, 1 = extreme)
634
+ */
635
+ explodeAssembly(factor) {
636
+ if (!this.ground) return;
637
+
638
+ const groundComp = this.components.get(this.ground);
639
+ const center = groundComp.position.clone();
640
+
641
+ for (const comp of this.components.values()) {
642
+ if (comp.grounded) continue;
643
+
644
+ // Save original transform
645
+ comp.originalTransform.position.copy(comp.position);
646
+ comp.originalTransform.rotation.copy(comp.rotation);
647
+
648
+ // Move outward from center
649
+ const direction = comp.position.clone().sub(center).normalize();
650
+ const distance = comp.position.clone().sub(center).length();
651
+ const explodeDistance = distance * factor * 5;
652
+
653
+ comp.position.copy(center).addScaledVector(direction, distance + explodeDistance);
654
+ comp.mesh.position.copy(comp.position);
655
+ }
656
+
657
+ console.log(`[Assembly] Exploded by factor ${factor}`);
658
+ },
659
+
660
+ /**
661
+ * Collapse assembly to original state
662
+ */
663
+ collapseAssembly() {
664
+ for (const comp of this.components.values()) {
665
+ comp.position.copy(comp.originalTransform.position);
666
+ comp.rotation.copy(comp.originalTransform.rotation);
667
+ comp.mesh.position.copy(comp.position);
668
+ comp.mesh.rotation.copy(comp.rotation);
669
+ }
670
+
671
+ console.log('[Assembly] Collapsed to assembled state');
672
+ },
673
+
674
+ /**
675
+ * Generate bill of materials
676
+ * @returns {Array} BOM entries: { id, name, quantity, volume, mass }
677
+ */
678
+ getAssemblyBOM() {
679
+ const bom = [];
680
+ const nameMap = new Map();
681
+
682
+ for (const comp of this.components.values()) {
683
+ const key = comp.name;
684
+ if (nameMap.has(key)) {
685
+ nameMap.get(key).quantity++;
686
+ } else {
687
+ // Estimate volume from mesh bounding box
688
+ const bbox = new THREE.Box3().setFromObject(comp.mesh);
689
+ const size = bbox.getSize(new THREE.Vector3());
690
+ const volume = size.x * size.y * size.z;
691
+
692
+ nameMap.set(key, {
693
+ name: key,
694
+ quantity: 1,
695
+ volume: volume.toFixed(2),
696
+ components: [comp.id],
697
+ });
698
+ }
699
+ }
700
+
701
+ // Convert to array
702
+ let index = 0;
703
+ for (const [name, entry] of nameMap.entries()) {
704
+ bom.push({
705
+ id: index++,
706
+ name: entry.name,
707
+ quantity: entry.quantity,
708
+ volume: entry.volume,
709
+ components: entry.components,
710
+ });
711
+ }
712
+
713
+ return bom;
714
+ },
715
+
716
+ /**
717
+ * Serialize assembly to JSON
718
+ * @returns {Object} assembly state JSON
719
+ */
720
+ saveAssembly() {
721
+ const data = {
722
+ components: [],
723
+ mates: [],
724
+ joints: [],
725
+ subAssemblies: [],
726
+ ground: this.ground,
727
+ timestamp: new Date().toISOString(),
728
+ };
729
+
730
+ // Serialize components
731
+ for (const comp of this.components.values()) {
732
+ data.components.push({
733
+ id: comp.id,
734
+ name: comp.name,
735
+ position: { x: comp.position.x, y: comp.position.y, z: comp.position.z },
736
+ rotation: { x: comp.rotation.x, y: comp.rotation.y, z: comp.rotation.z },
737
+ grounded: comp.grounded,
738
+ });
739
+ }
740
+
741
+ // Serialize mates
742
+ for (const mate of this.mates.values()) {
743
+ data.mates.push({
744
+ id: mate.id,
745
+ type: mate.type,
746
+ comp1Id: mate.comp1Id,
747
+ comp2Id: mate.comp2Id,
748
+ face1Normal: { x: mate.face1Normal.x, y: mate.face1Normal.y, z: mate.face1Normal.z },
749
+ face1Point: { x: mate.face1Point.x, y: mate.face1Point.y, z: mate.face1Point.z },
750
+ face2Normal: { x: mate.face2Normal.x, y: mate.face2Normal.y, z: mate.face2Normal.z },
751
+ face2Point: { x: mate.face2Point.x, y: mate.face2Point.y, z: mate.face2Point.z },
752
+ offset: mate.offset,
753
+ angle: mate.angle,
754
+ flip: mate.flip,
755
+ });
756
+ }
757
+
758
+ // Serialize joints
759
+ for (const joint of this.joints.values()) {
760
+ data.joints.push({
761
+ id: joint.id,
762
+ type: joint.type,
763
+ comp1Id: joint.comp1Id,
764
+ comp2Id: joint.comp2Id,
765
+ axis: { x: joint.axis.x, y: joint.axis.y, z: joint.axis.z },
766
+ point: { x: joint.point.x, y: joint.point.y, z: joint.point.z },
767
+ limits: joint.limits,
768
+ damping: joint.damping,
769
+ currentValue: joint.currentValue,
770
+ });
771
+ }
772
+
773
+ // Serialize sub-assemblies
774
+ for (const [id, subAsm] of this.subAssemblies.entries()) {
775
+ data.subAssemblies.push({
776
+ id,
777
+ name: subAsm.name,
778
+ componentIds: subAsm.componentIds,
779
+ });
780
+ }
781
+
782
+ return data;
783
+ },
784
+
785
+ /**
786
+ * Restore assembly from JSON
787
+ * Requires mesh references to be added back manually
788
+ * @param {Object} json - Assembly state JSON from saveAssembly()
789
+ */
790
+ loadAssembly(json) {
791
+ this.components.clear();
792
+ this.mates.clear();
793
+ this.joints.clear();
794
+ this.subAssemblies.clear();
795
+ this.ground = json.ground;
796
+
797
+ // Restore components (mesh data not preserved; need to re-add)
798
+ for (const compData of json.components) {
799
+ const comp = {
800
+ id: compData.id,
801
+ name: compData.name,
802
+ mesh: null, // Must be set externally
803
+ position: new THREE.Vector3(compData.position.x, compData.position.y, compData.position.z),
804
+ rotation: new THREE.Euler(compData.rotation.x, compData.rotation.y, compData.rotation.z),
805
+ constraints: [],
806
+ joints: [],
807
+ grounded: compData.grounded,
808
+ originalTransform: {
809
+ position: new THREE.Vector3(compData.position.x, compData.position.y, compData.position.z),
810
+ rotation: new THREE.Euler(compData.rotation.x, compData.rotation.y, compData.rotation.z),
811
+ },
812
+ parentSubAssembly: null,
813
+ };
814
+ this.components.set(compData.id, comp);
815
+ this.componentIdCounter = Math.max(this.componentIdCounter, parseInt(compData.id.split('_')[1]) + 1);
816
+ }
817
+
818
+ // Restore mates
819
+ for (const mateData of json.mates) {
820
+ const mate = {
821
+ id: mateData.id,
822
+ type: mateData.type,
823
+ comp1Id: mateData.comp1Id,
824
+ comp2Id: mateData.comp2Id,
825
+ face1Normal: new THREE.Vector3(mateData.face1Normal.x, mateData.face1Normal.y, mateData.face1Normal.z),
826
+ face1Point: new THREE.Vector3(mateData.face1Point.x, mateData.face1Point.y, mateData.face1Point.z),
827
+ face2Normal: new THREE.Vector3(mateData.face2Normal.x, mateData.face2Normal.y, mateData.face2Normal.z),
828
+ face2Point: new THREE.Vector3(mateData.face2Point.x, mateData.face2Point.y, mateData.face2Point.z),
829
+ offset: mateData.offset,
830
+ angle: mateData.angle,
831
+ flip: mateData.flip,
832
+ tolerance: this.solverTolerance,
833
+ };
834
+ this.mates.set(mateData.id, mate);
835
+
836
+ const comp1 = this.components.get(mateData.comp1Id);
837
+ const comp2 = this.components.get(mateData.comp2Id);
838
+ if (comp1) comp1.constraints.push(mateData.id);
839
+ if (comp2) comp2.constraints.push(mateData.id);
840
+
841
+ this.mateIdCounter = Math.max(this.mateIdCounter, parseInt(mateData.id.split('_')[1]) + 1);
842
+ }
843
+
844
+ // Restore joints
845
+ for (const jointData of json.joints) {
846
+ const joint = {
847
+ id: jointData.id,
848
+ type: jointData.type,
849
+ comp1Id: jointData.comp1Id,
850
+ comp2Id: jointData.comp2Id,
851
+ axis: new THREE.Vector3(jointData.axis.x, jointData.axis.y, jointData.axis.z),
852
+ point: new THREE.Vector3(jointData.point.x, jointData.point.y, jointData.point.z),
853
+ limits: jointData.limits,
854
+ damping: jointData.damping,
855
+ currentValue: jointData.currentValue,
856
+ keyframes: [],
857
+ };
858
+ this.joints.set(jointData.id, joint);
859
+
860
+ const comp1 = this.components.get(jointData.comp1Id);
861
+ const comp2 = this.components.get(jointData.comp2Id);
862
+ if (comp1) comp1.joints.push(jointData.id);
863
+ if (comp2) comp2.joints.push(jointData.id);
864
+
865
+ this.jointIdCounter = Math.max(this.jointIdCounter, parseInt(jointData.id.split('_')[1]) + 1);
866
+ }
867
+
868
+ // Restore sub-assemblies
869
+ for (const subAsmData of json.subAssemblies) {
870
+ this.subAssemblies.set(subAsmData.id, {
871
+ name: subAsmData.name,
872
+ componentIds: subAsmData.componentIds,
873
+ });
874
+ this.subAssemblyIdCounter = Math.max(this.subAssemblyIdCounter, parseInt(subAsmData.id.split('_')[1]) + 1);
875
+ }
876
+
877
+ console.log(`[Assembly] Loaded assembly: ${json.components.length} components, ${json.mates.length} mates, ${json.joints.length} joints`);
878
+ },
879
+
880
+ /**
881
+ * Get assembly statistics
882
+ * @returns {Object} { componentCount, mateCount, jointCount, groundedId, dof }
883
+ */
884
+ getAssemblyStats() {
885
+ // DOF = 6 * (n-1) - constraints
886
+ // Each fixed component reduces DOF by 6
887
+ // Each mate removes up to 5 DOF (varies by type)
888
+ const numComponents = this.components.size;
889
+ const numMates = this.mates.size;
890
+ const numJoints = this.joints.size;
891
+
892
+ // Simplified DOF calculation
893
+ let baseDOF = 6 * Math.max(0, numComponents - 1);
894
+ let constrainedDOF = numMates * 3 + numJoints * 0; // Mates remove ~3 DOF each, joints are prescribed
895
+ const dof = baseDOF - constrainedDOF;
896
+
897
+ return {
898
+ componentCount: numComponents,
899
+ mateCount: numMates,
900
+ jointCount: numJoints,
901
+ groundedId: this.ground,
902
+ dof: Math.max(0, dof),
903
+ };
904
+ },
905
+
906
+ /**
907
+ * Show/hide constraint visualization markers
908
+ * @param {boolean} visible - Show markers?
909
+ */
910
+ showConstraintMarkers(visible) {
911
+ if (visible) {
912
+ // Clear existing markers
913
+ this.constraintMarkers.forEach(marker => this.assemblyGroup.remove(marker));
914
+ this.constraintMarkers = [];
915
+
916
+ // Create markers for each mate
917
+ for (const mate of this.mates.values()) {
918
+ const comp1 = this.components.get(mate.comp1Id);
919
+ const comp2 = this.components.get(mate.comp2Id);
920
+ if (!comp1 || !comp2) continue;
921
+
922
+ // Line connecting constraint points
923
+ const pos1 = mate.face1Point.clone().applyEuler(comp1.rotation).add(comp1.position);
924
+ const pos2 = mate.face2Point.clone().applyEuler(comp2.rotation).add(comp2.position);
925
+
926
+ const geometry = new THREE.BufferGeometry();
927
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
928
+ pos1.x, pos1.y, pos1.z,
929
+ pos2.x, pos2.y, pos2.z,
930
+ ]), 3));
931
+
932
+ const material = new THREE.LineBasicMaterial({ color: this.constraintColor, linewidth: 2 });
933
+ const line = new THREE.Line(geometry, material);
934
+ this.assemblyGroup.add(line);
935
+ this.constraintMarkers.push(line);
936
+ }
937
+
938
+ console.log(`[Assembly] Showing ${this.constraintMarkers.length} constraint markers`);
939
+ } else {
940
+ this.constraintMarkers.forEach(marker => this.assemblyGroup.remove(marker));
941
+ this.constraintMarkers = [];
942
+ console.log('[Assembly] Hiding constraint markers');
943
+ }
944
+ },
945
+
946
+ /**
947
+ * Highlight a component with color
948
+ * @param {string} componentId - Component ID
949
+ * @param {number} color - THREE.js color hex
950
+ */
951
+ highlightComponent(componentId, color = 0xffff00) {
952
+ const comp = this.components.get(componentId);
953
+ if (!comp) return;
954
+
955
+ // Store original material
956
+ if (!comp._originalMaterial) {
957
+ comp._originalMaterial = comp.mesh.material;
958
+ }
959
+
960
+ // Apply highlight material
961
+ const highlightMaterial = new THREE.MeshPhongMaterial({ color, emissive: color, emissiveIntensity: 0.5 });
962
+ comp.mesh.material = highlightMaterial;
963
+ },
964
+
965
+ /**
966
+ * Clear component highlight
967
+ * @param {string} componentId - Component ID
968
+ */
969
+ clearHighlight(componentId) {
970
+ const comp = this.components.get(componentId);
971
+ if (!comp || !comp._originalMaterial) return;
972
+
973
+ comp.mesh.material = comp._originalMaterial;
974
+ },
975
+
976
+ /**
977
+ * Show/hide joint axis visualization
978
+ * @param {boolean} visible - Show axes?
979
+ */
980
+ showJointAxes(visible) {
981
+ if (visible) {
982
+ this.jointAxisMarkers.forEach(marker => this.assemblyGroup.remove(marker));
983
+ this.jointAxisMarkers = [];
984
+
985
+ // Create axis arrows for each joint
986
+ for (const joint of this.joints.values()) {
987
+ const origin = joint.point.clone();
988
+ const direction = joint.axis.clone();
989
+
990
+ // Arrow geometry
991
+ const arrowLength = 50;
992
+ const arrowHelper = new THREE.ArrowHelper(direction, origin, arrowLength, this.jointColor);
993
+ this.assemblyGroup.add(arrowHelper);
994
+ this.jointAxisMarkers.push(arrowHelper);
995
+ }
996
+
997
+ console.log(`[Assembly] Showing ${this.jointAxisMarkers.length} joint axes`);
998
+ } else {
999
+ this.jointAxisMarkers.forEach(marker => this.assemblyGroup.remove(marker));
1000
+ this.jointAxisMarkers = [];
1001
+ console.log('[Assembly] Hiding joint axes');
1002
+ }
1003
+ },
1004
+
1005
+ /**
1006
+ * Get assembly tree (for hierarchical display)
1007
+ * @returns {Object} tree structure: { id, name, type, children, components }
1008
+ */
1009
+ getAssemblyTree() {
1010
+ const tree = {
1011
+ id: 'root',
1012
+ name: 'Assembly',
1013
+ type: 'assembly',
1014
+ children: [],
1015
+ stats: this.getAssemblyStats(),
1016
+ };
1017
+
1018
+ // Add sub-assemblies
1019
+ for (const [subId, subAsm] of this.subAssemblies.entries()) {
1020
+ const subNode = {
1021
+ id: subId,
1022
+ name: subAsm.name,
1023
+ type: 'subAssembly',
1024
+ children: [],
1025
+ };
1026
+
1027
+ // Add components in sub-assembly
1028
+ for (const compId of subAsm.componentIds) {
1029
+ const comp = this.components.get(compId);
1030
+ if (comp) {
1031
+ subNode.children.push({
1032
+ id: compId,
1033
+ name: comp.name,
1034
+ type: 'component',
1035
+ grounded: comp.grounded,
1036
+ constraintCount: comp.constraints.length,
1037
+ jointCount: comp.joints.length,
1038
+ });
1039
+ }
1040
+ }
1041
+
1042
+ tree.children.push(subNode);
1043
+ }
1044
+
1045
+ // Add ungrouped components
1046
+ const ungroupedNode = {
1047
+ id: 'ungrouped',
1048
+ name: 'Components',
1049
+ type: 'group',
1050
+ children: [],
1051
+ };
1052
+
1053
+ for (const comp of this.components.values()) {
1054
+ if (!comp.parentSubAssembly) {
1055
+ ungroupedNode.children.push({
1056
+ id: comp.id,
1057
+ name: comp.name,
1058
+ type: 'component',
1059
+ grounded: comp.grounded,
1060
+ constraintCount: comp.constraints.length,
1061
+ jointCount: comp.joints.length,
1062
+ });
1063
+ }
1064
+ }
1065
+
1066
+ if (ungroupedNode.children.length > 0) {
1067
+ tree.children.push(ungroupedNode);
1068
+ }
1069
+
1070
+ return tree;
1071
+ },
1072
+
1073
+ /**
1074
+ * Create a sub-assembly from a group of components
1075
+ * @param {string} name - Sub-assembly name
1076
+ * @param {string[]} componentIds - Component IDs to group
1077
+ * @returns {string} sub-assembly ID
1078
+ */
1079
+ createSubAssembly(name, componentIds) {
1080
+ const id = `subasm_${this.subAssemblyIdCounter++}`;
1081
+
1082
+ const subAsm = {
1083
+ name,
1084
+ componentIds,
1085
+ };
1086
+
1087
+ this.subAssemblies.set(id, subAsm);
1088
+
1089
+ // Mark components as belonging to sub-assembly
1090
+ for (const compId of componentIds) {
1091
+ const comp = this.components.get(compId);
1092
+ if (comp) {
1093
+ comp.parentSubAssembly = id;
1094
+ }
1095
+ }
1096
+
1097
+ console.log(`[Assembly] Created sub-assembly: ${name} with ${componentIds.length} components`);
1098
+ return id;
1099
+ },
1100
+ };
1101
+
1102
+ export default Assembly;