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,1667 @@
1
+ /**
2
+ * @fileoverview Smart Assembly Mating Module for cycleCAD
3
+ *
4
+ * Provides intelligent assembly constraint system with:
5
+ * - Surface & feature detection (planar, cylindrical, spherical, conical)
6
+ * - 8 mate constraint types (coincident, concentric, tangent, parallel, perpendicular, distance, angle, gear)
7
+ * - Auto-mating with drag-to-snap, ghost previews, and confidence scoring
8
+ * - Motion studies with joint types (revolute, prismatic, cylindrical, ball, planar)
9
+ * - Assembly tree with constraint visualization
10
+ * - Full UI panel with mate wizard, motion playback, explode slider
11
+ *
12
+ * Exports: window.CycleCAD.SmartAssembly
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ window.CycleCAD = window.CycleCAD || {};
18
+
19
+ const SmartAssembly = (() => {
20
+ // =========================================================================
21
+ // STATE & CONFIG
22
+ // =========================================================================
23
+
24
+ let assemblyTree = {
25
+ name: 'Assembly',
26
+ parts: [],
27
+ constraints: [],
28
+ motionStudies: [],
29
+ subassemblies: []
30
+ };
31
+
32
+ let selectedParts = [];
33
+ let detectedFeatures = new Map(); // Map<partId, Feature[]>
34
+ let constraints = [];
35
+ let motionStudies = [];
36
+ let activeMotionStudy = null;
37
+ let draggedPart = null;
38
+ let ghostPreview = null;
39
+ let autoMateThreshold = 0.75;
40
+ let explodeAmount = 0; // 0-100%
41
+ let constraintVisualsEnabled = true;
42
+
43
+ const CONSTRAINT_TYPES = {
44
+ COINCIDENT: 'coincident',
45
+ CONCENTRIC: 'concentric',
46
+ TANGENT: 'tangent',
47
+ PARALLEL: 'parallel',
48
+ PERPENDICULAR: 'perpendicular',
49
+ DISTANCE: 'distance',
50
+ ANGLE: 'angle',
51
+ GEAR: 'gear'
52
+ };
53
+
54
+ const FEATURE_TYPES = {
55
+ MOUNTING_HOLE: 'mounting_hole',
56
+ SHAFT: 'shaft',
57
+ BORE: 'bore',
58
+ FLAT_FACE: 'flat_face',
59
+ SLOT: 'slot',
60
+ KEYWAY: 'keyway',
61
+ THREAD: 'thread',
62
+ BOSS: 'boss',
63
+ POCKET: 'pocket',
64
+ CYLINDER: 'cylinder',
65
+ SPHERE: 'sphere',
66
+ CONE: 'cone'
67
+ };
68
+
69
+ const JOINT_TYPES = {
70
+ REVOLUTE: 'revolute',
71
+ PRISMATIC: 'prismatic',
72
+ CYLINDRICAL: 'cylindrical',
73
+ BALL: 'ball',
74
+ PLANAR: 'planar'
75
+ };
76
+
77
+ // =========================================================================
78
+ // SURFACE & FEATURE DETECTION
79
+ // =========================================================================
80
+
81
+ /**
82
+ * Analyze mesh geometry to detect surfaces and features
83
+ * @param {THREE.Mesh} mesh - Mesh to analyze
84
+ * @param {string} partId - Part identifier
85
+ * @returns {Array} Array of detected features
86
+ */
87
+ function detectFeatures(mesh, partId) {
88
+ const features = [];
89
+ const geometry = mesh.geometry;
90
+
91
+ if (!geometry.attributes.position || !geometry.attributes.normal) {
92
+ return features;
93
+ }
94
+
95
+ const positions = geometry.attributes.position.array;
96
+ const normals = geometry.attributes.normal.array;
97
+ const faces = [];
98
+
99
+ // Group vertices into faces
100
+ const vertexCount = positions.length / 3;
101
+ for (let i = 0; i < vertexCount; i += 3) {
102
+ const v0 = new THREE.Vector3(positions[i*3], positions[i*3+1], positions[i*3+2]);
103
+ const v1 = new THREE.Vector3(positions[(i+1)*3], positions[(i+1)*3+1], positions[(i+1)*3+2]);
104
+ const v2 = new THREE.Vector3(positions[(i+2)*3], positions[(i+2)*3+1], positions[(i+2)*3+2]);
105
+ const n = new THREE.Vector3(normals[i*3], normals[i*3+1], normals[i*3+2]);
106
+
107
+ faces.push({ v0, v1, v2, normal: n, vertices: [v0, v1, v2] });
108
+ }
109
+
110
+ // Detect planar faces (group by normal direction)
111
+ const planarGroups = groupFacesByNormal(faces);
112
+ planarGroups.forEach((group, idx) => {
113
+ if (group.length > 10) {
114
+ const avgNormal = group[0].normal.clone();
115
+ const centroid = computeCentroid(group.map(f => f.v0));
116
+ features.push({
117
+ type: FEATURE_TYPES.FLAT_FACE,
118
+ id: `flat_${idx}`,
119
+ position: centroid,
120
+ normal: avgNormal,
121
+ radius: 0,
122
+ depth: 0,
123
+ confidence: Math.min(1, group.length / 50),
124
+ faces: group
125
+ });
126
+ }
127
+ });
128
+
129
+ // Detect cylindrical features (holes, shafts)
130
+ const cylindrical = detectCylindrical(faces, mesh.geometry.boundingBox);
131
+ cylindrical.forEach(cyl => {
132
+ features.push(cyl);
133
+ });
134
+
135
+ // Detect spherical features
136
+ const spheres = detectSpherical(faces);
137
+ spheres.forEach(sphere => {
138
+ features.push(sphere);
139
+ });
140
+
141
+ // Classify features into semantic types
142
+ classifyFeatures(features, mesh);
143
+
144
+ detectedFeatures.set(partId, features);
145
+ return features;
146
+ }
147
+
148
+ /**
149
+ * Group faces by similar normal direction
150
+ * @param {Array} faces - Array of face objects with normal property
151
+ * @returns {Map} Map of normal vectors to grouped faces
152
+ */
153
+ function groupFacesByNormal(faces) {
154
+ const groups = [];
155
+ const threshold = 0.1; // Normal dot product threshold
156
+
157
+ faces.forEach(face => {
158
+ let found = false;
159
+ for (let group of groups) {
160
+ if (group[0].normal.dot(face.normal) > 1 - threshold) {
161
+ group.push(face);
162
+ found = true;
163
+ break;
164
+ }
165
+ }
166
+ if (!found) {
167
+ groups.push([face]);
168
+ }
169
+ });
170
+
171
+ return groups;
172
+ }
173
+
174
+ /**
175
+ * Detect cylindrical features (holes, shafts, bores)
176
+ * @param {Array} faces - Array of face objects
177
+ * @param {THREE.Box3} boundingBox - Mesh bounding box
178
+ * @returns {Array} Array of cylindrical features
179
+ */
180
+ function detectCylindrical(faces, boundingBox) {
181
+ const features = [];
182
+ const size = boundingBox.getSize(new THREE.Vector3());
183
+ const center = boundingBox.getCenter(new THREE.Vector3());
184
+
185
+ // Check for circular edge loops
186
+ const edgeLoops = extractEdgeLoops(faces);
187
+
188
+ edgeLoops.forEach((loop, idx) => {
189
+ if (loop.vertices.length < 8) return; // Need at least 8 points for circle
190
+
191
+ const { center: circleCenter, radius, axis } = fitCircle(loop.vertices);
192
+ const confidence = loop.vertices.length / 100;
193
+
194
+ // Determine if hole (cavity) or shaft based on depth
195
+ const depth = estimateDepth(loop.vertices, axis);
196
+ const type = depth > radius * 0.5 ? FEATURE_TYPES.MOUNTING_HOLE : FEATURE_TYPES.SHAFT;
197
+
198
+ features.push({
199
+ type,
200
+ id: `cyl_${idx}`,
201
+ position: circleCenter,
202
+ normal: axis,
203
+ radius,
204
+ depth,
205
+ confidence: Math.min(1, confidence),
206
+ edgeLoop: loop
207
+ });
208
+ });
209
+
210
+ return features;
211
+ }
212
+
213
+ /**
214
+ * Detect spherical features
215
+ * @param {Array} faces - Array of face objects
216
+ * @returns {Array} Array of spherical features
217
+ */
218
+ function detectSpherical(faces) {
219
+ const features = [];
220
+
221
+ // Simple heuristic: check for radial normal pattern
222
+ const sphericalFaces = faces.filter(f => {
223
+ return faces.filter(f2 => f2.normal.dot(f.normal) > 0.9).length < 3;
224
+ });
225
+
226
+ if (sphericalFaces.length > 20) {
227
+ const centroid = computeCentroid(sphericalFaces.map(f => f.v0));
228
+ const radius = sphericalFaces[0].v0.distanceTo(centroid);
229
+
230
+ features.push({
231
+ type: FEATURE_TYPES.SPHERE,
232
+ id: 'sphere_0',
233
+ position: centroid,
234
+ normal: new THREE.Vector3(0, 0, 1),
235
+ radius,
236
+ depth: radius * 2,
237
+ confidence: 0.6,
238
+ faces: sphericalFaces
239
+ });
240
+ }
241
+
242
+ return features;
243
+ }
244
+
245
+ /**
246
+ * Extract circular edge loops from faces
247
+ * @param {Array} faces - Array of face objects
248
+ * @returns {Array} Array of edge loops
249
+ */
250
+ function extractEdgeLoops(faces) {
251
+ const loops = [];
252
+ const edges = new Map();
253
+
254
+ // Build edge map
255
+ faces.forEach(face => {
256
+ const e1 = `${face.v0.x},${face.v0.y},${face.v0.z}-${face.v1.x},${face.v1.y},${face.v1.z}`;
257
+ const e2 = `${face.v1.x},${face.v1.y},${face.v1.z}-${face.v2.x},${face.v2.y},${face.v2.z}`;
258
+ const e3 = `${face.v2.x},${face.v2.y},${face.v2.z}-${face.v0.x},${face.v0.y},${face.v0.z}`;
259
+
260
+ [e1, e2, e3].forEach(e => {
261
+ edges.set(e, (edges.get(e) || 0) + 1);
262
+ });
263
+ });
264
+
265
+ // Find boundary edges (count === 1)
266
+ const boundaryEdges = Array.from(edges.entries())
267
+ .filter(([k, v]) => v === 1)
268
+ .map(([k]) => k);
269
+
270
+ // Trace loops
271
+ const visited = new Set();
272
+ boundaryEdges.forEach(edge => {
273
+ if (visited.has(edge)) return;
274
+
275
+ const loop = { vertices: [], edges: [] };
276
+ let current = edge;
277
+
278
+ while (!visited.has(current) && loop.vertices.length < 1000) {
279
+ visited.add(current);
280
+ const [p1, p2] = current.split('-');
281
+ const v1 = parseVector(p1);
282
+ loop.vertices.push(v1);
283
+ current = findNextEdge(p2, boundaryEdges, visited);
284
+ if (!current) break;
285
+ }
286
+
287
+ if (loop.vertices.length > 8) {
288
+ loops.push(loop);
289
+ }
290
+ });
291
+
292
+ return loops;
293
+ }
294
+
295
+ /**
296
+ * Fit a circle to a set of points
297
+ * @param {Array} vertices - Array of THREE.Vector3
298
+ * @returns {Object} { center, radius, axis }
299
+ */
300
+ function fitCircle(vertices) {
301
+ if (vertices.length < 3) {
302
+ return { center: vertices[0].clone(), radius: 0, axis: new THREE.Vector3(0, 0, 1) };
303
+ }
304
+
305
+ // Project to dominant plane
306
+ let avg = new THREE.Vector3();
307
+ vertices.forEach(v => avg.add(v));
308
+ avg.divideScalar(vertices.length);
309
+
310
+ // Compute best-fit plane normal
311
+ let covMatrix = [0,0,0,0,0,0,0,0,0];
312
+ vertices.forEach(v => {
313
+ const p = v.clone().sub(avg);
314
+ covMatrix[0] += p.x * p.x;
315
+ covMatrix[1] += p.x * p.y;
316
+ covMatrix[2] += p.x * p.z;
317
+ covMatrix[4] += p.y * p.y;
318
+ covMatrix[5] += p.y * p.z;
319
+ covMatrix[8] += p.z * p.z;
320
+ });
321
+
322
+ // Simple approximation: axis is Z if variance in Z is highest
323
+ const axis = new THREE.Vector3(0, 0, 1);
324
+ const radius = vertices.reduce((sum, v) => sum + v.distanceTo(avg), 0) / vertices.length;
325
+
326
+ return { center: avg, radius, axis };
327
+ }
328
+
329
+ /**
330
+ * Estimate depth of a cylindrical hole
331
+ * @param {Array} vertices - Edge loop vertices
332
+ * @param {THREE.Vector3} axis - Cylinder axis
333
+ * @returns {number} Estimated depth
334
+ */
335
+ function estimateDepth(vertices, axis) {
336
+ const projections = vertices.map(v => v.dot(axis));
337
+ return Math.max(...projections) - Math.min(...projections);
338
+ }
339
+
340
+ /**
341
+ * Classify features into semantic types
342
+ * @param {Array} features - Array of detected features
343
+ * @param {THREE.Mesh} mesh - Source mesh
344
+ */
345
+ function classifyFeatures(features, mesh) {
346
+ features.forEach(feature => {
347
+ if (feature.type === FEATURE_TYPES.MOUNTING_HOLE) {
348
+ // Already classified
349
+ } else if (feature.type === FEATURE_TYPES.FLAT_FACE && feature.radius > 0) {
350
+ // Flat face with hole = boss
351
+ feature.type = FEATURE_TYPES.BOSS;
352
+ }
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Compute centroid of points
358
+ * @param {Array} points - Array of THREE.Vector3
359
+ * @returns {THREE.Vector3} Centroid
360
+ */
361
+ function computeCentroid(points) {
362
+ const centroid = new THREE.Vector3();
363
+ points.forEach(p => centroid.add(p));
364
+ centroid.divideScalar(Math.max(1, points.length));
365
+ return centroid;
366
+ }
367
+
368
+ /**
369
+ * Parse vector from string format "x,y,z"
370
+ * @param {string} str - Vector string
371
+ * @returns {THREE.Vector3} Parsed vector
372
+ */
373
+ function parseVector(str) {
374
+ const [x, y, z] = str.split(',').map(Number);
375
+ return new THREE.Vector3(x, y, z);
376
+ }
377
+
378
+ /**
379
+ * Find next edge in loop
380
+ * @param {string} from - Starting point string
381
+ * @param {Array} edges - Boundary edges
382
+ * @param {Set} visited - Visited edges
383
+ * @returns {string} Next edge or null
384
+ */
385
+ function findNextEdge(from, edges, visited) {
386
+ return edges.find(e => {
387
+ if (visited.has(e)) return false;
388
+ return e.startsWith(from);
389
+ }) || null;
390
+ }
391
+
392
+ // =========================================================================
393
+ // MATE CONSTRAINT SYSTEM
394
+ // =========================================================================
395
+
396
+ /**
397
+ * Create a mate constraint between two features
398
+ * @param {string} partId1 - First part ID
399
+ * @param {Object} feature1 - First feature
400
+ * @param {string} partId2 - Second part ID
401
+ * @param {Object} feature2 - Second feature
402
+ * @param {string} constraintType - Type of constraint
403
+ * @returns {Object} Constraint object
404
+ */
405
+ function createConstraint(partId1, feature1, partId2, feature2, constraintType) {
406
+ const constraint = {
407
+ id: `constraint_${constraints.length}`,
408
+ type: constraintType,
409
+ part1: partId1,
410
+ feature1,
411
+ part2: partId2,
412
+ feature2,
413
+ parameters: {},
414
+ priority: getPriority(constraintType),
415
+ active: true,
416
+ visualElement: null
417
+ };
418
+
419
+ // Set default parameters based on constraint type
420
+ switch (constraintType) {
421
+ case CONSTRAINT_TYPES.DISTANCE:
422
+ constraint.parameters.distance = 0;
423
+ break;
424
+ case CONSTRAINT_TYPES.ANGLE:
425
+ constraint.parameters.angle = 0;
426
+ break;
427
+ case CONSTRAINT_TYPES.GEAR:
428
+ constraint.parameters.ratio = 1;
429
+ break;
430
+ }
431
+
432
+ constraints.push(constraint);
433
+ return constraint;
434
+ }
435
+
436
+ /**
437
+ * Get priority for constraint solving (higher = solve first)
438
+ * @param {string} constraintType - Type of constraint
439
+ * @returns {number} Priority value
440
+ */
441
+ function getPriority(constraintType) {
442
+ const priorities = {
443
+ [CONSTRAINT_TYPES.CONCENTRIC]: 100,
444
+ [CONSTRAINT_TYPES.COINCIDENT]: 90,
445
+ [CONSTRAINT_TYPES.DISTANCE]: 80,
446
+ [CONSTRAINT_TYPES.ANGLE]: 70,
447
+ [CONSTRAINT_TYPES.PARALLEL]: 60,
448
+ [CONSTRAINT_TYPES.PERPENDICULAR]: 60,
449
+ [CONSTRAINT_TYPES.TANGENT]: 50,
450
+ [CONSTRAINT_TYPES.GEAR]: 40
451
+ };
452
+ return priorities[constraintType] || 50;
453
+ }
454
+
455
+ /**
456
+ * Solve assembly constraints iteratively
457
+ * @param {number} iterations - Number of solver iterations
458
+ */
459
+ function solveConstraints(iterations = 10) {
460
+ // Sort constraints by priority
461
+ const sorted = [...constraints].sort((a, b) => b.priority - a.priority);
462
+
463
+ for (let iter = 0; iter < iterations; iter++) {
464
+ sorted.forEach(constraint => {
465
+ if (!constraint.active) return;
466
+
467
+ const part1 = window.CycleCAD?.app?.parts?.get?.(constraint.part1);
468
+ const part2 = window.CycleCAD?.app?.parts?.get?.(constraint.part2);
469
+
470
+ if (!part1 || !part2) return;
471
+
472
+ applySingleConstraint(constraint, part1, part2);
473
+ });
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Apply a single constraint to two parts
479
+ * @param {Object} constraint - Constraint to apply
480
+ * @param {THREE.Mesh} part1 - First part mesh
481
+ * @param {THREE.Mesh} part2 - Second part mesh
482
+ */
483
+ function applySingleConstraint(constraint, part1, part2) {
484
+ const f1 = constraint.feature1;
485
+ const f2 = constraint.feature2;
486
+ const damping = 0.3; // Gradual convergence
487
+
488
+ switch (constraint.type) {
489
+ case CONSTRAINT_TYPES.COINCIDENT:
490
+ // Align face-to-face (planes coincident)
491
+ applyCoincidentConstraint(part1, part2, f1, f2, damping);
492
+ break;
493
+
494
+ case CONSTRAINT_TYPES.CONCENTRIC:
495
+ // Align axes (shaft in hole)
496
+ applyConcentricConstraint(part1, part2, f1, f2, damping);
497
+ break;
498
+
499
+ case CONSTRAINT_TYPES.TANGENT:
500
+ // Surface tangency
501
+ applyTangentConstraint(part1, part2, f1, f2, damping);
502
+ break;
503
+
504
+ case CONSTRAINT_TYPES.DISTANCE:
505
+ // Maintain distance between surfaces
506
+ applyDistanceConstraint(part1, part2, f1, f2, constraint.parameters.distance, damping);
507
+ break;
508
+
509
+ case CONSTRAINT_TYPES.ANGLE:
510
+ // Fixed angle between faces
511
+ applyAngleConstraint(part1, part2, f1, f2, constraint.parameters.angle, damping);
512
+ break;
513
+
514
+ case CONSTRAINT_TYPES.PARALLEL:
515
+ // Faces parallel at offset
516
+ applyParallelConstraint(part1, part2, f1, f2, damping);
517
+ break;
518
+
519
+ case CONSTRAINT_TYPES.PERPENDICULAR:
520
+ // Faces perpendicular
521
+ applyPerpendicularConstraint(part1, part2, f1, f2, damping);
522
+ break;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Apply coincident constraint (face-to-face)
528
+ */
529
+ function applyCoincidentConstraint(part1, part2, f1, f2, damping) {
530
+ const offset = f2.position.clone().sub(f1.position).multiplyScalar(damping);
531
+ part2.position.add(offset);
532
+
533
+ // Align normals
534
+ const quat = new THREE.Quaternion();
535
+ quat.setFromUnitVectors(f2.normal, f1.normal.clone().negate());
536
+ part2.quaternion.multiplyQuaternions(quat, part2.quaternion);
537
+ }
538
+
539
+ /**
540
+ * Apply concentric constraint (axis alignment)
541
+ */
542
+ function applyConcentricConstraint(part1, part2, f1, f2, damping) {
543
+ // Move part2 so axes align
544
+ const offset = f1.position.clone().sub(f2.position).multiplyScalar(damping);
545
+ part2.position.add(offset);
546
+
547
+ // Rotate part2 so normals (axes) align
548
+ const quat = new THREE.Quaternion();
549
+ quat.setFromUnitVectors(f2.normal, f1.normal);
550
+ part2.quaternion.multiplyQuaternions(quat, part2.quaternion);
551
+ }
552
+
553
+ /**
554
+ * Apply tangent constraint (surface tangency)
555
+ */
556
+ function applyTangentConstraint(part1, part2, f1, f2, damping) {
557
+ const distance = f1.radius + f2.radius;
558
+ const direction = f1.position.clone().sub(f2.position).normalize();
559
+ const targetPos = f2.position.clone().add(direction.multiplyScalar(distance));
560
+ const offset = targetPos.sub(f2.position).multiplyScalar(damping);
561
+ part2.position.add(offset);
562
+ }
563
+
564
+ /**
565
+ * Apply distance constraint (maintain offset)
566
+ */
567
+ function applyDistanceConstraint(part1, part2, f1, f2, targetDist, damping) {
568
+ const current = f1.position.distanceTo(f2.position);
569
+ const offset = targetDist - current;
570
+ const direction = f1.position.clone().sub(f2.position).normalize();
571
+ part2.position.add(direction.multiplyScalar(offset * damping * 0.5));
572
+ }
573
+
574
+ /**
575
+ * Apply angle constraint
576
+ */
577
+ function applyAngleConstraint(part1, part2, f1, f2, targetAngle, damping) {
578
+ const currentAngle = Math.acos(Math.min(1, Math.max(-1, f1.normal.dot(f2.normal))));
579
+ const angleDiff = targetAngle - currentAngle;
580
+ const axis = f1.normal.clone().cross(f2.normal).normalize();
581
+
582
+ if (axis.length() > 0.01) {
583
+ const quat = new THREE.Quaternion();
584
+ quat.setFromAxisAngle(axis, angleDiff * damping * 0.5);
585
+ part2.quaternion.multiplyQuaternions(quat, part2.quaternion);
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Apply parallel constraint
591
+ */
592
+ function applyParallelConstraint(part1, part2, f1, f2, damping) {
593
+ const quat = new THREE.Quaternion();
594
+ quat.setFromUnitVectors(f2.normal, f1.normal);
595
+ part2.quaternion.multiplyQuaternions(quat, part2.quaternion);
596
+ }
597
+
598
+ /**
599
+ * Apply perpendicular constraint
600
+ */
601
+ function applyPerpendicularConstraint(part1, part2, f1, f2, damping) {
602
+ const target = f2.normal.clone().cross(f1.normal).normalize();
603
+ const quat = new THREE.Quaternion();
604
+ quat.setFromUnitVectors(f2.normal, target);
605
+ part2.quaternion.multiplyQuaternions(quat, part2.quaternion);
606
+ }
607
+
608
+ /**
609
+ * Visualize constraints as lines and arrows
610
+ */
611
+ function visualizeConstraints() {
612
+ // Clear old visuals
613
+ constraints.forEach(c => {
614
+ if (c.visualElement) {
615
+ c.visualElement.geometry?.dispose?.();
616
+ c.visualElement.material?.dispose?.();
617
+ window.CycleCAD?.app?.scene?.remove?.(c.visualElement);
618
+ c.visualElement = null;
619
+ }
620
+ });
621
+
622
+ if (!constraintVisualsEnabled) return;
623
+
624
+ const scene = window.CycleCAD?.app?.scene;
625
+ if (!scene) return;
626
+
627
+ constraints.forEach(constraint => {
628
+ const colors = {
629
+ [CONSTRAINT_TYPES.COINCIDENT]: 0x00FF00,
630
+ [CONSTRAINT_TYPES.CONCENTRIC]: 0x0088FF,
631
+ [CONSTRAINT_TYPES.TANGENT]: 0xFF8800,
632
+ [CONSTRAINT_TYPES.PARALLEL]: 0xFF0088,
633
+ [CONSTRAINT_TYPES.PERPENDICULAR]: 0x88FF00,
634
+ [CONSTRAINT_TYPES.DISTANCE]: 0xFFFF00,
635
+ [CONSTRAINT_TYPES.ANGLE]: 0xFF0000,
636
+ [CONSTRAINT_TYPES.GEAR]: 0x8800FF
637
+ };
638
+
639
+ const color = colors[constraint.type] || 0xFFFFFF;
640
+ const geometry = new THREE.BufferGeometry();
641
+ const positions = new Float32Array([
642
+ constraint.feature1.position.x, constraint.feature1.position.y, constraint.feature1.position.z,
643
+ constraint.feature2.position.x, constraint.feature2.position.y, constraint.feature2.position.z
644
+ ]);
645
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
646
+
647
+ const material = new THREE.LineBasicMaterial({ color, linewidth: 2 });
648
+ const line = new THREE.Line(geometry, material);
649
+ scene.add(line);
650
+ constraint.visualElement = line;
651
+ });
652
+ }
653
+
654
+ // =========================================================================
655
+ // AUTO-MATING INTELLIGENCE
656
+ // =========================================================================
657
+
658
+ /**
659
+ * Score compatibility between two features
660
+ * @param {Object} f1 - First feature
661
+ * @param {Object} f2 - Second feature
662
+ * @returns {number} Compatibility score (0-1)
663
+ */
664
+ function scoreCompatibility(f1, f2) {
665
+ let score = 0;
666
+
667
+ // Matching types get high score
668
+ if (f1.type === FEATURE_TYPES.SHAFT && f2.type === FEATURE_TYPES.MOUNTING_HOLE) {
669
+ score = 0.9;
670
+ } else if (f1.type === FEATURE_TYPES.MOUNTING_HOLE && f2.type === FEATURE_TYPES.MOUNTING_HOLE) {
671
+ // Hole-to-hole mating (coaxial)
672
+ score = 0.85;
673
+ } else if (f1.type === FEATURE_TYPES.FLAT_FACE && f2.type === FEATURE_TYPES.FLAT_FACE) {
674
+ score = 0.75;
675
+ } else if (f1.type === FEATURE_TYPES.SPHERE && f2.type === FEATURE_TYPES.FLAT_FACE) {
676
+ score = 0.7;
677
+ } else if ((f1.type === FEATURE_TYPES.CYLINDER || f1.type === FEATURE_TYPES.BOSS) &&
678
+ (f2.type === FEATURE_TYPES.FLAT_FACE || f2.type === FEATURE_TYPES.POCKET)) {
679
+ score = 0.65;
680
+ }
681
+
682
+ // Check radius matching (within 20%)
683
+ if (score > 0 && f1.radius > 0 && f2.radius > 0) {
684
+ const radiusRatio = Math.max(f1.radius, f2.radius) / Math.max(0.001, Math.min(f1.radius, f2.radius));
685
+ if (radiusRatio < 1.2) {
686
+ score *= 1.1; // Bonus for matching sizes
687
+ } else if (radiusRatio > 1.5) {
688
+ score *= 0.7; // Penalty for mismatched sizes
689
+ }
690
+ }
691
+
692
+ return Math.min(1, Math.max(0, score));
693
+ }
694
+
695
+ /**
696
+ * Calculate proximity score
697
+ * @param {Object} f1 - First feature
698
+ * @param {Object} f2 - Second feature
699
+ * @param {number} maxDist - Maximum distance threshold
700
+ * @returns {number} Proximity score (0-1)
701
+ */
702
+ function scoreProximity(f1, f2, maxDist = 50) {
703
+ const dist = f1.position.distanceTo(f2.position);
704
+ return Math.max(0, 1 - (dist / maxDist));
705
+ }
706
+
707
+ /**
708
+ * Calculate alignment quality
709
+ * @param {Object} f1 - First feature
710
+ * @param {Object} f2 - Second feature
711
+ * @returns {number} Alignment score (0-1)
712
+ */
713
+ function scoreAlignment(f1, f2) {
714
+ if (!f1.normal || !f2.normal) return 0.5;
715
+
716
+ const dotProduct = Math.abs(f1.normal.dot(f2.normal));
717
+ return dotProduct; // 0 = perpendicular, 1 = parallel/antiparallel
718
+ }
719
+
720
+ /**
721
+ * Find top auto-mate suggestions for a part
722
+ * @param {string} draggedPartId - Part being dragged
723
+ * @param {string} targetPartId - Part being dragged near
724
+ * @returns {Array} Top 3 suggestions sorted by score
725
+ */
726
+ function suggestMates(draggedPartId, targetPartId) {
727
+ const draggedFeatures = detectedFeatures.get(draggedPartId) || [];
728
+ const targetFeatures = detectedFeatures.get(targetPartId) || [];
729
+
730
+ const suggestions = [];
731
+
732
+ draggedFeatures.forEach(df => {
733
+ targetFeatures.forEach(tf => {
734
+ const compat = scoreCompatibility(df, tf);
735
+ const proximity = scoreProximity(df, tf, 100);
736
+ const alignment = scoreAlignment(df, tf);
737
+
738
+ const totalScore = compat * 0.5 + proximity * 0.3 + alignment * 0.2;
739
+
740
+ if (totalScore > 0.3) {
741
+ suggestions.push({
742
+ score: totalScore,
743
+ constraintType: inferConstraintType(df, tf),
744
+ f1: df,
745
+ f2: tf,
746
+ part1: draggedPartId,
747
+ part2: targetPartId
748
+ });
749
+ }
750
+ });
751
+ });
752
+
753
+ return suggestions.sort((a, b) => b.score - a.score).slice(0, 3);
754
+ }
755
+
756
+ /**
757
+ * Infer best constraint type for two features
758
+ * @param {Object} f1 - First feature
759
+ * @param {Object} f2 - Second feature
760
+ * @returns {string} Constraint type
761
+ */
762
+ function inferConstraintType(f1, f2) {
763
+ if ((f1.type === FEATURE_TYPES.SHAFT && f2.type === FEATURE_TYPES.MOUNTING_HOLE) ||
764
+ (f1.type === FEATURE_TYPES.MOUNTING_HOLE && f2.type === FEATURE_TYPES.MOUNTING_HOLE)) {
765
+ return CONSTRAINT_TYPES.CONCENTRIC;
766
+ }
767
+ if (f1.type === FEATURE_TYPES.FLAT_FACE && f2.type === FEATURE_TYPES.FLAT_FACE) {
768
+ return CONSTRAINT_TYPES.COINCIDENT;
769
+ }
770
+ if (f1.radius > 0 && f2.radius > 0) {
771
+ return CONSTRAINT_TYPES.TANGENT;
772
+ }
773
+ return CONSTRAINT_TYPES.DISTANCE;
774
+ }
775
+
776
+ /**
777
+ * Auto-mate all unmated parts in assembly
778
+ * @param {number} threshold - Confidence threshold (0-1)
779
+ */
780
+ function autoMateAll(threshold = autoMateThreshold) {
781
+ const parts = Array.from(window.CycleCAD?.app?.parts?.entries?.() || []);
782
+
783
+ parts.forEach(([id1, part1]) => {
784
+ parts.forEach(([id2, part2]) => {
785
+ if (id1 === id2) return;
786
+
787
+ // Check if already constrained
788
+ if (constraints.some(c =>
789
+ (c.part1 === id1 && c.part2 === id2) ||
790
+ (c.part1 === id2 && c.part2 === id1))) {
791
+ return;
792
+ }
793
+
794
+ const suggestions = suggestMates(id1, id2);
795
+ if (suggestions.length > 0 && suggestions[0].score >= threshold) {
796
+ const s = suggestions[0];
797
+ createConstraint(s.part1, s.f1, s.part2, s.f2, s.constraintType);
798
+ }
799
+ });
800
+ });
801
+
802
+ solveConstraints();
803
+ visualizeConstraints();
804
+ }
805
+
806
+ // =========================================================================
807
+ // MOTION STUDY
808
+ // =========================================================================
809
+
810
+ /**
811
+ * Define a joint from constraints
812
+ * @param {Array} constraintIds - Array of constraint IDs forming joint
813
+ * @param {string} jointType - Type of joint (revolute, prismatic, etc)
814
+ * @returns {Object} Joint object
815
+ */
816
+ function defineJoint(constraintIds, jointType) {
817
+ const relatedConstraints = constraints.filter(c => constraintIds.includes(c.id));
818
+
819
+ const joint = {
820
+ id: `joint_${motionStudies.length}`,
821
+ type: jointType,
822
+ constraints: relatedConstraints,
823
+ range: { min: 0, max: 360 }, // degrees for revolute, mm for prismatic
824
+ current: 0,
825
+ speed: 1,
826
+ playing: false,
827
+ keyframes: []
828
+ };
829
+
830
+ // Set range based on joint type
831
+ if (jointType === JOINT_TYPES.PRISMATIC) {
832
+ joint.range.max = 100; // 100mm default stroke
833
+ } else if (jointType === JOINT_TYPES.BALL) {
834
+ joint.range.max = 180;
835
+ }
836
+
837
+ motionStudies.push(joint);
838
+ return joint;
839
+ }
840
+
841
+ /**
842
+ * Play motion study
843
+ * @param {string} jointId - Joint ID to animate
844
+ */
845
+ function playMotion(jointId) {
846
+ const joint = motionStudies.find(j => j.id === jointId);
847
+ if (joint) {
848
+ joint.playing = true;
849
+ joint.current = joint.range.min;
850
+ activeMotionStudy = joint;
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Stop motion study
856
+ * @param {string} jointId - Joint ID to stop
857
+ */
858
+ function stopMotion(jointId) {
859
+ const joint = motionStudies.find(j => j.id === jointId);
860
+ if (joint) {
861
+ joint.playing = false;
862
+ }
863
+ }
864
+
865
+ /**
866
+ * Update motion frame (called from animation loop)
867
+ * @param {number} deltaTime - Delta time in seconds
868
+ */
869
+ function updateMotion(deltaTime) {
870
+ if (!activeMotionStudy || !activeMotionStudy.playing) return;
871
+
872
+ const joint = activeMotionStudy;
873
+ joint.current += (joint.range.max - joint.range.min) * joint.speed * deltaTime * 0.1;
874
+
875
+ if (joint.current > joint.range.max) {
876
+ joint.current = joint.range.min;
877
+ }
878
+
879
+ applyJointTransform(joint);
880
+ }
881
+
882
+ /**
883
+ * Apply joint transformation to all constrained parts
884
+ * @param {Object} joint - Joint object
885
+ */
886
+ function applyJointTransform(joint) {
887
+ joint.constraints.forEach(constraint => {
888
+ const part = window.CycleCAD?.app?.parts?.get?.(constraint.part2);
889
+ if (!part) return;
890
+
891
+ const origin = constraint.feature1.position;
892
+ const axis = constraint.feature1.normal;
893
+
894
+ if (joint.type === JOINT_TYPES.REVOLUTE) {
895
+ // Rotate around axis
896
+ const angle = (joint.current / 360) * Math.PI * 2;
897
+ const quat = new THREE.Quaternion();
898
+ quat.setFromAxisAngle(axis, angle);
899
+
900
+ // Rotate around origin point
901
+ part.position.sub(origin);
902
+ part.position.applyQuaternion(quat);
903
+ part.position.add(origin);
904
+ part.quaternion.multiplyQuaternions(quat, part.quaternion);
905
+
906
+ } else if (joint.type === JOINT_TYPES.PRISMATIC) {
907
+ // Slide along axis
908
+ const distance = joint.current * axis;
909
+ part.position.copy(constraint.feature1.position).add(distance);
910
+ }
911
+ });
912
+ }
913
+
914
+ /**
915
+ * Check for part interference during motion
916
+ * @param {Object} joint - Joint object
917
+ * @returns {Array} Array of interference objects
918
+ */
919
+ function checkInterference(joint) {
920
+ const interferences = [];
921
+ const parts = Array.from(window.CycleCAD?.app?.parts?.values?.() || []);
922
+
923
+ for (let i = 0; i < parts.length; i++) {
924
+ for (let j = i + 1; j < parts.length; j++) {
925
+ if (checkMeshIntersection(parts[i], parts[j])) {
926
+ interferences.push({
927
+ part1: parts[i],
928
+ part2: parts[j],
929
+ position: parts[i].position.clone().lerp(parts[j].position, 0.5)
930
+ });
931
+ }
932
+ }
933
+ }
934
+
935
+ return interferences;
936
+ }
937
+
938
+ /**
939
+ * Check if two meshes intersect (AABB approximation)
940
+ * @param {THREE.Mesh} m1 - First mesh
941
+ * @param {THREE.Mesh} m2 - Second mesh
942
+ * @returns {boolean} True if meshes overlap
943
+ */
944
+ function checkMeshIntersection(m1, m2) {
945
+ const box1 = new THREE.Box3().setFromObject(m1);
946
+ const box2 = new THREE.Box3().setFromObject(m2);
947
+ return box1.intersectsBox(box2);
948
+ }
949
+
950
+ // =========================================================================
951
+ // ASSEMBLY TREE
952
+ // =========================================================================
953
+
954
+ /**
955
+ * Add part to assembly
956
+ * @param {THREE.Mesh} part - Part mesh
957
+ * @param {string} partId - Part identifier
958
+ * @param {string} name - Display name
959
+ */
960
+ function addPart(part, partId, name) {
961
+ assemblyTree.parts.push({
962
+ id: partId,
963
+ name: name || `Part_${partId}`,
964
+ mesh: part,
965
+ constraints: [],
966
+ hidden: false,
967
+ suppressed: false
968
+ });
969
+
970
+ // Detect features
971
+ detectFeatures(part, partId);
972
+ }
973
+
974
+ /**
975
+ * Get constraint status for a part
976
+ * @param {string} partId - Part ID
977
+ * @returns {Object} { total, satisfied, over, under }
978
+ */
979
+ function getConstraintStatus(partId) {
980
+ const partConstraints = constraints.filter(c => c.part1 === partId || c.part2 === partId);
981
+
982
+ return {
983
+ total: partConstraints.length,
984
+ satisfied: partConstraints.filter(c => c.active).length,
985
+ over: Math.max(0, partConstraints.filter(c => c.active).length - 6),
986
+ under: Math.max(0, 6 - partConstraints.filter(c => c.active).length)
987
+ };
988
+ }
989
+
990
+ // =========================================================================
991
+ // UI PANEL
992
+ // =========================================================================
993
+
994
+ /**
995
+ * Initialize smart assembly module
996
+ */
997
+ function init() {
998
+ // Attach to app if available
999
+ if (window.CycleCAD?.app) {
1000
+ window.CycleCAD.app.smartAssembly = window.CycleCAD.SmartAssembly;
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Get UI panel HTML
1006
+ * @returns {string} HTML for smart assembly panel
1007
+ */
1008
+ function getUI() {
1009
+ const tabHtml = `
1010
+ <div class="smart-assembly-panel" style="
1011
+ display: flex;
1012
+ flex-direction: column;
1013
+ height: 100%;
1014
+ background: var(--color-bg-panel, #1a1a1a);
1015
+ color: var(--color-text, #e0e0e0);
1016
+ font-family: 'Segoe UI', sans-serif;
1017
+ font-size: 12px;
1018
+ border-radius: 4px;
1019
+ overflow: hidden;
1020
+ ">
1021
+ <!-- Tab Navigation -->
1022
+ <div style="
1023
+ display: flex;
1024
+ border-bottom: 1px solid var(--color-border, #333);
1025
+ background: var(--color-bg-darker, #0f0f0f);
1026
+ ">
1027
+ <button class="sa-tab-btn" data-tab="mate" style="
1028
+ flex: 1;
1029
+ padding: 10px;
1030
+ background: var(--color-accent, #007acc);
1031
+ color: white;
1032
+ border: none;
1033
+ cursor: pointer;
1034
+ font-weight: 500;
1035
+ ">Mate</button>
1036
+ <button class="sa-tab-btn" data-tab="motion" style="
1037
+ flex: 1;
1038
+ padding: 10px;
1039
+ background: transparent;
1040
+ color: var(--color-text, #e0e0e0);
1041
+ border: none;
1042
+ cursor: pointer;
1043
+ border-left: 1px solid var(--color-border, #333);
1044
+ ">Motion</button>
1045
+ <button class="sa-tab-btn" data-tab="tree" style="
1046
+ flex: 1;
1047
+ padding: 10px;
1048
+ background: transparent;
1049
+ color: var(--color-text, #e0e0e0);
1050
+ border: none;
1051
+ cursor: pointer;
1052
+ border-left: 1px solid var(--color-border, #333);
1053
+ ">Tree</button>
1054
+ <button class="sa-tab-btn" data-tab="visual" style="
1055
+ flex: 1;
1056
+ padding: 10px;
1057
+ background: transparent;
1058
+ color: var(--color-text, #e0e0e0);
1059
+ border: none;
1060
+ cursor: pointer;
1061
+ border-left: 1px solid var(--color-border, #333);
1062
+ ">Visual</button>
1063
+ </div>
1064
+
1065
+ <!-- Tab Content -->
1066
+ <div style="flex: 1; overflow: auto;">
1067
+
1068
+ <!-- MATE TAB -->
1069
+ <div class="sa-tab-content" id="sa-mate" style="
1070
+ padding: 15px;
1071
+ display: block;
1072
+ ">
1073
+ <h3 style="margin: 0 0 10px 0; font-size: 13px; font-weight: 600;">Quick Mate</h3>
1074
+
1075
+ <div style="margin-bottom: 10px;">
1076
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1077
+ Select 2 Faces/Features
1078
+ </label>
1079
+ <div style="
1080
+ padding: 8px;
1081
+ background: var(--color-bg-input, #252525);
1082
+ border: 1px solid var(--color-border, #333);
1083
+ border-radius: 3px;
1084
+ min-height: 50px;
1085
+ font-size: 11px;
1086
+ " id="sa-selection-display">
1087
+ <span style="color: var(--color-text-secondary, #a0a0a0);">Click faces to select...</span>
1088
+ </div>
1089
+ </div>
1090
+
1091
+ <div style="margin-bottom: 10px;">
1092
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1093
+ Constraint Type
1094
+ </label>
1095
+ <select id="sa-constraint-type" style="
1096
+ width: 100%;
1097
+ padding: 6px;
1098
+ background: var(--color-bg-input, #252525);
1099
+ color: var(--color-text, #e0e0e0);
1100
+ border: 1px solid var(--color-border, #333);
1101
+ border-radius: 3px;
1102
+ font-size: 11px;
1103
+ ">
1104
+ <option value="concentric">Concentric (Shaft in Hole)</option>
1105
+ <option value="coincident">Coincident (Face to Face)</option>
1106
+ <option value="distance">Distance</option>
1107
+ <option value="angle">Angle</option>
1108
+ <option value="parallel">Parallel</option>
1109
+ <option value="perpendicular">Perpendicular</option>
1110
+ <option value="tangent">Tangent</option>
1111
+ <option value="gear">Gear</option>
1112
+ </select>
1113
+ </div>
1114
+
1115
+ <div style="margin-bottom: 10px;">
1116
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1117
+ Parameter (if needed)
1118
+ </label>
1119
+ <input type="number" id="sa-param-value" placeholder="0" style="
1120
+ width: 100%;
1121
+ padding: 6px;
1122
+ background: var(--color-bg-input, #252525);
1123
+ color: var(--color-text, #e0e0e0);
1124
+ border: 1px solid var(--color-border, #333);
1125
+ border-radius: 3px;
1126
+ font-size: 11px;
1127
+ box-sizing: border-box;
1128
+ " />
1129
+ </div>
1130
+
1131
+ <button id="sa-apply-mate" style="
1132
+ width: 100%;
1133
+ padding: 8px;
1134
+ background: var(--color-accent, #007acc);
1135
+ color: white;
1136
+ border: none;
1137
+ border-radius: 3px;
1138
+ font-weight: 500;
1139
+ cursor: pointer;
1140
+ margin-bottom: 10px;
1141
+ ">Apply Constraint</button>
1142
+
1143
+ <button id="sa-auto-mate" style="
1144
+ width: 100%;
1145
+ padding: 8px;
1146
+ background: var(--color-success, #22aa22);
1147
+ color: white;
1148
+ border: none;
1149
+ border-radius: 3px;
1150
+ font-weight: 500;
1151
+ cursor: pointer;
1152
+ margin-bottom: 10px;
1153
+ ">Auto-Mate All</button>
1154
+
1155
+ <h3 style="margin: 15px 0 10px 0; font-size: 13px; font-weight: 600;">Suggestions</h3>
1156
+ <div id="sa-suggestions" style="
1157
+ max-height: 150px;
1158
+ overflow: auto;
1159
+ "></div>
1160
+
1161
+ <h3 style="margin: 15px 0 10px 0; font-size: 13px; font-weight: 600;">Active Constraints</h3>
1162
+ <div id="sa-constraints-list" style="
1163
+ max-height: 150px;
1164
+ overflow: auto;
1165
+ font-size: 11px;
1166
+ "></div>
1167
+ </div>
1168
+
1169
+ <!-- MOTION TAB -->
1170
+ <div class="sa-tab-content" id="sa-motion" style="
1171
+ padding: 15px;
1172
+ display: none;
1173
+ ">
1174
+ <h3 style="margin: 0 0 10px 0; font-size: 13px; font-weight: 600;">Motion Study</h3>
1175
+
1176
+ <div style="margin-bottom: 10px;">
1177
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1178
+ Joint Type
1179
+ </label>
1180
+ <select id="sa-joint-type" style="
1181
+ width: 100%;
1182
+ padding: 6px;
1183
+ background: var(--color-bg-input, #252525);
1184
+ color: var(--color-text, #e0e0e0);
1185
+ border: 1px solid var(--color-border, #333);
1186
+ border-radius: 3px;
1187
+ font-size: 11px;
1188
+ ">
1189
+ <option value="revolute">Revolute (Rotate)</option>
1190
+ <option value="prismatic">Prismatic (Slide)</option>
1191
+ <option value="cylindrical">Cylindrical</option>
1192
+ <option value="ball">Ball</option>
1193
+ <option value="planar">Planar</option>
1194
+ </select>
1195
+ </div>
1196
+
1197
+ <div style="margin-bottom: 10px;">
1198
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1199
+ Range (degrees/mm)
1200
+ </label>
1201
+ <div style="display: flex; gap: 5px;">
1202
+ <input type="number" id="sa-range-min" placeholder="0" style="
1203
+ flex: 1;
1204
+ padding: 6px;
1205
+ background: var(--color-bg-input, #252525);
1206
+ color: var(--color-text, #e0e0e0);
1207
+ border: 1px solid var(--color-border, #333);
1208
+ border-radius: 3px;
1209
+ font-size: 11px;
1210
+ " />
1211
+ <input type="number" id="sa-range-max" placeholder="360" style="
1212
+ flex: 1;
1213
+ padding: 6px;
1214
+ background: var(--color-bg-input, #252525);
1215
+ color: var(--color-text, #e0e0e0);
1216
+ border: 1px solid var(--color-border, #333);
1217
+ border-radius: 3px;
1218
+ font-size: 11px;
1219
+ " />
1220
+ </div>
1221
+ </div>
1222
+
1223
+ <div style="margin-bottom: 10px;">
1224
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1225
+ Speed
1226
+ </label>
1227
+ <input type="range" id="sa-speed" min="0.1" max="5" step="0.1" value="1" style="
1228
+ width: 100%;
1229
+ " />
1230
+ <span id="sa-speed-display" style="font-size: 10px; color: var(--color-text-secondary, #a0a0a0);">1.0x</span>
1231
+ </div>
1232
+
1233
+ <div style="display: flex; gap: 5px; margin-bottom: 10px;">
1234
+ <button id="sa-motion-play" style="
1235
+ flex: 1;
1236
+ padding: 8px;
1237
+ background: var(--color-accent, #007acc);
1238
+ color: white;
1239
+ border: none;
1240
+ border-radius: 3px;
1241
+ font-weight: 500;
1242
+ cursor: pointer;
1243
+ ">Play</button>
1244
+ <button id="sa-motion-stop" style="
1245
+ flex: 1;
1246
+ padding: 8px;
1247
+ background: var(--color-warning, #ff9900);
1248
+ color: white;
1249
+ border: none;
1250
+ border-radius: 3px;
1251
+ font-weight: 500;
1252
+ cursor: pointer;
1253
+ ">Stop</button>
1254
+ </div>
1255
+
1256
+ <h3 style="margin: 15px 0 10px 0; font-size: 13px; font-weight: 600;">Joints</h3>
1257
+ <div id="sa-joints-list" style="
1258
+ max-height: 200px;
1259
+ overflow: auto;
1260
+ font-size: 11px;
1261
+ "></div>
1262
+
1263
+ <h3 style="margin: 15px 0 10px 0; font-size: 13px; font-weight: 600;">Interference</h3>
1264
+ <div id="sa-interference-list" style="
1265
+ max-height: 100px;
1266
+ overflow: auto;
1267
+ font-size: 11px;
1268
+ color: var(--color-warning, #ff9900);
1269
+ "></div>
1270
+ </div>
1271
+
1272
+ <!-- TREE TAB -->
1273
+ <div class="sa-tab-content" id="sa-tree" style="
1274
+ padding: 15px;
1275
+ display: none;
1276
+ ">
1277
+ <h3 style="margin: 0 0 10px 0; font-size: 13px; font-weight: 600;">Assembly Tree</h3>
1278
+ <div id="sa-tree-view" style="
1279
+ max-height: 400px;
1280
+ overflow: auto;
1281
+ font-size: 11px;
1282
+ "></div>
1283
+ </div>
1284
+
1285
+ <!-- VISUAL TAB -->
1286
+ <div class="sa-tab-content" id="sa-visual" style="
1287
+ padding: 15px;
1288
+ display: none;
1289
+ ">
1290
+ <h3 style="margin: 0 0 10px 0; font-size: 13px; font-weight: 600;">Visualization</h3>
1291
+
1292
+ <div style="margin-bottom: 10px;">
1293
+ <label style="display: flex; align-items: center; gap: 8px; font-size: 11px;">
1294
+ <input type="checkbox" id="sa-show-constraints" checked style="cursor: pointer;" />
1295
+ Show Constraint Lines
1296
+ </label>
1297
+ </div>
1298
+
1299
+ <div style="margin-bottom: 10px;">
1300
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1301
+ Explode (%)
1302
+ </label>
1303
+ <input type="range" id="sa-explode-slider" min="0" max="100" step="5" value="0" style="
1304
+ width: 100%;
1305
+ " />
1306
+ <span id="sa-explode-display" style="font-size: 10px; color: var(--color-text-secondary, #a0a0a0);">0%</span>
1307
+ </div>
1308
+
1309
+ <div style="margin-bottom: 10px;">
1310
+ <label style="display: block; margin-bottom: 5px; font-size: 11px; color: var(--color-text-secondary, #a0a0a0);">
1311
+ Part Colors by Status
1312
+ </label>
1313
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 10px;">
1314
+ <div style="display: flex; align-items: center; gap: 6px;">
1315
+ <div style="width: 12px; height: 12px; background: #22aa22; border-radius: 2px;"></div>
1316
+ <span>Fully Constrained</span>
1317
+ </div>
1318
+ <div style="display: flex; align-items: center; gap: 6px;">
1319
+ <div style="width: 12px; height: 12px; background: #ffaa00; border-radius: 2px;"></div>
1320
+ <span>Under-Constrained</span>
1321
+ </div>
1322
+ <div style="display: flex; align-items: center; gap: 6px;">
1323
+ <div style="width: 12px; height: 12px; background: #ff3333; border-radius: 2px;"></div>
1324
+ <span>Over-Constrained</span>
1325
+ </div>
1326
+ </div>
1327
+ </div>
1328
+
1329
+ <h3 style="margin: 15px 0 10px 0; font-size: 13px; font-weight: 600;">Features Detected</h3>
1330
+ <div id="sa-features-list" style="
1331
+ max-height: 150px;
1332
+ overflow: auto;
1333
+ font-size: 11px;
1334
+ "></div>
1335
+ </div>
1336
+
1337
+ </div>
1338
+
1339
+ <!-- Status Bar -->
1340
+ <div style="
1341
+ padding: 10px;
1342
+ background: var(--color-bg-darker, #0f0f0f);
1343
+ border-top: 1px solid var(--color-border, #333);
1344
+ font-size: 10px;
1345
+ color: var(--color-text-secondary, #a0a0a0);
1346
+ ">
1347
+ <div id="sa-status" style="margin-bottom: 5px;">Ready</div>
1348
+ <div style="display: flex; gap: 5px;">
1349
+ <span>Parts: <span id="sa-parts-count">0</span></span>
1350
+ <span>Constraints: <span id="sa-constraints-count">0</span></span>
1351
+ <span>Joints: <span id="sa-joints-count">0</span></span>
1352
+ </div>
1353
+ </div>
1354
+ </div>
1355
+ `;
1356
+
1357
+ const panelDiv = document.createElement('div');
1358
+ panelDiv.innerHTML = tabHtml;
1359
+
1360
+ // Attach event handlers
1361
+ attachEventHandlers(panelDiv);
1362
+
1363
+ return panelDiv;
1364
+ }
1365
+
1366
+ /**
1367
+ * Attach event handlers to UI elements
1368
+ * @param {HTMLElement} panelDiv - Panel root element
1369
+ */
1370
+ function attachEventHandlers(panelDiv) {
1371
+ // Tab switching
1372
+ panelDiv.querySelectorAll('.sa-tab-btn').forEach(btn => {
1373
+ btn.addEventListener('click', (e) => {
1374
+ const tabName = e.target.dataset.tab;
1375
+ panelDiv.querySelectorAll('.sa-tab-content').forEach(t => t.style.display = 'none');
1376
+ panelDiv.querySelector(`#sa-${tabName}`).style.display = 'block';
1377
+
1378
+ // Update button styles
1379
+ panelDiv.querySelectorAll('.sa-tab-btn').forEach(b => {
1380
+ b.style.background = b === e.target ? 'var(--color-accent, #007acc)' : 'transparent';
1381
+ });
1382
+
1383
+ // Refresh data when tab opens
1384
+ if (tabName === 'motion') refreshMotionTab(panelDiv);
1385
+ if (tabName === 'tree') refreshTreeTab(panelDiv);
1386
+ if (tabName === 'visual') refreshVisualTab(panelDiv);
1387
+ });
1388
+ });
1389
+
1390
+ // Mate controls
1391
+ panelDiv.querySelector('#sa-apply-mate').addEventListener('click', () => {
1392
+ applyMateFromUI(panelDiv);
1393
+ });
1394
+
1395
+ panelDiv.querySelector('#sa-auto-mate').addEventListener('click', () => {
1396
+ autoMateAll();
1397
+ refreshConstraintsDisplay(panelDiv);
1398
+ });
1399
+
1400
+ // Motion controls
1401
+ panelDiv.querySelector('#sa-motion-play').addEventListener('click', () => {
1402
+ if (motionStudies.length > 0) {
1403
+ playMotion(motionStudies[0].id);
1404
+ }
1405
+ });
1406
+
1407
+ panelDiv.querySelector('#sa-motion-stop').addEventListener('click', () => {
1408
+ motionStudies.forEach(j => stopMotion(j.id));
1409
+ });
1410
+
1411
+ // Speed slider
1412
+ panelDiv.querySelector('#sa-speed').addEventListener('input', (e) => {
1413
+ const speed = parseFloat(e.target.value);
1414
+ if (motionStudies.length > 0) {
1415
+ motionStudies[0].speed = speed;
1416
+ }
1417
+ panelDiv.querySelector('#sa-speed-display').textContent = speed.toFixed(1) + 'x';
1418
+ });
1419
+
1420
+ // Explode slider
1421
+ panelDiv.querySelector('#sa-explode-slider').addEventListener('input', (e) => {
1422
+ explodeAmount = parseInt(e.target.value);
1423
+ panelDiv.querySelector('#sa-explode-display').textContent = explodeAmount + '%';
1424
+ applyExplode();
1425
+ });
1426
+
1427
+ // Constraint visibility toggle
1428
+ panelDiv.querySelector('#sa-show-constraints').addEventListener('change', (e) => {
1429
+ constraintVisualsEnabled = e.target.checked;
1430
+ visualizeConstraints();
1431
+ });
1432
+
1433
+ // Initial refresh
1434
+ refreshConstraintsDisplay(panelDiv);
1435
+ refreshStatusBar(panelDiv);
1436
+ }
1437
+
1438
+ /**
1439
+ * Apply mate constraint from UI selection
1440
+ */
1441
+ function applyMateFromUI(panelDiv) {
1442
+ if (selectedParts.length < 2) {
1443
+ panelDiv.querySelector('#sa-status').textContent = 'Select 2 features first';
1444
+ return;
1445
+ }
1446
+
1447
+ const constraintType = panelDiv.querySelector('#sa-constraint-type').value;
1448
+ const paramValue = parseFloat(panelDiv.querySelector('#sa-param-value').value) || 0;
1449
+
1450
+ const [part1, f1, part2, f2] = selectedParts.slice(0, 4);
1451
+ const constraint = createConstraint(part1, f1, part2, f2, constraintType);
1452
+
1453
+ if (constraintType === CONSTRAINT_TYPES.DISTANCE) {
1454
+ constraint.parameters.distance = paramValue;
1455
+ } else if (constraintType === CONSTRAINT_TYPES.ANGLE) {
1456
+ constraint.parameters.angle = paramValue;
1457
+ }
1458
+
1459
+ solveConstraints();
1460
+ visualizeConstraints();
1461
+ refreshConstraintsDisplay(panelDiv);
1462
+ refreshStatusBar(panelDiv);
1463
+ selectedParts = [];
1464
+ panelDiv.querySelector('#sa-status').textContent = `Constraint ${constraint.id} applied`;
1465
+ }
1466
+
1467
+ /**
1468
+ * Refresh motion tab display
1469
+ */
1470
+ function refreshMotionTab(panelDiv) {
1471
+ const jointsList = panelDiv.querySelector('#sa-joints-list');
1472
+ jointsList.innerHTML = motionStudies.map(j => `
1473
+ <div style="
1474
+ padding: 6px;
1475
+ background: var(--color-bg-input, #252525);
1476
+ border-radius: 2px;
1477
+ margin-bottom: 5px;
1478
+ ">
1479
+ <div><strong>${j.id}</strong> (${j.type})</div>
1480
+ <div style="color: var(--color-text-secondary, #a0a0a0);">
1481
+ Range: ${j.range.min.toFixed(1)} - ${j.range.max.toFixed(1)}
1482
+ </div>
1483
+ </div>
1484
+ `).join('');
1485
+
1486
+ const interference = checkInterference(activeMotionStudy || motionStudies[0]);
1487
+ const interfList = panelDiv.querySelector('#sa-interference-list');
1488
+ interfList.innerHTML = interference.length > 0 ?
1489
+ `<div>⚠ ${interference.length} interference(s) detected</div>` :
1490
+ '<div style="color: var(--color-success, #22aa22);">No interferences</div>';
1491
+ }
1492
+
1493
+ /**
1494
+ * Refresh tree tab display
1495
+ */
1496
+ function refreshTreeTab(panelDiv) {
1497
+ const treeView = panelDiv.querySelector('#sa-tree-view');
1498
+ treeView.innerHTML = assemblyTree.parts.map(p => {
1499
+ const status = getConstraintStatus(p.id);
1500
+ const statusColor = status.under > 0 ? 'var(--color-warning, #ff9900)' :
1501
+ status.over > 0 ? 'var(--color-warning, #ff9900)' :
1502
+ 'var(--color-success, #22aa22)';
1503
+
1504
+ return `
1505
+ <div style="
1506
+ padding: 6px;
1507
+ background: var(--color-bg-input, #252525);
1508
+ border-radius: 2px;
1509
+ margin-bottom: 5px;
1510
+ ">
1511
+ <div style="display: flex; justify-content: space-between; align-items: center;">
1512
+ <strong>${p.name}</strong>
1513
+ <span style="color: ${statusColor}; font-size: 10px;">
1514
+ ${status.satisfied}/${status.total} constraints
1515
+ </span>
1516
+ </div>
1517
+ </div>
1518
+ `;
1519
+ }).join('');
1520
+ }
1521
+
1522
+ /**
1523
+ * Refresh visual tab display
1524
+ */
1525
+ function refreshVisualTab(panelDiv) {
1526
+ const featuresList = panelDiv.querySelector('#sa-features-list');
1527
+ let html = '';
1528
+ detectedFeatures.forEach((features, partId) => {
1529
+ html += `<div style="font-weight: 600; margin-bottom: 5px;">${partId}</div>`;
1530
+ features.forEach(f => {
1531
+ html += `
1532
+ <div style="
1533
+ padding: 4px 8px;
1534
+ background: var(--color-bg-darker, #0f0f0f);
1535
+ margin-bottom: 3px;
1536
+ border-left: 2px solid var(--color-accent, #007acc);
1537
+ ">
1538
+ ${f.type} (confidence: ${(f.confidence * 100).toFixed(0)}%)
1539
+ </div>
1540
+ `;
1541
+ });
1542
+ });
1543
+ featuresList.innerHTML = html || '<div style="color: var(--color-text-secondary, #a0a0a0);">No features detected</div>';
1544
+ }
1545
+
1546
+ /**
1547
+ * Refresh constraints display
1548
+ */
1549
+ function refreshConstraintsDisplay(panelDiv) {
1550
+ const list = panelDiv.querySelector('#sa-constraints-list');
1551
+ list.innerHTML = constraints.map(c => `
1552
+ <div style="
1553
+ padding: 6px;
1554
+ background: var(--color-bg-input, #252525);
1555
+ border-radius: 2px;
1556
+ margin-bottom: 5px;
1557
+ ">
1558
+ <div style="display: flex; justify-content: space-between;">
1559
+ <span><strong>${c.type}</strong></span>
1560
+ <button style="
1561
+ padding: 2px 8px;
1562
+ background: var(--color-warning, #ff9900);
1563
+ color: white;
1564
+ border: none;
1565
+ border-radius: 2px;
1566
+ font-size: 10px;
1567
+ cursor: pointer;
1568
+ " onclick="window.CycleCAD.SmartAssembly.removeConstraint('${c.id}')">Delete</button>
1569
+ </div>
1570
+ <div style="font-size: 10px; color: var(--color-text-secondary, #a0a0a0);">
1571
+ ${c.part1} → ${c.part2}
1572
+ </div>
1573
+ </div>
1574
+ `).join('');
1575
+ }
1576
+
1577
+ /**
1578
+ * Refresh status bar
1579
+ */
1580
+ function refreshStatusBar(panelDiv) {
1581
+ panelDiv.querySelector('#sa-parts-count').textContent = assemblyTree.parts.length;
1582
+ panelDiv.querySelector('#sa-constraints-count').textContent = constraints.length;
1583
+ panelDiv.querySelector('#sa-joints-count').textContent = motionStudies.length;
1584
+ }
1585
+
1586
+ /**
1587
+ * Apply explode transformation to all parts
1588
+ */
1589
+ function applyExplode() {
1590
+ const centerOfMass = new THREE.Vector3();
1591
+ assemblyTree.parts.forEach(p => centerOfMass.add(p.mesh.position));
1592
+ centerOfMass.divideScalar(Math.max(1, assemblyTree.parts.length));
1593
+
1594
+ const factor = explodeAmount / 100;
1595
+ assemblyTree.parts.forEach(p => {
1596
+ const direction = p.mesh.position.clone().sub(centerOfMass).normalize();
1597
+ const distance = p.mesh.position.distanceTo(centerOfMass);
1598
+ p.mesh.position.copy(centerOfMass).add(direction.multiplyScalar(distance * (1 + factor * 0.5)));
1599
+ });
1600
+ }
1601
+
1602
+ /**
1603
+ * Execute command (for agent API compatibility)
1604
+ * @param {string} method - Method name
1605
+ * @param {Object} params - Parameters
1606
+ * @returns {*} Result
1607
+ */
1608
+ function execute(method, params) {
1609
+ switch (method) {
1610
+ case 'detectFeatures':
1611
+ return detectFeatures(params.mesh, params.partId);
1612
+ case 'createConstraint':
1613
+ return createConstraint(params.part1, params.feature1, params.part2, params.feature2, params.type);
1614
+ case 'solveConstraints':
1615
+ return solveConstraints(params.iterations);
1616
+ case 'autoMateAll':
1617
+ return autoMateAll(params.threshold);
1618
+ case 'playMotion':
1619
+ return playMotion(params.jointId);
1620
+ case 'stopMotion':
1621
+ return stopMotion(params.jointId);
1622
+ case 'addPart':
1623
+ return addPart(params.mesh, params.partId, params.name);
1624
+ default:
1625
+ return null;
1626
+ }
1627
+ }
1628
+
1629
+ /**
1630
+ * Remove a constraint by ID (public method for UI)
1631
+ * @param {string} constraintId - Constraint ID
1632
+ */
1633
+ function removeConstraint(constraintId) {
1634
+ constraints = constraints.filter(c => c.id !== constraintId);
1635
+ visualizeConstraints();
1636
+ }
1637
+
1638
+ // =========================================================================
1639
+ // PUBLIC API
1640
+ // =========================================================================
1641
+
1642
+ return {
1643
+ init,
1644
+ getUI,
1645
+ execute,
1646
+ detectFeatures,
1647
+ createConstraint,
1648
+ solveConstraints,
1649
+ suggestMates,
1650
+ autoMateAll,
1651
+ detectMates: suggestMates, // Alias
1652
+ getConstraints: () => constraints,
1653
+ addPart,
1654
+ defineJoint,
1655
+ playMotion,
1656
+ stopMotion,
1657
+ updateMotion,
1658
+ checkInterference,
1659
+ visualizeConstraints,
1660
+ removeConstraint,
1661
+ get constraints() { return constraints; },
1662
+ get motionStudies() { return motionStudies; },
1663
+ get assemblyTree() { return assemblyTree; }
1664
+ };
1665
+ })();
1666
+
1667
+ window.CycleCAD.SmartAssembly = SmartAssembly;