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