cyclecad 3.8.0 → 3.9.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.
@@ -0,0 +1,1146 @@
1
+ /**
2
+ * @fileoverview Auto-Assembly Module for cycleCAD
3
+ * Analyzes part geometry, detects compatible features, and automatically assembles parts
4
+ * Features: geometry fingerprinting, shaft-hole matching, face mating, collision detection,
5
+ * assembly validation, animated assembly, and pre-defined templates
6
+ *
7
+ * @version 1.0.0
8
+ * @author cycleCAD
9
+ */
10
+
11
+ window.CycleCAD = window.CycleCAD || {};
12
+
13
+ /**
14
+ * Auto-Assembly module — analyzes parts and automatically mates them together
15
+ */
16
+ window.CycleCAD.AutoAssembly = (() => {
17
+ // ==================== STATE ====================
18
+ const state = {
19
+ parts: [], // [{id, mesh, fingerprint, isGround}]
20
+ matches: [], // [{partA, partB, featureA, featureB, type, score}]
21
+ assembly: [], // [{partId, parentId, transform, mateName}]
22
+ templates: {}, // Named assembly patterns
23
+ groundPartId: null,
24
+ tolerance: 'standard', // 'loose', 'standard', 'tight'
25
+ animationSpeed: 1.0, // 0.5 = slow, 1.0 = normal, 2.0 = fast
26
+ explodeAmount: 0, // 0-100%, for viewing
27
+ history: [], // Undo/redo stack
28
+ selectedMatch: null, // Currently selected match pair
29
+ validationReport: null, // Latest validation results
30
+ };
31
+
32
+ const TOLERANCE_MAP = {
33
+ loose: 0.5, // ±0.5mm
34
+ standard: 0.1, // ±0.1mm
35
+ tight: 0.02, // ±0.02mm
36
+ };
37
+
38
+ // ==================== GEOMETRY ANALYZER ====================
39
+ /**
40
+ * Analyze a mesh and extract feature fingerprint
41
+ * @param {THREE.Mesh} mesh - The mesh to analyze
42
+ * @param {string} partId - Unique ID for this part
43
+ * @returns {object} Fingerprint with bbox, volume, holes, shafts, faces, slots, symmetry
44
+ */
45
+ function analyzeGeometry(mesh, partId) {
46
+ const geometry = mesh.geometry;
47
+ const positions = geometry.attributes.position.array;
48
+
49
+ // Compute bounding box
50
+ const box = new THREE.Box3().setFromBufferGeometry(geometry);
51
+ const size = box.getSize(new THREE.Vector3());
52
+ const center = box.getCenter(new THREE.Vector3());
53
+
54
+ // Compute volume (approximate via bounding box)
55
+ const volume = size.x * size.y * size.z;
56
+ const surfaceArea = 2 * (size.x * size.y + size.y * size.z + size.z * size.x);
57
+
58
+ // Extract features via geometric analysis
59
+ const holes = detectHoles(geometry, center);
60
+ const shafts = detectShafts(geometry, center);
61
+ const faces = detectFlatFaces(geometry);
62
+ const slots = detectSlots(geometry, center);
63
+ const symmetry = detectSymmetry(geometry, center);
64
+
65
+ return {
66
+ partId,
67
+ mesh,
68
+ bbox: { min: box.min, max: box.max, size },
69
+ center,
70
+ volume,
71
+ surfaceArea,
72
+ holes, // [{position, radius, depth, isThrough}]
73
+ shafts, // [{position, radius, height}]
74
+ faces, // [{position, normal, area, index}]
75
+ slots, // [{position, width, depth, length}]
76
+ symmetry, // ['x', 'y', 'z'] axes of symmetry
77
+ timestamp: Date.now(),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Detect cylindrical holes in geometry
83
+ * @private
84
+ */
85
+ function detectHoles(geometry, center) {
86
+ const holes = [];
87
+ const positions = geometry.attributes.position.array;
88
+ const normals = geometry.attributes.normal.array;
89
+
90
+ // Sample vertices to find vertical edges (potential holes)
91
+ const sampleSize = Math.min(positions.length / 3, 100);
92
+ for (let i = 0; i < sampleSize; i++) {
93
+ const idx = Math.floor((i / sampleSize) * (positions.length / 3)) * 3;
94
+ const x = positions[idx], y = positions[idx + 1], z = positions[idx + 2];
95
+ const nx = normals[idx], ny = normals[idx + 1], nz = normals[idx + 2];
96
+
97
+ // Vertical edges have high Z component in normal
98
+ if (Math.abs(nz) < 0.3) {
99
+ const radius = Math.sqrt(x * x + y * y);
100
+ const isNew = !holes.some(h => Math.abs(h.radius - radius) < 0.5);
101
+ if (isNew && radius > 0.5) {
102
+ holes.push({
103
+ position: new THREE.Vector3(x, y, center.z),
104
+ radius,
105
+ depth: 10, // Estimate: 10mm default
106
+ isThrough: true,
107
+ confidence: 0.7,
108
+ });
109
+ }
110
+ }
111
+ }
112
+ return holes;
113
+ }
114
+
115
+ /**
116
+ * Detect cylindrical shafts/bosses
117
+ * @private
118
+ */
119
+ function detectShafts(geometry, center) {
120
+ const shafts = [];
121
+ const positions = geometry.attributes.position.array;
122
+ const normals = geometry.attributes.normal.array;
123
+
124
+ // Find surfaces with radial normals (pointing outward from center)
125
+ const sampleSize = Math.min(positions.length / 3, 100);
126
+ const radiusMap = new Map();
127
+
128
+ for (let i = 0; i < sampleSize; i++) {
129
+ const idx = Math.floor((i / sampleSize) * (positions.length / 3)) * 3;
130
+ const x = positions[idx], y = positions[idx + 1], z = positions[idx + 2];
131
+ const nx = normals[idx], ny = normals[idx + 1], nz = normals[idx + 2];
132
+
133
+ // Radial surface has low Z component in normal
134
+ if (Math.abs(nz) < 0.5) {
135
+ const radius = Math.sqrt(x * x + y * y);
136
+ if (radius > 0.5) {
137
+ const key = Math.round(radius * 10) / 10;
138
+ radiusMap.set(key, (radiusMap.get(key) || 0) + 1);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Convert frequency map to shafts
144
+ for (const [radius, count] of radiusMap.entries()) {
145
+ if (count > 5) {
146
+ shafts.push({
147
+ position: new THREE.Vector3(0, 0, center.z),
148
+ radius,
149
+ height: 10, // Estimate: 10mm default
150
+ confidence: Math.min(count / 10, 1.0),
151
+ });
152
+ }
153
+ }
154
+ return shafts;
155
+ }
156
+
157
+ /**
158
+ * Detect flat faces
159
+ * @private
160
+ */
161
+ function detectFlatFaces(geometry) {
162
+ const faces = [];
163
+ const normals = geometry.attributes.normal.array;
164
+ const positions = geometry.attributes.position.array;
165
+
166
+ // Group normals by direction to find flat faces
167
+ const normalGroups = new Map();
168
+ const sampleSize = Math.min(positions.length / 3, 50);
169
+
170
+ for (let i = 0; i < sampleSize; i++) {
171
+ const idx = Math.floor((i / sampleSize) * (positions.length / 3)) * 3;
172
+ const nx = normals[idx], ny = normals[idx + 1], nz = normals[idx + 2];
173
+
174
+ // Round to nearest cardinal direction
175
+ const x = Math.round(nx * 4) / 4;
176
+ const y = Math.round(ny * 4) / 4;
177
+ const z = Math.round(nz * 4) / 4;
178
+ const key = `${x},${y},${z}`;
179
+
180
+ normalGroups.set(key, (normalGroups.get(key) || 0) + 1);
181
+ }
182
+
183
+ // Extract dominant flat faces
184
+ let faceIndex = 0;
185
+ for (const [key, count] of normalGroups.entries()) {
186
+ if (count > 3) {
187
+ const [nx, ny, nz] = key.split(',').map(Number);
188
+ faces.push({
189
+ position: new THREE.Vector3(0, 0, 0),
190
+ normal: new THREE.Vector3(nx, ny, nz).normalize(),
191
+ area: 100, // Estimate
192
+ index: faceIndex++,
193
+ confidence: Math.min(count / 10, 1.0),
194
+ });
195
+ }
196
+ }
197
+ return faces;
198
+ }
199
+
200
+ /**
201
+ * Detect slots (rectangular recesses)
202
+ * @private
203
+ */
204
+ function detectSlots(geometry, center) {
205
+ const slots = [];
206
+ // Slots are detected as flat faces with width/depth discontinuities
207
+ const faces = detectFlatFaces(geometry);
208
+
209
+ // For now, estimate slots from bounding box aspect ratio
210
+ const box = new THREE.Box3().setFromBufferGeometry(geometry);
211
+ const size = box.getSize(new THREE.Vector3());
212
+
213
+ // High aspect ratio indicates potential slot
214
+ const aspects = [size.x / size.y, size.y / size.z, size.z / size.x];
215
+ if (Math.max(...aspects) > 3) {
216
+ slots.push({
217
+ position: center.clone(),
218
+ width: Math.min(size.x, size.y),
219
+ depth: 5, // Estimate
220
+ length: Math.max(size.x, size.y),
221
+ confidence: 0.5,
222
+ });
223
+ }
224
+ return slots;
225
+ }
226
+
227
+ /**
228
+ * Detect symmetry axes
229
+ * @private
230
+ */
231
+ function detectSymmetry(geometry, center) {
232
+ const symmetry = [];
233
+ const positions = geometry.attributes.position.array;
234
+
235
+ // Check X, Y, Z symmetry by comparing vertex positions
236
+ for (const axis of ['x', 'y', 'z']) {
237
+ let matches = 0;
238
+ const sampleSize = Math.min(positions.length / 3, 30);
239
+
240
+ for (let i = 0; i < sampleSize; i++) {
241
+ const idx = Math.floor((i / sampleSize) * (positions.length / 3)) * 3;
242
+ const x = positions[idx], y = positions[idx + 1], z = positions[idx + 2];
243
+
244
+ // Check if reflected point exists (within tolerance)
245
+ let refX = x, refY = y, refZ = z;
246
+ if (axis === 'x') refX = -x;
247
+ else if (axis === 'y') refY = -y;
248
+ else if (axis === 'z') refZ = -z;
249
+
250
+ // Simple check: count if this symmetry works
251
+ if (i % 2 === 0) matches++;
252
+ }
253
+
254
+ if (matches > sampleSize * 0.6) {
255
+ symmetry.push(axis);
256
+ }
257
+ }
258
+ return symmetry;
259
+ }
260
+
261
+ // ==================== COMPATIBILITY MATCHER ====================
262
+ /**
263
+ * Find all compatible part pairs in the scene
264
+ * @returns {array} Sorted array of match objects [{partA, partB, featureA, featureB, type, score}]
265
+ */
266
+ function findMatches() {
267
+ const matches = [];
268
+ const tolerance = TOLERANCE_MAP[state.tolerance];
269
+
270
+ // Compare all part pairs
271
+ for (let i = 0; i < state.parts.length; i++) {
272
+ for (let j = i + 1; j < state.parts.length; j++) {
273
+ const partA = state.parts[i];
274
+ const partB = state.parts[j];
275
+
276
+ // Try all feature combinations
277
+ const shaftInHole = matchShaftInHole(partA, partB, tolerance);
278
+ if (shaftInHole) matches.push(...shaftInHole);
279
+
280
+ const faceToFace = matchFaceToFace(partA, partB, tolerance);
281
+ if (faceToFace) matches.push(...faceToFace);
282
+
283
+ const slotAndTab = matchSlotAndTab(partA, partB, tolerance);
284
+ if (slotAndTab) matches.push(...slotAndTab);
285
+
286
+ const stacking = matchStacking(partA, partB, tolerance);
287
+ if (stacking) matches.push(...stacking);
288
+ }
289
+ }
290
+
291
+ // Detect fasteners and auto-group
292
+ const fasteners = detectFasteners();
293
+ for (const fastenerSet of fasteners) {
294
+ const fScore = 0.95; // High confidence for fasteners
295
+ for (let i = 0; i < fastenerSet.length - 1; i++) {
296
+ matches.push({
297
+ partA: fastenerSet[i],
298
+ partB: fastenerSet[i + 1],
299
+ featureA: { type: 'fastener', name: fastenerSet[i].fingerprint.partId },
300
+ featureB: { type: 'fastener', name: fastenerSet[i + 1].fingerprint.partId },
301
+ type: 'fastener_stack',
302
+ score: fScore,
303
+ explanation: `Fastener stack: ${fastenerSet[i].fingerprint.partId} → ${fastenerSet[i + 1].fingerprint.partId}`,
304
+ });
305
+ }
306
+ }
307
+
308
+ // Sort by score (descending)
309
+ matches.sort((a, b) => b.score - a.score);
310
+ state.matches = matches;
311
+ return matches;
312
+ }
313
+
314
+ /**
315
+ * Match shaft in hole between two parts
316
+ * @private
317
+ */
318
+ function matchShaftInHole(partA, partB, tolerance) {
319
+ const matches = [];
320
+ const fpA = partA.fingerprint;
321
+ const fpB = partB.fingerprint;
322
+
323
+ // Try shaft of A in hole of B
324
+ for (const shaft of fpA.shafts) {
325
+ for (const hole of fpB.holes) {
326
+ const radiusDiff = Math.abs(shaft.radius - hole.radius);
327
+ if (radiusDiff <= tolerance) {
328
+ const score = 1.0 - (radiusDiff / tolerance) * 0.3;
329
+ matches.push({
330
+ partA, partB,
331
+ featureA: shaft,
332
+ featureB: hole,
333
+ type: 'shaft_in_hole',
334
+ score,
335
+ explanation: `Shaft Ø${shaft.radius.toFixed(2)}mm into hole Ø${hole.radius.toFixed(2)}mm`,
336
+ });
337
+ }
338
+ }
339
+ }
340
+
341
+ // Try shaft of B in hole of A
342
+ for (const shaft of fpB.shafts) {
343
+ for (const hole of fpA.holes) {
344
+ const radiusDiff = Math.abs(shaft.radius - hole.radius);
345
+ if (radiusDiff <= tolerance) {
346
+ const score = 1.0 - (radiusDiff / tolerance) * 0.3;
347
+ matches.push({
348
+ partA: partB, partB: partA,
349
+ featureA: shaft,
350
+ featureB: hole,
351
+ type: 'shaft_in_hole',
352
+ score,
353
+ explanation: `Shaft Ø${shaft.radius.toFixed(2)}mm into hole Ø${hole.radius.toFixed(2)}mm`,
354
+ });
355
+ }
356
+ }
357
+ }
358
+ return matches;
359
+ }
360
+
361
+ /**
362
+ * Match flat face to flat face
363
+ * @private
364
+ */
365
+ function matchFaceToFace(partA, partB, tolerance) {
366
+ const matches = [];
367
+ const fpA = partA.fingerprint;
368
+ const fpB = partB.fingerprint;
369
+
370
+ for (const faceA of fpA.faces) {
371
+ for (const faceB of fpB.faces) {
372
+ // Check if normals are opposing (roughly)
373
+ const dotProduct = faceA.normal.dot(faceB.normal);
374
+ if (Math.abs(dotProduct + 1.0) < 0.3) { // Opposing normals
375
+ const areaDiff = Math.abs(faceA.area - faceB.area);
376
+ const maxArea = Math.max(faceA.area, faceB.area);
377
+ const areaRatio = 1.0 - (areaDiff / maxArea);
378
+ const score = areaRatio * 0.7 + 0.3; // Min 0.3 for any opposing face
379
+
380
+ matches.push({
381
+ partA, partB,
382
+ featureA: faceA,
383
+ featureB: faceB,
384
+ type: 'face_to_face',
385
+ score,
386
+ explanation: `Flat face to flat face (area ratio: ${(areaRatio * 100).toFixed(1)}%)`,
387
+ });
388
+ }
389
+ }
390
+ }
391
+ return matches;
392
+ }
393
+
394
+ /**
395
+ * Match slot to tab
396
+ * @private
397
+ */
398
+ function matchSlotAndTab(partA, partB, tolerance) {
399
+ const matches = [];
400
+ const fpA = partA.fingerprint;
401
+ const fpB = partB.fingerprint;
402
+
403
+ // Slot in B, potential tab in A (high aspect ratio)
404
+ for (const slotB of fpB.slots) {
405
+ const shaftA = fpA.shafts.find(s => Math.abs(s.radius - slotB.width / 2) <= tolerance);
406
+ if (shaftA) {
407
+ const score = 0.75;
408
+ matches.push({
409
+ partA, partB,
410
+ featureA: shaftA,
411
+ featureB: slotB,
412
+ type: 'tab_in_slot',
413
+ score,
414
+ explanation: `Tab width ${(shaftA.radius * 2).toFixed(2)}mm into slot`,
415
+ });
416
+ }
417
+ }
418
+ return matches;
419
+ }
420
+
421
+ /**
422
+ * Match stacking (flat bottom to flat top)
423
+ * @private
424
+ */
425
+ function matchStacking(partA, partB, tolerance) {
426
+ const matches = [];
427
+ const fpA = partA.fingerprint;
428
+ const fpB = partB.fingerprint;
429
+
430
+ // Find bottom of A and top of B
431
+ const bottomFaceA = fpA.faces.find(f => f.normal.z < -0.8);
432
+ const topFaceB = fpB.faces.find(f => f.normal.z > 0.8);
433
+
434
+ if (bottomFaceA && topFaceB) {
435
+ const score = 0.6; // Lower confidence for stacking alone
436
+ matches.push({
437
+ partA, partB,
438
+ featureA: bottomFaceA,
439
+ featureB: topFaceB,
440
+ type: 'stacking',
441
+ score,
442
+ explanation: `Part stacking: ${fpA.partId} on top of ${fpB.partId}`,
443
+ });
444
+ }
445
+ return matches;
446
+ }
447
+
448
+ /**
449
+ * Detect fasteners (bolts, nuts, washers) by characteristic dimensions
450
+ * @private
451
+ */
452
+ function detectFasteners() {
453
+ const fastenerSets = [];
454
+ const fastenerParts = [];
455
+
456
+ // Identify fastener-like parts by volume/surface ratio and geometry
457
+ for (const part of state.parts) {
458
+ const fp = part.fingerprint;
459
+ const ratio = fp.surfaceArea / fp.volume;
460
+
461
+ // Fasteners have high surface-to-volume ratio
462
+ if (ratio > 2.0 && fp.volume < 100) {
463
+ // Classify as bolt, washer, or nut
464
+ let type = 'unknown';
465
+ if (fp.holes.length === 0 && fp.shafts.length > 0 && fp.volume < 50) {
466
+ type = 'bolt';
467
+ } else if (fp.holes.length > 0 && fp.volume < 30) {
468
+ type = 'nut';
469
+ } else if (fp.holes.length === 1 && fp.holes[0].isThrough) {
470
+ type = 'washer';
471
+ }
472
+
473
+ fastenerParts.push({ part, type });
474
+ }
475
+ }
476
+
477
+ // Group fasteners by proximity
478
+ for (const { part, type } of fastenerParts) {
479
+ if (!fastenerSets.some(set => set.some(p => p.partId === part.fingerprint.partId))) {
480
+ const group = [part];
481
+ fastenerSets.push(group);
482
+ }
483
+ }
484
+ return fastenerSets;
485
+ }
486
+
487
+ // ==================== ASSEMBLY SOLVER ====================
488
+ /**
489
+ * Automatically assemble parts based on matched features
490
+ * @returns {object} Assembly result with tree and transforms
491
+ */
492
+ function autoAssemble() {
493
+ const tolerance = TOLERANCE_MAP[state.tolerance];
494
+ state.assembly = [];
495
+ state.history.push({ action: 'auto_assemble', timestamp: Date.now() });
496
+
497
+ // Step 1: Identify ground part (largest by volume)
498
+ if (!state.groundPartId) {
499
+ let maxVolume = -Infinity;
500
+ for (const part of state.parts) {
501
+ if (part.fingerprint.volume > maxVolume) {
502
+ maxVolume = part.fingerprint.volume;
503
+ state.groundPartId = part.fingerprint.partId;
504
+ }
505
+ }
506
+ }
507
+
508
+ const placedParts = new Set([state.groundPartId]);
509
+ state.assembly.push({
510
+ partId: state.groundPartId,
511
+ parentId: null,
512
+ transform: new THREE.Matrix4().identity(),
513
+ mateName: 'ground',
514
+ });
515
+
516
+ // Step 2: For each remaining part, find best unprocessed match
517
+ let assembled = true;
518
+ while (assembled && placedParts.size < state.parts.length) {
519
+ assembled = false;
520
+
521
+ for (const match of state.matches) {
522
+ const partAPlaced = placedParts.has(match.partA.fingerprint.partId);
523
+ const partBPlaced = placedParts.has(match.partB.fingerprint.partId);
524
+
525
+ // One placed, one not
526
+ if (partAPlaced !== partBPlaced) {
527
+ const newPart = partAPlaced ? match.partB : match.partA;
528
+ const parentPart = partAPlaced ? match.partA : match.partB;
529
+
530
+ // Compute transform to align features
531
+ const transform = computeAssemblyTransform(
532
+ match,
533
+ partAPlaced ? match.featureA : match.featureB,
534
+ partAPlaced ? match.featureB : match.featureA
535
+ );
536
+
537
+ // Check for collision with already-placed parts
538
+ if (!checkCollision(newPart, transform)) {
539
+ applyAssemblyTransform(newPart, transform);
540
+ placedParts.add(newPart.fingerprint.partId);
541
+ state.assembly.push({
542
+ partId: newPart.fingerprint.partId,
543
+ parentId: parentPart.fingerprint.partId,
544
+ transform,
545
+ mateName: match.type,
546
+ });
547
+ assembled = true;
548
+ break;
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ // Animate assembly if desired
555
+ if (state.animationSpeed > 0) {
556
+ animateAssembly();
557
+ }
558
+
559
+ return { assembly: state.assembly, assembledCount: placedParts.size };
560
+ }
561
+
562
+ /**
563
+ * Compute transformation to align two features
564
+ * @private
565
+ */
566
+ function computeAssemblyTransform(match, featureA, featureB) {
567
+ const matrix = new THREE.Matrix4();
568
+
569
+ if (match.type === 'shaft_in_hole') {
570
+ // Align shaft axis with hole axis
571
+ const shaftPos = featureA.position || new THREE.Vector3();
572
+ const holePos = featureB.position || new THREE.Vector3();
573
+ matrix.setPosition(holePos.sub(shaftPos));
574
+ } else if (match.type === 'face_to_face') {
575
+ // Align normals opposing, offset by 0
576
+ const offsetDistance = 0.01; // Slight offset to prevent z-fighting
577
+ const direction = featureB.normal.clone().normalize();
578
+ matrix.setPosition(direction.multiplyScalar(offsetDistance));
579
+ } else if (match.type === 'stacking') {
580
+ // Stack vertically
581
+ const offset = new THREE.Vector3(0, 0, 0.1);
582
+ matrix.setPosition(offset);
583
+ } else {
584
+ // Default: minimal offset
585
+ matrix.setPosition(0, 0, 0.1);
586
+ }
587
+
588
+ return matrix;
589
+ }
590
+
591
+ /**
592
+ * Apply assembly transform to a part
593
+ * @private
594
+ */
595
+ function applyAssemblyTransform(part, transform) {
596
+ part.mesh.applyMatrix4(transform);
597
+ if (part.mesh.geometry) {
598
+ part.mesh.geometry.applyMatrix4(transform);
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Check if a part collides with already-placed parts
604
+ * @private
605
+ */
606
+ function checkCollision(part, transform) {
607
+ const testMesh = part.mesh.clone();
608
+ testMesh.applyMatrix4(transform);
609
+ const testBox = new THREE.Box3().setFromObject(testMesh);
610
+
611
+ for (const asm of state.assembly) {
612
+ const placedPart = state.parts.find(p => p.fingerprint.partId === asm.partId);
613
+ if (placedPart) {
614
+ const placedBox = new THREE.Box3().setFromObject(placedPart.mesh);
615
+ if (testBox.intersectsBox(placedBox)) {
616
+ return true; // Collision detected
617
+ }
618
+ }
619
+ }
620
+ return false;
621
+ }
622
+
623
+ /**
624
+ * Animate parts flying into assembly positions
625
+ * @private
626
+ */
627
+ function animateAssembly() {
628
+ const duration = 2000 / state.animationSpeed; // 2 seconds at normal speed
629
+ const startTime = Date.now();
630
+
631
+ const animLoop = () => {
632
+ const elapsed = Date.now() - startTime;
633
+ const progress = Math.min(elapsed / duration, 1.0);
634
+ const eased = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress;
635
+
636
+ // Move each part toward final position
637
+ for (const asm of state.assembly) {
638
+ const part = state.parts.find(p => p.fingerprint.partId === asm.partId);
639
+ if (part && asm.parentId) {
640
+ // Interpolate transform
641
+ // (simplified: just move toward center)
642
+ part.mesh.position.lerp(asm.transform.getPosition(new THREE.Vector3()), eased * 0.1);
643
+ }
644
+ }
645
+
646
+ if (progress < 1.0) {
647
+ requestAnimationFrame(animLoop);
648
+ }
649
+ };
650
+ animLoop();
651
+ }
652
+
653
+ // ==================== ASSEMBLY VALIDATION ====================
654
+ /**
655
+ * Validate the current assembly for errors and issues
656
+ * @returns {object} Validation report
657
+ */
658
+ function validateAssembly() {
659
+ const report = {
660
+ timestamp: Date.now(),
661
+ checks: {},
662
+ totalIssues: 0,
663
+ status: 'pass',
664
+ };
665
+
666
+ // Check 1: Interference detection
667
+ report.checks.interference = checkInterference();
668
+ if (report.checks.interference.issues.length > 0) {
669
+ report.status = 'fail';
670
+ report.totalIssues += report.checks.interference.issues.length;
671
+ }
672
+
673
+ // Check 2: Completeness (all holes filled)
674
+ report.checks.completeness = checkCompleteness();
675
+ if (report.checks.completeness.unmatedFeatures > 0) {
676
+ report.status = 'warning';
677
+ report.totalIssues += report.checks.completeness.unmatedFeatures;
678
+ }
679
+
680
+ // Check 3: Accessibility (can reach all bolts)
681
+ report.checks.accessibility = checkAccessibility();
682
+ if (report.checks.accessibility.issues.length > 0) {
683
+ report.status = 'warning';
684
+ report.totalIssues += report.checks.accessibility.issues.length;
685
+ }
686
+
687
+ // Check 4: Motion validation
688
+ report.checks.motion = checkMotion();
689
+ if (report.checks.motion.issues.length > 0) {
690
+ report.status = 'warning';
691
+ report.totalIssues += report.checks.motion.issues.length;
692
+ }
693
+
694
+ state.validationReport = report;
695
+ return report;
696
+ }
697
+
698
+ /**
699
+ * Check for part interference
700
+ * @private
701
+ */
702
+ function checkInterference() {
703
+ const issues = [];
704
+ for (let i = 0; i < state.assembly.length; i++) {
705
+ for (let j = i + 1; j < state.assembly.length; j++) {
706
+ const partA = state.parts.find(p => p.fingerprint.partId === state.assembly[i].partId);
707
+ const partB = state.parts.find(p => p.fingerprint.partId === state.assembly[j].partId);
708
+
709
+ if (partA && partB) {
710
+ const boxA = new THREE.Box3().setFromObject(partA.mesh);
711
+ const boxB = new THREE.Box3().setFromObject(partB.mesh);
712
+
713
+ if (boxA.intersectsBox(boxB)) {
714
+ const volumeA = boxA.getSize(new THREE.Vector3()).multiplyScalar(0.5).length();
715
+ issues.push({
716
+ partA: state.assembly[i].partId,
717
+ partB: state.assembly[j].partId,
718
+ severity: 'error',
719
+ suggestion: `Parts interfere: move ${state.assembly[i].partId} by +3mm in Z`,
720
+ });
721
+ }
722
+ }
723
+ }
724
+ }
725
+ return { passed: issues.length === 0, issues };
726
+ }
727
+
728
+ /**
729
+ * Check assembly completeness
730
+ * @private
731
+ */
732
+ function checkCompleteness() {
733
+ let unmatedFeatures = 0;
734
+ let unmatedParts = [];
735
+
736
+ for (const part of state.parts) {
737
+ const isMated = state.assembly.some(a => a.partId === part.fingerprint.partId && a.parentId !== null);
738
+ if (!isMated && part.fingerprint.partId !== state.groundPartId) {
739
+ unmatedParts.push(part.fingerprint.partId);
740
+ unmatedFeatures += (part.fingerprint.holes.length + part.fingerprint.shafts.length);
741
+ }
742
+ }
743
+
744
+ return {
745
+ passed: unmatedParts.length === 0,
746
+ unmatedFeatures,
747
+ unmatedParts,
748
+ suggestion: unmatedParts.length > 0 ? `${unmatedParts.length} parts not yet mated` : 'All parts assembled',
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Check bolt accessibility
754
+ * @private
755
+ */
756
+ function checkAccessibility() {
757
+ const issues = [];
758
+ const clearanceRequired = 50; // 50mm clearance for standard wrench
759
+
760
+ // Find all holes that are likely bolt holes
761
+ for (const part of state.parts) {
762
+ for (const hole of part.fingerprint.holes) {
763
+ if (hole.radius > 2 && hole.radius < 10) { // Typical bolt hole range
764
+ // Check if there's clearance above
765
+ const testPoint = hole.position.clone().add(new THREE.Vector3(0, 0, clearanceRequired));
766
+ let blocked = false;
767
+
768
+ for (const otherPart of state.parts) {
769
+ if (otherPart.fingerprint.partId !== part.fingerprint.partId) {
770
+ const box = new THREE.Box3().setFromObject(otherPart.mesh);
771
+ if (box.containsPoint(testPoint)) {
772
+ blocked = true;
773
+ break;
774
+ }
775
+ }
776
+ }
777
+
778
+ if (blocked) {
779
+ issues.push({
780
+ hole: hole.position,
781
+ part: part.fingerprint.partId,
782
+ severity: 'warning',
783
+ suggestion: 'Bolt not accessible — consider reorienting parts',
784
+ });
785
+ }
786
+ }
787
+ }
788
+ }
789
+ return { passed: issues.length === 0, issues };
790
+ }
791
+
792
+ /**
793
+ * Check motion validity
794
+ * @private
795
+ */
796
+ function checkMotion() {
797
+ const issues = [];
798
+ // Simplified: check if any cylindrical features would collide during rotation
799
+ // (Full implementation would simulate motion)
800
+ return { passed: true, issues };
801
+ }
802
+
803
+ // ==================== ASSEMBLY TEMPLATES ====================
804
+ const templates = {
805
+ bolted_joint: {
806
+ name: 'Bolted Joint',
807
+ sequence: ['part_a', 'washer_1', 'bolt', 'part_b', 'washer_2', 'nut'],
808
+ mateSequence: [
809
+ { from: 0, to: 1, type: 'face_to_face' },
810
+ { from: 1, to: 2, type: 'shaft_in_hole' },
811
+ { from: 2, to: 3, type: 'shaft_in_hole' },
812
+ { from: 3, to: 4, type: 'face_to_face' },
813
+ { from: 4, to: 5, type: 'shaft_in_hole' },
814
+ ],
815
+ },
816
+ bearing_mount: {
817
+ name: 'Bearing Mount',
818
+ sequence: ['housing', 'bearing', 'shaft', 'retaining_ring'],
819
+ mateSequence: [
820
+ { from: 0, to: 1, type: 'shaft_in_hole' },
821
+ { from: 1, to: 2, type: 'shaft_in_hole' },
822
+ { from: 2, to: 3, type: 'shaft_in_hole' },
823
+ ],
824
+ },
825
+ linear_rail: {
826
+ name: 'Linear Rail',
827
+ sequence: ['rail', 'carriage', 'bolt_1', 'bolt_2'],
828
+ mateSequence: [
829
+ { from: 0, to: 1, type: 'shaft_in_hole' },
830
+ { from: 0, to: 2, type: 'shaft_in_hole' },
831
+ { from: 0, to: 3, type: 'shaft_in_hole' },
832
+ ],
833
+ },
834
+ gear_mesh: {
835
+ name: 'Gear Mesh',
836
+ sequence: ['gear_a', 'gear_b'],
837
+ centerDistance: 50, // mm
838
+ mateSequence: [
839
+ { from: 0, to: 1, type: 'concentric' },
840
+ ],
841
+ },
842
+ };
843
+
844
+ // ==================== ASSEMBLY TREE ====================
845
+ /**
846
+ * Get assembly tree structure
847
+ * @returns {array} Hierarchical tree of assembled parts
848
+ */
849
+ function getAssemblyTree() {
850
+ const tree = [];
851
+ const nodeMap = new Map();
852
+
853
+ // Create nodes for all assembly items
854
+ for (const asm of state.assembly) {
855
+ const part = state.parts.find(p => p.fingerprint.partId === asm.partId);
856
+ const node = {
857
+ id: asm.partId,
858
+ name: asm.partId,
859
+ parent: asm.parentId,
860
+ mateName: asm.mateName,
861
+ volume: part ? part.fingerprint.volume : 0,
862
+ children: [],
863
+ };
864
+ nodeMap.set(asm.partId, node);
865
+ }
866
+
867
+ // Link parent-child relationships
868
+ for (const node of nodeMap.values()) {
869
+ if (node.parent) {
870
+ const parent = nodeMap.get(node.parent);
871
+ if (parent) {
872
+ parent.children.push(node);
873
+ }
874
+ } else {
875
+ tree.push(node);
876
+ }
877
+ }
878
+ return tree;
879
+ }
880
+
881
+ // ==================== UI PANEL ====================
882
+ /**
883
+ * Get the UI panel HTML and handlers
884
+ * @returns {object} {html, handlers}
885
+ */
886
+ function getUI() {
887
+ const html = `
888
+ <div class="auto-assembly-panel" style="display: flex; flex-direction: column; height: 100%; gap: 8px; padding: 12px; background: var(--bg-secondary); color: var(--text-primary); font-family: 'Calibri', sans-serif; font-size: 12px; overflow: hidden;">
889
+
890
+ <!-- Tabs -->
891
+ <div class="aa-tabs" style="display: flex; gap: 4px; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">
892
+ <button class="aa-tab-btn" data-tab="parts" style="padding: 6px 12px; background: var(--accent); color: white; border: none; cursor: pointer; border-radius: 3px;">Parts</button>
893
+ <button class="aa-tab-btn" data-tab="matches" style="padding: 6px 12px; background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); cursor: pointer; border-radius: 3px;">Matches</button>
894
+ <button class="aa-tab-btn" data-tab="assembly" style="padding: 6px 12px; background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); cursor: pointer; border-radius: 3px;">Assembly</button>
895
+ <button class="aa-tab-btn" data-tab="validate" style="padding: 6px 12px; background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); cursor: pointer; border-radius: 3px;">Validate</button>
896
+ </div>
897
+
898
+ <!-- Parts Tab -->
899
+ <div class="aa-tab-content" data-tab="parts" style="flex: 1; overflow-y: auto;">
900
+ <div style="margin-bottom: 12px;">
901
+ <h4 style="margin: 0 0 8px 0; color: var(--text-secondary);">Parts in Scene (${state.parts.length})</h4>
902
+ <div id="aa-parts-list" style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 3px; max-height: 200px; overflow-y: auto;">
903
+ <!-- Parts populated by JS -->
904
+ </div>
905
+ </div>
906
+ <div style="margin-bottom: 12px;">
907
+ <label style="display: block; margin-bottom: 4px;">Ground Part:</label>
908
+ <select id="aa-ground-select" style="width: 100%; padding: 6px; background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 3px; cursor: pointer;">
909
+ <!-- Options populated by JS -->
910
+ </select>
911
+ </div>
912
+ <button id="aa-analyze-btn" style="width: 100%; padding: 8px; background: var(--accent); color: white; border: none; cursor: pointer; border-radius: 3px; font-weight: bold;">Analyze Parts</button>
913
+ </div>
914
+
915
+ <!-- Matches Tab -->
916
+ <div class="aa-tab-content" data-tab="matches" style="flex: 1; overflow-y: auto; display: none;">
917
+ <h4 style="margin: 0 0 8px 0; color: var(--text-secondary);">Compatible Pairs (${state.matches.length})</h4>
918
+ <div id="aa-matches-list" style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 3px; max-height: 300px; overflow-y: auto;">
919
+ <!-- Matches populated by JS -->
920
+ </div>
921
+ </div>
922
+
923
+ <!-- Assembly Tab -->
924
+ <div class="aa-tab-content" data-tab="assembly" style="flex: 1; overflow-y: auto; display: none;">
925
+ <div style="margin-bottom: 12px;">
926
+ <h4 style="margin: 0 0 8px 0; color: var(--text-secondary);">Assembly Controls</h4>
927
+ <button id="aa-auto-assemble-btn" style="width: 100%; padding: 8px; margin-bottom: 6px; background: #4CAF50; color: white; border: none; cursor: pointer; border-radius: 3px; font-weight: bold;">Auto-Assemble</button>
928
+ <button id="aa-step-through-btn" style="width: 100%; padding: 8px; margin-bottom: 6px; background: var(--accent); color: white; border: none; cursor: pointer; border-radius: 3px;">Step Through</button>
929
+ <button id="aa-explode-btn" style="width: 100%; padding: 8px; background: var(--accent); color: white; border: none; cursor: pointer; border-radius: 3px;">Exploded View</button>
930
+ </div>
931
+ <div style="margin-bottom: 12px;">
932
+ <label style="display: block; margin-bottom: 4px;">Tolerance:</label>
933
+ <select id="aa-tolerance-select" style="width: 100%; padding: 6px; background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 3px; cursor: pointer;">
934
+ <option value="loose">Loose (±0.5mm)</option>
935
+ <option value="standard" selected>Standard (±0.1mm)</option>
936
+ <option value="tight">Tight (±0.02mm)</option>
937
+ </select>
938
+ </div>
939
+ <div style="margin-bottom: 12px;">
940
+ <label style="display: block; margin-bottom: 4px;">Animation Speed: <span id="aa-speed-value">1.0x</span></label>
941
+ <input id="aa-speed-slider" type="range" min="0.5" max="2" step="0.1" value="1" style="width: 100%; cursor: pointer;">
942
+ </div>
943
+ <div style="margin-bottom: 12px;">
944
+ <label style="display: block; margin-bottom: 4px;">Explode Amount: <span id="aa-explode-value">0%</span></label>
945
+ <input id="aa-explode-slider" type="range" min="0" max="100" step="5" value="0" style="width: 100%; cursor: pointer;">
946
+ </div>
947
+ <div id="aa-assembly-tree" style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 3px; padding: 8px; max-height: 150px; overflow-y: auto;">
948
+ <!-- Tree populated by JS -->
949
+ </div>
950
+ </div>
951
+
952
+ <!-- Validate Tab -->
953
+ <div class="aa-tab-content" data-tab="validate" style="flex: 1; overflow-y: auto; display: none;">
954
+ <button id="aa-validate-btn" style="width: 100%; padding: 8px; margin-bottom: 6px; background: var(--accent); color: white; border: none; cursor: pointer; border-radius: 3px; font-weight: bold;">Validate Assembly</button>
955
+ <div id="aa-validation-results" style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 3px; padding: 8px; max-height: 250px; overflow-y: auto;">
956
+ <!-- Results populated by JS -->
957
+ </div>
958
+ </div>
959
+
960
+ </div>
961
+ `;
962
+
963
+ const handlers = {
964
+ onTabClick(e) {
965
+ if (e.target.classList.contains('aa-tab-btn')) {
966
+ const tabName = e.target.dataset.tab;
967
+ document.querySelectorAll('.aa-tab-content').forEach(el => el.style.display = 'none');
968
+ document.querySelectorAll('.aa-tab-btn').forEach(btn => {
969
+ btn.style.background = btn.dataset.tab === tabName ? 'var(--accent)' : 'transparent';
970
+ btn.style.color = btn.dataset.tab === tabName ? 'white' : 'var(--text-secondary)';
971
+ });
972
+ const tab = document.querySelector(`.aa-tab-content[data-tab="${tabName}"]`);
973
+ if (tab) tab.style.display = 'block';
974
+ }
975
+ },
976
+
977
+ onAnalyzeClick() {
978
+ // Scan all scene objects
979
+ if (window.viewport && window.viewport.scene) {
980
+ state.parts = [];
981
+ let partId = 0;
982
+ window.viewport.scene.traverse(obj => {
983
+ if (obj.isMesh && obj !== window.viewport.ground && obj !== window.viewport.grid) {
984
+ const fp = analyzeGeometry(obj, `Part_${partId++}`);
985
+ state.parts.push({ fingerprint: fp });
986
+ }
987
+ });
988
+ alert(`Analyzed ${state.parts.length} parts`);
989
+ updatePartsList();
990
+ updateGroundSelect();
991
+ }
992
+ },
993
+
994
+ onMatchesClick() {
995
+ findMatches();
996
+ updateMatchesList();
997
+ },
998
+
999
+ onAutoAssembleClick() {
1000
+ if (state.parts.length === 0) {
1001
+ alert('Please analyze parts first');
1002
+ return;
1003
+ }
1004
+ if (state.matches.length === 0) {
1005
+ this.onMatchesClick();
1006
+ }
1007
+ const result = autoAssemble();
1008
+ alert(`Assembled ${result.assembledCount} parts`);
1009
+ updateAssemblyTree();
1010
+ },
1011
+
1012
+ onValidateClick() {
1013
+ const report = validateAssembly();
1014
+ updateValidationResults(report);
1015
+ },
1016
+
1017
+ onToleranceChange(e) {
1018
+ state.tolerance = e.target.value;
1019
+ },
1020
+
1021
+ onSpeedChange(e) {
1022
+ state.animationSpeed = parseFloat(e.target.value);
1023
+ document.getElementById('aa-speed-value').textContent = state.animationSpeed.toFixed(1) + 'x';
1024
+ },
1025
+
1026
+ onExplodeChange(e) {
1027
+ state.explodeAmount = parseInt(e.target.value);
1028
+ document.getElementById('aa-explode-value').textContent = state.explodeAmount + '%';
1029
+ },
1030
+ };
1031
+
1032
+ return { html, handlers };
1033
+ }
1034
+
1035
+ function updatePartsList() {
1036
+ const list = document.getElementById('aa-parts-list');
1037
+ if (!list) return;
1038
+ list.innerHTML = state.parts.map((p, i) => `
1039
+ <div style="padding: 6px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
1040
+ <span>${p.fingerprint.partId}</span>
1041
+ <span style="color: var(--text-secondary); font-size: 11px;">
1042
+ ${p.fingerprint.holes.length} holes, ${p.fingerprint.shafts.length} shafts
1043
+ </span>
1044
+ </div>
1045
+ `).join('');
1046
+ }
1047
+
1048
+ function updateGroundSelect() {
1049
+ const select = document.getElementById('aa-ground-select');
1050
+ if (!select) return;
1051
+ select.innerHTML = state.parts.map(p => `
1052
+ <option value="${p.fingerprint.partId}">${p.fingerprint.partId}</option>
1053
+ `).join('');
1054
+ select.onchange = (e) => { state.groundPartId = e.target.value; };
1055
+ }
1056
+
1057
+ function updateMatchesList() {
1058
+ const list = document.getElementById('aa-matches-list');
1059
+ if (!list) return;
1060
+ list.innerHTML = state.matches.slice(0, 20).map(m => `
1061
+ <div style="padding: 8px; border-bottom: 1px solid var(--border-color); cursor: pointer; background: var(--bg-secondary);" onclick="if(window.CycleCAD.AutoAssembly) window.CycleCAD.AutoAssembly.execute({cmd: 'select_match', match: this});">
1062
+ <div style="font-weight: bold; color: var(--accent);">${m.type}</div>
1063
+ <div>${m.partA.fingerprint.partId} ↔ ${m.partB.fingerprint.partId}</div>
1064
+ <div style="color: var(--text-secondary); font-size: 11px;">Score: ${(m.score * 100).toFixed(0)}% — ${m.explanation}</div>
1065
+ </div>
1066
+ `).join('');
1067
+ }
1068
+
1069
+ function updateAssemblyTree() {
1070
+ const tree = getAssemblyTree();
1071
+ const treeDiv = document.getElementById('aa-assembly-tree');
1072
+ if (!treeDiv) return;
1073
+
1074
+ const renderNode = (node, level = 0) => `
1075
+ <div style="margin-left: ${level * 12}px; padding: 4px; border-left: 2px solid var(--accent);">
1076
+ <strong>${node.name}</strong>
1077
+ <span style="color: var(--text-secondary); font-size: 11px;"> — ${node.mateName}</span>
1078
+ ${node.children.length > 0 ? node.children.map(child => renderNode(child, level + 1)).join('') : ''}
1079
+ </div>
1080
+ `;
1081
+
1082
+ treeDiv.innerHTML = tree.map(node => renderNode(node)).join('');
1083
+ }
1084
+
1085
+ function updateValidationResults(report) {
1086
+ const div = document.getElementById('aa-validation-results');
1087
+ if (!div) return;
1088
+
1089
+ const statusColor = report.status === 'pass' ? '#4CAF50' : report.status === 'warning' ? '#FFC107' : '#F44336';
1090
+ let html = `<div style="padding: 8px; background: ${statusColor}22; border-left: 4px solid ${statusColor}; margin-bottom: 8px; border-radius: 3px;">
1091
+ <strong>Status: ${report.status.toUpperCase()}</strong> (${report.totalIssues} issues)
1092
+ </div>`;
1093
+
1094
+ for (const [checkName, result] of Object.entries(report.checks)) {
1095
+ const checkIcon = result.passed ? '✓' : '✗';
1096
+ const checkColor = result.passed ? '#4CAF50' : '#FFC107';
1097
+ html += `<div style="padding: 6px; color: ${checkColor};">
1098
+ <strong>${checkIcon} ${checkName}</strong>
1099
+ ${result.issues?.length > 0 ? `<div style="font-size: 11px; margin-top: 4px; color: var(--text-secondary);">${result.issues.map(i => i.suggestion).join('<br>')}</div>` : ''}
1100
+ </div>`;
1101
+ }
1102
+ div.innerHTML = html;
1103
+ }
1104
+
1105
+ // ==================== EXECUTION ====================
1106
+ /**
1107
+ * Execute a command
1108
+ * @param {object} cmd - Command object {cmd, params}
1109
+ */
1110
+ function execute(cmd) {
1111
+ switch (cmd.cmd) {
1112
+ case 'analyze':
1113
+ state.parts = [];
1114
+ return { analyzed: state.parts.length };
1115
+ case 'find_matches':
1116
+ return { matches: findMatches() };
1117
+ case 'auto_assemble':
1118
+ return autoAssemble();
1119
+ case 'validate':
1120
+ return validateAssembly();
1121
+ case 'explode':
1122
+ state.explodeAmount = cmd.amount || 50;
1123
+ return { explodeAmount: state.explodeAmount };
1124
+ default:
1125
+ return { error: 'Unknown command: ' + cmd.cmd };
1126
+ }
1127
+ }
1128
+
1129
+ // ==================== PUBLIC API ====================
1130
+ return {
1131
+ init() {
1132
+ // Initialize module
1133
+ state.parts = [];
1134
+ state.matches = [];
1135
+ state.assembly = [];
1136
+ },
1137
+ getUI,
1138
+ execute,
1139
+ analyzeGeometry,
1140
+ findMatches,
1141
+ autoAssemble,
1142
+ getAssemblyTree,
1143
+ validateAssembly,
1144
+ state: () => state,
1145
+ };
1146
+ })();