cyclecad 1.3.2 → 1.3.3

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,582 @@
1
+ /**
2
+ * Assembly Module for cycleCAD
3
+ * Manages multi-body assemblies with joints, constraints, and motion studies
4
+ * Version 1.0.0
5
+ */
6
+
7
+ const AssemblyModule = {
8
+ id: 'assembly',
9
+ name: 'Assembly',
10
+ version: '1.0.0',
11
+ category: 'engine',
12
+ dependencies: ['viewport', 'operations'],
13
+ memoryEstimate: 25,
14
+
15
+ // Module state
16
+ state: {
17
+ components: new Map(), // id -> {partId, name, position, rotation, visible}
18
+ joints: new Map(), // id -> {type, comp1, comp2, axis, origin, min, max, value}
19
+ bomEntries: [], // [{partId, quantity, name}]
20
+ explodeFactor: 0,
21
+ motionStudy: null, // {jointId, start, end, steps, positions[]}
22
+ },
23
+
24
+ // Joint type definitions
25
+ JOINT_TYPES: {
26
+ RIGID: 'rigid', // 0 DOF
27
+ REVOLUTE: 'revolute', // 1 DOF (rotation)
28
+ SLIDER: 'slider', // 1 DOF (translation)
29
+ CYLINDRICAL: 'cylindrical', // 2 DOF (rotation + translation)
30
+ PIN_SLOT: 'pin-slot', // 2 DOF (rotation + perpendicular translation)
31
+ PLANAR: 'planar', // 3 DOF (translation in plane + normal rotation)
32
+ BALL: 'ball', // 3 DOF (rotation around point)
33
+ },
34
+
35
+ /**
36
+ * Initialize assembly module
37
+ */
38
+ init() {
39
+ if (window._debug) console.log('[Assembly] Initializing...');
40
+ this.state.components.clear();
41
+ this.state.joints.clear();
42
+ this.state.bomEntries = [];
43
+ this.state.explodeFactor = 0;
44
+ this._initEventListeners();
45
+ },
46
+
47
+ /**
48
+ * Initialize event listeners
49
+ */
50
+ _initEventListeners() {
51
+ // Placeholder for event delegation
52
+ window.addEventListener('assembly:action', (e) => {
53
+ if (window._debug) console.log('[Assembly] Event:', e.detail);
54
+ });
55
+ },
56
+
57
+ /**
58
+ * Insert a component (part) into assembly
59
+ * @param {string} partId - UUID or index of part
60
+ * @param {THREE.Vector3} position - placement position
61
+ * @param {THREE.Quaternion} rotation - placement rotation
62
+ * @returns {string} componentId
63
+ */
64
+ insertComponent(partId, position = new THREE.Vector3(0, 0, 0), rotation = new THREE.Quaternion()) {
65
+ const componentId = `comp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
66
+
67
+ const component = {
68
+ id: componentId,
69
+ partId,
70
+ name: `Part ${this.state.components.size + 1}`,
71
+ position: position.clone(),
72
+ rotation: rotation.clone(),
73
+ visible: true,
74
+ group: new THREE.Group(),
75
+ matrix: new THREE.Matrix4(),
76
+ };
77
+
78
+ // Set initial transform
79
+ component.group.position.copy(position);
80
+ component.group.quaternion.copy(rotation);
81
+
82
+ this.state.components.set(componentId, component);
83
+
84
+ if (window._debug) console.log(`[Assembly] Inserted component ${componentId} for part ${partId}`);
85
+ window.dispatchEvent(new CustomEvent('assembly:componentInserted', { detail: { componentId, partId } }));
86
+
87
+ return componentId;
88
+ },
89
+
90
+ /**
91
+ * Create a joint between two components
92
+ * @param {string} type - joint type (RIGID, REVOLUTE, SLIDER, etc.)
93
+ * @param {string} comp1Id - first component ID
94
+ * @param {string} comp2Id - second component ID
95
+ * @param {Object} params - {axis: Vector3, origin: Vector3, offset1: Vector3, offset2: Vector3}
96
+ * @returns {string} jointId
97
+ */
98
+ createJoint(type, comp1Id, comp2Id, params = {}) {
99
+ if (!this.JOINT_TYPES[type.toUpperCase()]) {
100
+ console.error(`[Assembly] Unknown joint type: ${type}`);
101
+ return null;
102
+ }
103
+
104
+ const jointId = `joint-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
105
+ const comp1 = this.state.components.get(comp1Id);
106
+ const comp2 = this.state.components.get(comp2Id);
107
+
108
+ if (!comp1 || !comp2) {
109
+ console.error(`[Assembly] Component not found: ${comp1Id} or ${comp2Id}`);
110
+ return null;
111
+ }
112
+
113
+ // Default axis and origin (world Z-axis at midpoint)
114
+ const axis = params.axis || new THREE.Vector3(0, 0, 1);
115
+ const origin = params.origin || new THREE.Vector3().addVectors(comp1.position, comp2.position).multiplyScalar(0.5);
116
+
117
+ const joint = {
118
+ id: jointId,
119
+ type: type.toLowerCase(),
120
+ comp1Id,
121
+ comp2Id,
122
+ axis: axis.normalize(),
123
+ origin: origin.clone(),
124
+ min: params.min !== undefined ? params.min : 0,
125
+ max: params.max !== undefined ? params.max : 0,
126
+ value: 0, // Current joint value (angle in rad for revolute, distance for slider)
127
+ offset1: params.offset1 ? params.offset1.clone() : new THREE.Vector3(),
128
+ offset2: params.offset2 ? params.offset2.clone() : new THREE.Vector3(),
129
+ };
130
+
131
+ this.state.joints.set(jointId, joint);
132
+
133
+ if (window._debug) console.log(`[Assembly] Created ${type} joint ${jointId} between ${comp1Id} and ${comp2Id}`);
134
+ window.dispatchEvent(new CustomEvent('assembly:jointCreated', { detail: { jointId, type, comp1Id, comp2Id } }));
135
+
136
+ return jointId;
137
+ },
138
+
139
+ /**
140
+ * Set joint limits (min and max values)
141
+ * @param {string} jointId - joint ID
142
+ * @param {number} min - minimum value (radians for revolute, mm for slider)
143
+ * @param {number} max - maximum value
144
+ */
145
+ setJointLimits(jointId, min, max) {
146
+ const joint = this.state.joints.get(jointId);
147
+ if (!joint) {
148
+ console.error(`[Assembly] Joint not found: ${jointId}`);
149
+ return;
150
+ }
151
+
152
+ joint.min = min;
153
+ joint.max = max;
154
+ joint.value = Math.max(min, Math.min(joint.value, max)); // Clamp current value
155
+
156
+ if (window._debug) console.log(`[Assembly] Set limits for ${jointId}: [${min}, ${max}]`);
157
+ },
158
+
159
+ /**
160
+ * Animate a joint to a specific value
161
+ * @param {string} jointId - joint ID
162
+ * @param {number} value - target value (radians for revolute, mm for slider)
163
+ * @param {number} duration - animation duration in ms (optional, default 500)
164
+ */
165
+ animateJoint(jointId, value, duration = 500) {
166
+ const joint = this.state.joints.get(jointId);
167
+ if (!joint) {
168
+ console.error(`[Assembly] Joint not found: ${jointId}`);
169
+ return;
170
+ }
171
+
172
+ // Clamp to limits
173
+ const targetValue = Math.max(joint.min, Math.min(value, joint.max));
174
+ const startValue = joint.value;
175
+ const startTime = Date.now();
176
+
177
+ const animate = () => {
178
+ const elapsed = Date.now() - startTime;
179
+ const progress = Math.min(elapsed / duration, 1);
180
+
181
+ // Ease-in-out cubic
182
+ const easeProgress = progress < 0.5
183
+ ? 4 * progress * progress * progress
184
+ : 1 - Math.pow(-2 * progress + 2, 3) / 2;
185
+
186
+ joint.value = startValue + (targetValue - startValue) * easeProgress;
187
+ this._updateComponentTransforms();
188
+
189
+ if (progress < 1) {
190
+ requestAnimationFrame(animate);
191
+ } else {
192
+ joint.value = targetValue;
193
+ this._updateComponentTransforms();
194
+ window.dispatchEvent(new CustomEvent('assembly:jointAnimationComplete', { detail: { jointId } }));
195
+ }
196
+ };
197
+
198
+ animate();
199
+ },
200
+
201
+ /**
202
+ * Update component transforms based on joint values
203
+ * @private
204
+ */
205
+ _updateComponentTransforms() {
206
+ for (const joint of this.state.joints.values()) {
207
+ const comp1 = this.state.components.get(joint.comp1Id);
208
+ const comp2 = this.state.components.get(joint.comp2Id);
209
+
210
+ if (!comp1 || !comp2) continue;
211
+
212
+ switch (joint.type) {
213
+ case 'rigid':
214
+ // No relative motion
215
+ break;
216
+
217
+ case 'revolute':
218
+ // Rotate comp2 around joint axis
219
+ {
220
+ const q = new THREE.Quaternion();
221
+ q.setFromAxisAngle(joint.axis, joint.value);
222
+
223
+ // Transform relative to joint origin
224
+ const relPos = comp2.position.clone().sub(joint.origin);
225
+ relPos.applyQuaternion(q);
226
+ comp2.position.copy(joint.origin).add(relPos);
227
+
228
+ const newRot = new THREE.Quaternion();
229
+ newRot.multiplyQuaternions(q, comp2.rotation);
230
+ comp2.rotation.copy(newRot);
231
+ }
232
+ break;
233
+
234
+ case 'slider':
235
+ // Translate comp2 along joint axis
236
+ {
237
+ const translation = joint.axis.clone().multiplyScalar(joint.value);
238
+ comp2.position.copy(comp1.position).add(translation).add(joint.offset2);
239
+ }
240
+ break;
241
+
242
+ case 'cylindrical':
243
+ // Combination of revolute and slider
244
+ {
245
+ // Translation component (only apply 50% of value for cylindrical)
246
+ const translation = joint.axis.clone().multiplyScalar(joint.value * 0.5);
247
+ const q = new THREE.Quaternion();
248
+ q.setFromAxisAngle(joint.axis, joint.value);
249
+
250
+ const relPos = comp2.position.clone().sub(joint.origin);
251
+ relPos.applyQuaternion(q);
252
+ relPos.add(translation);
253
+
254
+ comp2.position.copy(joint.origin).add(relPos);
255
+ const newRot = new THREE.Quaternion();
256
+ newRot.multiplyQuaternions(q, comp2.rotation);
257
+ comp2.rotation.copy(newRot);
258
+ }
259
+ break;
260
+
261
+ case 'planar':
262
+ // Translation in plane (2 DOF) — simplified to just translation
263
+ {
264
+ const translation = joint.axis.clone().multiplyScalar(joint.value);
265
+ comp2.position.copy(comp1.position).add(translation);
266
+ }
267
+ break;
268
+
269
+ case 'ball':
270
+ // Free rotation around origin
271
+ {
272
+ const q = new THREE.Quaternion();
273
+ q.setFromAxisAngle(joint.axis, joint.value);
274
+ const newRot = new THREE.Quaternion();
275
+ newRot.multiplyQuaternions(q, comp2.rotation);
276
+ comp2.rotation.copy(newRot);
277
+ }
278
+ break;
279
+ }
280
+
281
+ // Update Three.js group
282
+ comp2.group.position.copy(comp2.position);
283
+ comp2.group.quaternion.copy(comp2.rotation);
284
+ }
285
+ },
286
+
287
+ /**
288
+ * Check for interference (overlap) between components
289
+ * @param {string[]} componentIds - list of component IDs to check (or all if empty)
290
+ * @returns {Array} [{comp1Id, comp2Id, distance, isInterfering}]
291
+ */
292
+ checkInterference(componentIds = []) {
293
+ const toCheck = componentIds.length > 0 ? componentIds : Array.from(this.state.components.keys());
294
+ const results = [];
295
+
296
+ for (let i = 0; i < toCheck.length; i++) {
297
+ for (let j = i + 1; j < toCheck.length; j++) {
298
+ const comp1 = this.state.components.get(toCheck[i]);
299
+ const comp2 = this.state.components.get(toCheck[j]);
300
+
301
+ if (!comp1 || !comp2) continue;
302
+
303
+ // Simple BBox-based interference check
304
+ // In production, would use mesh-to-mesh intersection
305
+ const dist = comp1.position.distanceTo(comp2.position);
306
+ const minDist = 5; // Minimum safe distance in mm
307
+ const isInterfering = dist < minDist;
308
+
309
+ results.push({
310
+ comp1Id: toCheck[i],
311
+ comp2Id: toCheck[j],
312
+ distance: dist,
313
+ isInterfering,
314
+ });
315
+
316
+ if (isInterfering) {
317
+ window.dispatchEvent(new CustomEvent('assembly:interferenceFound', {
318
+ detail: { comp1Id: toCheck[i], comp2Id: toCheck[j], distance: dist }
319
+ }));
320
+ }
321
+ }
322
+ }
323
+
324
+ return results;
325
+ },
326
+
327
+ /**
328
+ * Generate exploded view
329
+ * @param {number} factor - explosion distance multiplier (0-1 for collapse, 1+ for explode)
330
+ */
331
+ explode(factor = 1.5) {
332
+ this.state.explodeFactor = factor;
333
+
334
+ for (const component of this.state.components.values()) {
335
+ // Move each component away from assembly origin
336
+ const direction = component.position.clone().normalize();
337
+ const baseDistance = component.position.length();
338
+ const newDistance = baseDistance * factor;
339
+
340
+ component.position.copy(direction.multiplyScalar(newDistance));
341
+ component.group.position.copy(component.position);
342
+ }
343
+
344
+ if (window._debug) console.log(`[Assembly] Exploded view with factor ${factor}`);
345
+ window.dispatchEvent(new CustomEvent('assembly:exploded', { detail: { factor } }));
346
+ },
347
+
348
+ /**
349
+ * Generate bill of materials
350
+ * @returns {Array} [{partId, name, quantity}]
351
+ */
352
+ generateBOM() {
353
+ const bom = new Map(); // partId -> {name, quantity}
354
+
355
+ for (const component of this.state.components.values()) {
356
+ if (bom.has(component.partId)) {
357
+ bom.get(component.partId).quantity++;
358
+ } else {
359
+ bom.set(component.partId, {
360
+ partId: component.partId,
361
+ name: component.name,
362
+ quantity: 1,
363
+ });
364
+ }
365
+ }
366
+
367
+ this.state.bomEntries = Array.from(bom.values());
368
+
369
+ if (window._debug) console.log(`[Assembly] Generated BOM with ${this.state.bomEntries.length} entries`);
370
+ window.dispatchEvent(new CustomEvent('assembly:bomGenerated', { detail: { entries: this.state.bomEntries } }));
371
+
372
+ return this.state.bomEntries;
373
+ },
374
+
375
+ /**
376
+ * Create a component pattern (linear or circular)
377
+ * @param {string} componentId - component to pattern
378
+ * @param {string} type - 'linear' or 'circular'
379
+ * @param {Object} params - {count, spacing, axis, center}
380
+ */
381
+ pattern(componentId, type, params = {}) {
382
+ const baseComp = this.state.components.get(componentId);
383
+ if (!baseComp) {
384
+ console.error(`[Assembly] Component not found: ${componentId}`);
385
+ return;
386
+ }
387
+
388
+ const count = params.count || 3;
389
+ const spacing = params.spacing || 10;
390
+ const axis = params.axis || new THREE.Vector3(1, 0, 0);
391
+ const center = params.center || baseComp.position.clone();
392
+
393
+ const newComponentIds = [];
394
+
395
+ for (let i = 1; i < count; i++) {
396
+ let position = baseComp.position.clone();
397
+
398
+ if (type === 'linear') {
399
+ position.add(axis.clone().normalize().multiplyScalar(spacing * i));
400
+ } else if (type === 'circular') {
401
+ // Circular array around center
402
+ const angle = (Math.PI * 2 / count) * i;
403
+ const radius = baseComp.position.distanceTo(center);
404
+ const x = center.x + radius * Math.cos(angle);
405
+ const y = center.y + radius * Math.sin(angle);
406
+ position.set(x, y, center.z);
407
+ }
408
+
409
+ const newCompId = this.insertComponent(baseComp.partId, position, baseComp.rotation.clone());
410
+ newComponentIds.push(newCompId);
411
+ }
412
+
413
+ if (window._debug) console.log(`[Assembly] Created ${type} pattern with ${count} instances`);
414
+ window.dispatchEvent(new CustomEvent('assembly:patternCreated', {
415
+ detail: { type, count, newComponentIds }
416
+ }));
417
+
418
+ return newComponentIds;
419
+ },
420
+
421
+ /**
422
+ * Create a motion study (animate through joint range)
423
+ * @param {string} jointId - joint ID to animate
424
+ * @param {number} start - start value
425
+ * @param {number} end - end value
426
+ * @param {number} steps - number of steps in study
427
+ * @returns {Object} motion study object
428
+ */
429
+ motionStudy(jointId, start, end, steps = 20) {
430
+ const joint = this.state.joints.get(jointId);
431
+ if (!joint) {
432
+ console.error(`[Assembly] Joint not found: ${jointId}`);
433
+ return null;
434
+ }
435
+
436
+ const positions = [];
437
+ for (let i = 0; i <= steps; i++) {
438
+ const t = i / steps;
439
+ const value = start + (end - start) * t;
440
+ positions.push(value);
441
+ }
442
+
443
+ this.state.motionStudy = {
444
+ jointId,
445
+ start,
446
+ end,
447
+ steps,
448
+ positions,
449
+ currentStep: 0,
450
+ };
451
+
452
+ if (window._debug) console.log(`[Assembly] Created motion study: ${steps} steps, ${start} → ${end}`);
453
+ window.dispatchEvent(new CustomEvent('assembly:motionStudyCreated', { detail: this.state.motionStudy }));
454
+
455
+ return this.state.motionStudy;
456
+ },
457
+
458
+ /**
459
+ * Step through motion study
460
+ * @param {number} step - step index (0 to steps)
461
+ */
462
+ motionStudyStep(step) {
463
+ if (!this.state.motionStudy) {
464
+ console.error('[Assembly] No active motion study');
465
+ return;
466
+ }
467
+
468
+ const study = this.state.motionStudy;
469
+ step = Math.max(0, Math.min(step, study.positions.length - 1));
470
+ const value = study.positions[step];
471
+
472
+ this.animateJoint(study.jointId, value, 100);
473
+ study.currentStep = step;
474
+
475
+ window.dispatchEvent(new CustomEvent('assembly:motionStudyStep', { detail: { step, value } }));
476
+ },
477
+
478
+ /**
479
+ * Get UI panel for assembly editor
480
+ * @returns {HTMLElement}
481
+ */
482
+ getUI() {
483
+ const panel = document.createElement('div');
484
+ panel.className = 'assembly-panel';
485
+ panel.style.cssText = `
486
+ width: 300px;
487
+ height: 100%;
488
+ background: #1e1e1e;
489
+ color: #e0e0e0;
490
+ font-family: Calibri, sans-serif;
491
+ font-size: 13px;
492
+ border-left: 1px solid #333;
493
+ overflow-y: auto;
494
+ padding: 12px;
495
+ `;
496
+
497
+ panel.innerHTML = `
498
+ <div style="margin-bottom: 16px;">
499
+ <h3 style="margin: 0 0 8px 0; color: #0284c7;">Assembly Editor</h3>
500
+
501
+ <div style="margin-bottom: 12px;">
502
+ <label style="display: block; margin-bottom: 4px;">Components (${this.state.components.size})</label>
503
+ <div id="component-list" style="max-height: 150px; overflow-y: auto; background: #252525; border-radius: 4px; padding: 8px;">
504
+ ${Array.from(this.state.components.values()).map(c => `
505
+ <div style="padding: 4px; margin-bottom: 4px; background: #333; border-radius: 2px; font-size: 11px;">
506
+ ${c.name}
507
+ <button data-comp-id="${c.id}" class="comp-delete" style="float: right; padding: 1px 6px; font-size: 10px;">X</button>
508
+ </div>
509
+ `).join('')}
510
+ </div>
511
+ </div>
512
+
513
+ <div style="margin-bottom: 12px;">
514
+ <label style="display: block; margin-bottom: 4px;">Joints (${this.state.joints.size})</label>
515
+ <div id="joint-list" style="max-height: 150px; overflow-y: auto; background: #252525; border-radius: 4px; padding: 8px;">
516
+ ${Array.from(this.state.joints.values()).map(j => `
517
+ <div style="padding: 4px; margin-bottom: 4px; background: #333; border-radius: 2px; font-size: 11px;">
518
+ ${j.type.toUpperCase()}
519
+ <input type="range" min="${j.min}" max="${j.max}" value="${j.value}"
520
+ data-joint-id="${j.id}" class="joint-slider"
521
+ style="width: 100%; margin-top: 4px; cursor: pointer;">
522
+ </div>
523
+ `).join('')}
524
+ </div>
525
+ </div>
526
+
527
+ <button id="btn-explode" style="width: 100%; padding: 6px; margin-bottom: 6px; background: #0284c7; color: white; border: none; border-radius: 4px; cursor: pointer;">
528
+ Explode (${(this.state.explodeFactor * 100).toFixed(0)}%)
529
+ </button>
530
+
531
+ <button id="btn-bom" style="width: 100%; padding: 6px; margin-bottom: 6px; background: #0284c7; color: white; border: none; border-radius: 4px; cursor: pointer;">
532
+ Generate BOM
533
+ </button>
534
+
535
+ <button id="btn-motion-study" style="width: 100%; padding: 6px; background: #0284c7; color: white; border: none; border-radius: 4px; cursor: pointer;">
536
+ Motion Study
537
+ </button>
538
+ </div>
539
+ `;
540
+
541
+ // Event handlers
542
+ const self = this;
543
+ panel.querySelectorAll('.joint-slider').forEach(slider => {
544
+ slider.addEventListener('input', (e) => {
545
+ const jointId = e.target.dataset.jointId;
546
+ const value = parseFloat(e.target.value);
547
+ self.animateJoint(jointId, value, 200);
548
+ });
549
+ });
550
+
551
+ panel.querySelector('#btn-explode').addEventListener('click', () => {
552
+ const factor = this.state.explodeFactor > 1 ? 1 : 1.5;
553
+ this.explode(factor);
554
+ });
555
+
556
+ panel.querySelector('#btn-bom').addEventListener('click', () => {
557
+ const bom = this.generateBOM();
558
+ console.table(bom);
559
+ });
560
+
561
+ panel.querySelector('#btn-motion-study').addEventListener('click', () => {
562
+ if (this.state.joints.size > 0) {
563
+ const joint = Array.from(this.state.joints.values())[0];
564
+ const study = this.motionStudy(joint.id, joint.min, joint.max, 30);
565
+ if (study) {
566
+ let step = 0;
567
+ const interval = setInterval(() => {
568
+ if (step >= study.positions.length) {
569
+ clearInterval(interval);
570
+ return;
571
+ }
572
+ this.motionStudyStep(step++);
573
+ }, 100);
574
+ }
575
+ }
576
+ });
577
+
578
+ return panel;
579
+ },
580
+ };
581
+
582
+ export default AssemblyModule;