cyclecad 3.7.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.
- package/app/HELP-QUICK-START.md +207 -0
- package/app/HELP-SYSTEM-README.md +287 -0
- package/app/help-viewer.html +805 -0
- package/app/index.html +96 -0
- package/app/js/killer-features-help.json +310 -391
- package/app/js/modules/auto-assembly.js +1146 -0
- package/app/js/modules/digital-twin.js +1225 -0
- package/app/js/modules/engineering-notebook.js +1505 -0
- package/app/js/modules/generative-design.js +159 -6
- package/app/js/modules/machine-control.js +1270 -0
- package/app/js/modules/manufacturability.js +170 -3
- package/app/js/modules/multi-physics.js +167 -7
- package/app/js/modules/parametric-from-example.js +900 -0
- package/app/js/modules/photo-to-cad.js +200 -10
- package/app/js/modules/smart-assembly.js +1667 -0
- package/app/js/modules/smart-parts.js +179 -9
- package/app/js/modules/text-to-cad.js +242 -33
- package/app/tests/KILLER_FEATURES_TEST_GUIDE.md +324 -0
- package/app/tests/index.html +24 -7
- package/app/tests/killer-features-visual-test.html +1362 -0
- package/docs/KILLER-FEATURES-GUIDE.md +2728 -0
- package/docs/KILLER-FEATURES-TUTORIAL.md +1663 -5
- package/package.json +1 -1
|
@@ -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;
|