cyclecad 0.1.4 → 0.1.7
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/CLAUDE.md +20 -9
- package/app/index.html +451 -3
- package/app/js/advanced-ops.js +762 -0
- package/app/js/assembly.js +1102 -0
- package/app/js/constraint-solver.js +1046 -0
- package/app/js/dxf-export.js +1173 -0
- package/app/js/viewport.js +83 -0
- package/app/mobile.html +1276 -0
- package/package.json +1 -1
- package/DUO-MANIFEST-README.md +0 -233
- package/app/duo-manifest-demo.html +0 -337
- package/app/duo-manifest.json +0 -7375
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembly Workspace Module — cycleCAD
|
|
3
|
+
*
|
|
4
|
+
* Manages assembly construction with components, mate constraints, joints, and motion.
|
|
5
|
+
* Phase 6 of the cycleCAD roadmap.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Component management (add, remove, reposition)
|
|
9
|
+
* - Mate constraints (flush, mate, insert, angle, tangent)
|
|
10
|
+
* - Joint system (revolute, prismatic, cylindrical, ball, fixed, planar)
|
|
11
|
+
* - Iterative constraint solver with convergence checking
|
|
12
|
+
* - Joint animation with smooth keyframe interpolation
|
|
13
|
+
* - Explode/collapse assembly views
|
|
14
|
+
* - BOM generation and assembly statistics
|
|
15
|
+
* - Constraint and joint visualization markers
|
|
16
|
+
* - Assembly serialization (save/load)
|
|
17
|
+
* - Sub-assembly grouping
|
|
18
|
+
*
|
|
19
|
+
* @module assembly
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
|
|
23
|
+
|
|
24
|
+
/** @typedef {Object} Component - A part in the assembly */
|
|
25
|
+
/**
|
|
26
|
+
* @property {string} id - Unique component ID
|
|
27
|
+
* @property {string} name - Component name
|
|
28
|
+
* @property {THREE.Mesh} mesh - Three.js mesh object
|
|
29
|
+
* @property {THREE.Vector3} position - World position
|
|
30
|
+
* @property {THREE.Euler} rotation - World rotation
|
|
31
|
+
* @property {Array} constraints - Mate constraints involving this component
|
|
32
|
+
* @property {Array} joints - Joints involving this component
|
|
33
|
+
* @property {boolean} grounded - Is this component fixed (assembly origin)?
|
|
34
|
+
* @property {Object} originalTransform - Saved position/rotation for explode restore
|
|
35
|
+
* @property {string|null} parentSubAssembly - Sub-assembly ID if grouped
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** @typedef {Object} Mate - A mate constraint between two components */
|
|
39
|
+
/**
|
|
40
|
+
* @property {string} id - Unique mate ID
|
|
41
|
+
* @property {string} type - 'flush'|'mate'|'insert'|'angle'|'tangent'
|
|
42
|
+
* @property {string} comp1Id - First component ID
|
|
43
|
+
* @property {string} comp2Id - Second component ID
|
|
44
|
+
* @property {THREE.Vector3} face1Normal - Normal of first face
|
|
45
|
+
* @property {THREE.Vector3} face1Point - Point on first face
|
|
46
|
+
* @property {THREE.Vector3} face2Normal - Normal of second face
|
|
47
|
+
* @property {THREE.Vector3} face2Point - Point on second face
|
|
48
|
+
* @property {number} offset - Distance offset (for insert, mate)
|
|
49
|
+
* @property {number} angle - Angle for angle constraints
|
|
50
|
+
* @property {boolean} flip - Flip normal direction
|
|
51
|
+
* @property {number} tolerance - Convergence tolerance (mm)
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** @typedef {Object} Joint - A motion joint between two components */
|
|
55
|
+
/**
|
|
56
|
+
* @property {string} id - Unique joint ID
|
|
57
|
+
* @property {string} type - 'revolute'|'prismatic'|'cylindrical'|'ball'|'fixed'|'planar'
|
|
58
|
+
* @property {string} comp1Id - First component (origin)
|
|
59
|
+
* @property {string} comp2Id - Second component (moved)
|
|
60
|
+
* @property {THREE.Vector3} axis - Joint axis direction (normalized)
|
|
61
|
+
* @property {THREE.Vector3} point - Joint origin point (world)
|
|
62
|
+
* @property {Object} limits - { min, max } in radians (revolute) or mm (prismatic)
|
|
63
|
+
* @property {number} damping - Damping factor 0-1
|
|
64
|
+
* @property {number} currentValue - Current joint position (rad or mm)
|
|
65
|
+
* @property {Array} keyframes - Array of { value, transform } for saved positions
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Assembly Workspace state and functions
|
|
70
|
+
* @namespace Assembly
|
|
71
|
+
*/
|
|
72
|
+
const Assembly = {
|
|
73
|
+
// Assembly state
|
|
74
|
+
components: new Map(), // id → Component
|
|
75
|
+
mates: new Map(), // id → Mate
|
|
76
|
+
joints: new Map(), // id → Joint
|
|
77
|
+
subAssemblies: new Map(), // id → { name, componentIds }
|
|
78
|
+
constraintMarkers: [], // THREE.Object3D
|
|
79
|
+
jointAxisMarkers: [], // THREE.Object3D
|
|
80
|
+
|
|
81
|
+
// Scene integration
|
|
82
|
+
assemblyGroup: null, // THREE.Group for all assembly meshes
|
|
83
|
+
scene: null, // Reference to Three.js scene
|
|
84
|
+
ground: null, // ID of grounded component
|
|
85
|
+
|
|
86
|
+
// Configuration
|
|
87
|
+
solverMaxIterations: 50,
|
|
88
|
+
solverTolerance: 0.01, // mm
|
|
89
|
+
constraintColor: 0x00ff00, // Green for mates
|
|
90
|
+
jointColor: 0xffff00, // Yellow for joints
|
|
91
|
+
|
|
92
|
+
// Tracking
|
|
93
|
+
componentIdCounter: 0,
|
|
94
|
+
mateIdCounter: 0,
|
|
95
|
+
jointIdCounter: 0,
|
|
96
|
+
subAssemblyIdCounter: 0,
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Initialize assembly workspace
|
|
100
|
+
* @param {THREE.Scene} scene - Three.js scene to attach to
|
|
101
|
+
* @returns {THREE.Group} assembly group for rendering
|
|
102
|
+
*/
|
|
103
|
+
initAssembly(scene) {
|
|
104
|
+
this.scene = scene;
|
|
105
|
+
this.assemblyGroup = new THREE.Group();
|
|
106
|
+
this.assemblyGroup.name = 'Assembly';
|
|
107
|
+
scene.add(this.assemblyGroup);
|
|
108
|
+
console.log('[Assembly] Workspace initialized');
|
|
109
|
+
return this.assemblyGroup;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Add a component (part) to the assembly
|
|
114
|
+
* @param {THREE.Mesh} mesh - The part mesh
|
|
115
|
+
* @param {string} name - Display name
|
|
116
|
+
* @param {Object} options - { position: Vector3, rotation: Euler, grounded: boolean }
|
|
117
|
+
* @returns {string} component ID
|
|
118
|
+
*/
|
|
119
|
+
addComponent(mesh, name, options = {}) {
|
|
120
|
+
const id = `comp_${this.componentIdCounter++}`;
|
|
121
|
+
|
|
122
|
+
const component = {
|
|
123
|
+
id,
|
|
124
|
+
name,
|
|
125
|
+
mesh: mesh.clone(),
|
|
126
|
+
position: options.position ? options.position.clone() : new THREE.Vector3(),
|
|
127
|
+
rotation: options.rotation ? options.rotation.clone() : new THREE.Euler(),
|
|
128
|
+
constraints: [],
|
|
129
|
+
joints: [],
|
|
130
|
+
grounded: options.grounded || false,
|
|
131
|
+
originalTransform: {
|
|
132
|
+
position: options.position ? options.position.clone() : new THREE.Vector3(),
|
|
133
|
+
rotation: options.rotation ? options.rotation.clone() : new THREE.Euler(),
|
|
134
|
+
},
|
|
135
|
+
parentSubAssembly: null,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Apply transform to mesh
|
|
139
|
+
component.mesh.position.copy(component.position);
|
|
140
|
+
component.mesh.rotation.copy(component.rotation);
|
|
141
|
+
this.assemblyGroup.add(component.mesh);
|
|
142
|
+
|
|
143
|
+
this.components.set(id, component);
|
|
144
|
+
console.log(`[Assembly] Added component: ${name} (${id})`);
|
|
145
|
+
return id;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove a component from the assembly
|
|
150
|
+
* @param {string} componentId - ID of component to remove
|
|
151
|
+
* @returns {boolean} success
|
|
152
|
+
*/
|
|
153
|
+
removeComponent(componentId) {
|
|
154
|
+
const component = this.components.get(componentId);
|
|
155
|
+
if (!component) return false;
|
|
156
|
+
|
|
157
|
+
// Remove associated mates and joints
|
|
158
|
+
component.constraints.forEach(mateId => this.removeMate(mateId));
|
|
159
|
+
component.joints.forEach(jointId => this.removeJoint(jointId));
|
|
160
|
+
|
|
161
|
+
// Remove mesh from scene
|
|
162
|
+
this.assemblyGroup.remove(component.mesh);
|
|
163
|
+
this.components.delete(componentId);
|
|
164
|
+
|
|
165
|
+
console.log(`[Assembly] Removed component: ${component.name}`);
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reposition a component
|
|
171
|
+
* @param {string} componentId - Component ID
|
|
172
|
+
* @param {THREE.Vector3} position - New position
|
|
173
|
+
* @param {THREE.Euler} rotation - New rotation
|
|
174
|
+
*/
|
|
175
|
+
moveComponent(componentId, position, rotation) {
|
|
176
|
+
const component = this.components.get(componentId);
|
|
177
|
+
if (!component) return;
|
|
178
|
+
|
|
179
|
+
if (position) {
|
|
180
|
+
component.position.copy(position);
|
|
181
|
+
component.mesh.position.copy(position);
|
|
182
|
+
}
|
|
183
|
+
if (rotation) {
|
|
184
|
+
component.rotation.copy(rotation);
|
|
185
|
+
component.mesh.rotation.copy(rotation);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Ground a component (fix it as assembly origin)
|
|
191
|
+
* @param {string} componentId - Component ID to ground
|
|
192
|
+
*/
|
|
193
|
+
groundComponent(componentId) {
|
|
194
|
+
const component = this.components.get(componentId);
|
|
195
|
+
if (!component) return;
|
|
196
|
+
|
|
197
|
+
// Unground previous ground
|
|
198
|
+
if (this.ground) {
|
|
199
|
+
const groundComp = this.components.get(this.ground);
|
|
200
|
+
if (groundComp) groundComp.grounded = false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
component.grounded = true;
|
|
204
|
+
this.ground = componentId;
|
|
205
|
+
console.log(`[Assembly] Grounded component: ${component.name}`);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get component data
|
|
210
|
+
* @param {string} componentId - Component ID
|
|
211
|
+
* @returns {Component|null}
|
|
212
|
+
*/
|
|
213
|
+
getComponent(componentId) {
|
|
214
|
+
return this.components.get(componentId) || null;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get all components
|
|
219
|
+
* @returns {Component[]}
|
|
220
|
+
*/
|
|
221
|
+
getAllComponents() {
|
|
222
|
+
return Array.from(this.components.values());
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Add a mate constraint between two components
|
|
227
|
+
* @param {string} type - 'flush'|'mate'|'insert'|'angle'|'tangent'
|
|
228
|
+
* @param {string} comp1Id - First component ID
|
|
229
|
+
* @param {string} comp2Id - Second component ID
|
|
230
|
+
* @param {Object} params - { face1, face2, offset, angle, flip, tolerance }
|
|
231
|
+
* @returns {string} mate ID
|
|
232
|
+
*/
|
|
233
|
+
addMate(type, comp1Id, comp2Id, params = {}) {
|
|
234
|
+
const comp1 = this.components.get(comp1Id);
|
|
235
|
+
const comp2 = this.components.get(comp2Id);
|
|
236
|
+
if (!comp1 || !comp2) return null;
|
|
237
|
+
|
|
238
|
+
const id = `mate_${this.mateIdCounter++}`;
|
|
239
|
+
|
|
240
|
+
const mate = {
|
|
241
|
+
id,
|
|
242
|
+
type,
|
|
243
|
+
comp1Id,
|
|
244
|
+
comp2Id,
|
|
245
|
+
face1Normal: params.face1Normal ? params.face1Normal.clone().normalize() : new THREE.Vector3(0, 0, 1),
|
|
246
|
+
face1Point: params.face1Point ? params.face1Point.clone() : new THREE.Vector3(),
|
|
247
|
+
face2Normal: params.face2Normal ? params.face2Normal.clone().normalize() : new THREE.Vector3(0, 0, -1),
|
|
248
|
+
face2Point: params.face2Point ? params.face2Point.clone() : new THREE.Vector3(),
|
|
249
|
+
offset: params.offset || 0,
|
|
250
|
+
angle: params.angle || 0,
|
|
251
|
+
flip: params.flip || false,
|
|
252
|
+
tolerance: params.tolerance || this.solverTolerance,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Apply flip if requested
|
|
256
|
+
if (mate.flip) {
|
|
257
|
+
mate.face2Normal.multiplyScalar(-1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.mates.set(id, mate);
|
|
261
|
+
comp1.constraints.push(id);
|
|
262
|
+
comp2.constraints.push(id);
|
|
263
|
+
|
|
264
|
+
console.log(`[Assembly] Added ${type} mate between ${comp1.name} and ${comp2.name}`);
|
|
265
|
+
return id;
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Remove a mate constraint
|
|
270
|
+
* @param {string} mateId - Mate ID
|
|
271
|
+
* @returns {boolean} success
|
|
272
|
+
*/
|
|
273
|
+
removeMate(mateId) {
|
|
274
|
+
const mate = this.mates.get(mateId);
|
|
275
|
+
if (!mate) return false;
|
|
276
|
+
|
|
277
|
+
const comp1 = this.components.get(mate.comp1Id);
|
|
278
|
+
const comp2 = this.components.get(mate.comp2Id);
|
|
279
|
+
|
|
280
|
+
if (comp1) comp1.constraints = comp1.constraints.filter(id => id !== mateId);
|
|
281
|
+
if (comp2) comp2.constraints = comp2.constraints.filter(id => id !== mateId);
|
|
282
|
+
|
|
283
|
+
this.mates.delete(mateId);
|
|
284
|
+
return true;
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Solve all mate constraints iteratively
|
|
289
|
+
* Uses Gauss-Seidel-style relaxation: fix grounded, move others to satisfy constraints
|
|
290
|
+
* @returns {Object} { converged: boolean, iterations: number, error: number }
|
|
291
|
+
*/
|
|
292
|
+
solveMates() {
|
|
293
|
+
let converged = false;
|
|
294
|
+
let iteration = 0;
|
|
295
|
+
let maxError = Infinity;
|
|
296
|
+
|
|
297
|
+
while (iteration < this.solverMaxIterations && !converged) {
|
|
298
|
+
maxError = 0;
|
|
299
|
+
|
|
300
|
+
// For each mate constraint
|
|
301
|
+
for (const mate of this.mates.values()) {
|
|
302
|
+
const comp1 = this.components.get(mate.comp1Id);
|
|
303
|
+
const comp2 = this.components.get(mate.comp2Id);
|
|
304
|
+
if (!comp1 || !comp2) continue;
|
|
305
|
+
|
|
306
|
+
// Skip if both grounded
|
|
307
|
+
if (comp1.grounded && comp2.grounded) continue;
|
|
308
|
+
|
|
309
|
+
// Determine which to move
|
|
310
|
+
const moveComp = comp1.grounded ? comp2 : comp1;
|
|
311
|
+
const fixComp = comp1.grounded ? comp1 : comp2;
|
|
312
|
+
const moveMate = comp1.grounded ? mate : this._flipMate(mate);
|
|
313
|
+
const fixMate = comp1.grounded ? mate : this._flipMate(mate);
|
|
314
|
+
|
|
315
|
+
// Calculate correction
|
|
316
|
+
const correction = this._calculateMateCorrection(moveMate, fixMate, moveComp, fixComp);
|
|
317
|
+
maxError = Math.max(maxError, correction.distance);
|
|
318
|
+
|
|
319
|
+
// Apply correction
|
|
320
|
+
if (correction.distance > moveComp.constraints.length * 0.001) { // Only apply if error is significant
|
|
321
|
+
moveComp.position.add(correction.translation);
|
|
322
|
+
const targetRotation = new THREE.Quaternion().setFromAxisAngle(
|
|
323
|
+
correction.rotationAxis,
|
|
324
|
+
correction.rotationAngle
|
|
325
|
+
);
|
|
326
|
+
const q = new THREE.Quaternion().setFromEuler(moveComp.rotation);
|
|
327
|
+
q.multiplyQuaternions(targetRotation, q);
|
|
328
|
+
moveComp.rotation.setFromQuaternion(q);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Update mesh transforms
|
|
333
|
+
for (const comp of this.components.values()) {
|
|
334
|
+
if (!comp.grounded) {
|
|
335
|
+
comp.mesh.position.copy(comp.position);
|
|
336
|
+
comp.mesh.rotation.copy(comp.rotation);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
iteration++;
|
|
341
|
+
converged = maxError < this.solverTolerance;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const result = {
|
|
345
|
+
converged,
|
|
346
|
+
iterations: iteration,
|
|
347
|
+
error: maxError,
|
|
348
|
+
};
|
|
349
|
+
console.log(`[Assembly] Constraint solve: ${result.converged ? 'converged' : 'max iterations'} (${result.iterations}/${this.solverMaxIterations}, error=${result.error.toFixed(3)}mm)`);
|
|
350
|
+
return result;
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Internal: Calculate mate constraint correction
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
_calculateMateCorrection(mate, fixMate, moveComp, fixComp) {
|
|
358
|
+
const correction = {
|
|
359
|
+
translation: new THREE.Vector3(),
|
|
360
|
+
rotationAxis: new THREE.Vector3(0, 0, 1),
|
|
361
|
+
rotationAngle: 0,
|
|
362
|
+
distance: 0,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Transform face points to world space
|
|
366
|
+
const face1Point = mate.face1Point.clone().applyEuler(moveComp.rotation).add(moveComp.position);
|
|
367
|
+
const face2Point = fixMate.face2Point.clone().applyEuler(fixComp.rotation).add(fixComp.position);
|
|
368
|
+
const face1Normal = mate.face1Normal.clone().applyEuler(moveComp.rotation).normalize();
|
|
369
|
+
const face2Normal = fixMate.face2Normal.clone().applyEuler(fixComp.rotation).normalize();
|
|
370
|
+
|
|
371
|
+
const gap = face2Point.clone().sub(face1Point);
|
|
372
|
+
const distance = gap.length();
|
|
373
|
+
correction.distance = distance;
|
|
374
|
+
|
|
375
|
+
// Constraint type specific logic
|
|
376
|
+
switch (mate.type) {
|
|
377
|
+
case 'flush':
|
|
378
|
+
// Align normals, bring into contact
|
|
379
|
+
correction.translation.copy(gap);
|
|
380
|
+
correction.rotationAxis.crossVectors(face1Normal, face2Normal).normalize();
|
|
381
|
+
correction.rotationAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(face2Normal)))) * 0.1;
|
|
382
|
+
break;
|
|
383
|
+
|
|
384
|
+
case 'mate':
|
|
385
|
+
// Align normals opposite, bring into contact
|
|
386
|
+
const oppNormal = face2Normal.clone().multiplyScalar(-1);
|
|
387
|
+
correction.translation.copy(gap);
|
|
388
|
+
correction.rotationAxis.crossVectors(face1Normal, oppNormal).normalize();
|
|
389
|
+
correction.rotationAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(oppNormal)))) * 0.1;
|
|
390
|
+
break;
|
|
391
|
+
|
|
392
|
+
case 'insert':
|
|
393
|
+
// Coaxial + flush (axes aligned, faces touching)
|
|
394
|
+
correction.translation.copy(gap);
|
|
395
|
+
correction.rotationAxis.crossVectors(face1Normal, face2Normal).normalize();
|
|
396
|
+
correction.rotationAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(face2Normal)))) * 0.1;
|
|
397
|
+
break;
|
|
398
|
+
|
|
399
|
+
case 'angle':
|
|
400
|
+
// Maintain specified angle between normals
|
|
401
|
+
const targetAngle = mate.angle;
|
|
402
|
+
const currentAngle = Math.acos(Math.min(1, Math.max(-1, face1Normal.dot(face2Normal))));
|
|
403
|
+
const angleDiff = currentAngle - targetAngle;
|
|
404
|
+
correction.translation.copy(gap.multiplyScalar(0.5));
|
|
405
|
+
correction.rotationAxis.crossVectors(face1Normal, face2Normal).normalize();
|
|
406
|
+
correction.rotationAngle = angleDiff * 0.05;
|
|
407
|
+
break;
|
|
408
|
+
|
|
409
|
+
case 'tangent':
|
|
410
|
+
// Face tangent to cylinder (distance = cylinder radius)
|
|
411
|
+
correction.translation.copy(gap.multiplyScalar(0.5));
|
|
412
|
+
correction.rotationAxis.copy(face1Normal);
|
|
413
|
+
correction.rotationAngle = 0;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Damping
|
|
418
|
+
correction.translation.multiplyScalar(0.3);
|
|
419
|
+
correction.rotationAngle *= 0.3;
|
|
420
|
+
|
|
421
|
+
return correction;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Internal: Flip mate constraint (swap comp1/comp2)
|
|
426
|
+
* @private
|
|
427
|
+
*/
|
|
428
|
+
_flipMate(mate) {
|
|
429
|
+
return {
|
|
430
|
+
...mate,
|
|
431
|
+
face1Point: mate.face2Point.clone(),
|
|
432
|
+
face1Normal: mate.face2Normal.clone(),
|
|
433
|
+
face2Point: mate.face1Point.clone(),
|
|
434
|
+
face2Normal: mate.face1Normal.clone(),
|
|
435
|
+
};
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Add a motion joint between two components
|
|
440
|
+
* @param {string} type - 'revolute'|'prismatic'|'cylindrical'|'ball'|'fixed'|'planar'
|
|
441
|
+
* @param {string} comp1Id - First component (origin)
|
|
442
|
+
* @param {string} comp2Id - Second component (moved)
|
|
443
|
+
* @param {Object} params - { axis, point, limits: { min, max }, damping }
|
|
444
|
+
* @returns {string} joint ID
|
|
445
|
+
*/
|
|
446
|
+
addJoint(type, comp1Id, comp2Id, params = {}) {
|
|
447
|
+
const comp1 = this.components.get(comp1Id);
|
|
448
|
+
const comp2 = this.components.get(comp2Id);
|
|
449
|
+
if (!comp1 || !comp2) return null;
|
|
450
|
+
|
|
451
|
+
const id = `joint_${this.jointIdCounter++}`;
|
|
452
|
+
|
|
453
|
+
const joint = {
|
|
454
|
+
id,
|
|
455
|
+
type,
|
|
456
|
+
comp1Id,
|
|
457
|
+
comp2Id,
|
|
458
|
+
axis: params.axis ? params.axis.clone().normalize() : new THREE.Vector3(0, 0, 1),
|
|
459
|
+
point: params.point ? params.point.clone() : new THREE.Vector3(),
|
|
460
|
+
limits: {
|
|
461
|
+
min: params.limits?.min ?? (type === 'revolute' || type === 'cylindrical' ? -Math.PI : -100),
|
|
462
|
+
max: params.limits?.max ?? (type === 'revolute' || type === 'cylindrical' ? Math.PI : 100),
|
|
463
|
+
},
|
|
464
|
+
damping: params.damping ?? 0.8,
|
|
465
|
+
currentValue: 0,
|
|
466
|
+
keyframes: [],
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
this.joints.set(id, joint);
|
|
470
|
+
comp1.joints.push(id);
|
|
471
|
+
comp2.joints.push(id);
|
|
472
|
+
|
|
473
|
+
console.log(`[Assembly] Added ${type} joint between ${comp1.name} and ${comp2.name}`);
|
|
474
|
+
return id;
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Remove a joint
|
|
479
|
+
* @param {string} jointId - Joint ID
|
|
480
|
+
* @returns {boolean} success
|
|
481
|
+
*/
|
|
482
|
+
removeJoint(jointId) {
|
|
483
|
+
const joint = this.joints.get(jointId);
|
|
484
|
+
if (!joint) return false;
|
|
485
|
+
|
|
486
|
+
const comp1 = this.components.get(joint.comp1Id);
|
|
487
|
+
const comp2 = this.components.get(joint.comp2Id);
|
|
488
|
+
|
|
489
|
+
if (comp1) comp1.joints = comp1.joints.filter(id => id !== jointId);
|
|
490
|
+
if (comp2) comp2.joints = comp2.joints.filter(id => id !== jointId);
|
|
491
|
+
|
|
492
|
+
this.joints.delete(jointId);
|
|
493
|
+
return true;
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Get current joint position/angle
|
|
498
|
+
* @param {string} jointId - Joint ID
|
|
499
|
+
* @returns {number} current value (radians or mm)
|
|
500
|
+
*/
|
|
501
|
+
getJointValue(jointId) {
|
|
502
|
+
const joint = this.joints.get(jointId);
|
|
503
|
+
return joint?.currentValue ?? 0;
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Animate a joint to a target position over duration
|
|
508
|
+
* Uses requestAnimationFrame for smooth keyframe interpolation
|
|
509
|
+
* @param {string} jointId - Joint ID
|
|
510
|
+
* @param {number} targetValue - Target value (radians or mm)
|
|
511
|
+
* @param {number} duration - Duration in milliseconds
|
|
512
|
+
* @returns {Promise} resolves when animation completes
|
|
513
|
+
*/
|
|
514
|
+
animateJoint(jointId, targetValue, duration = 500) {
|
|
515
|
+
return new Promise((resolve) => {
|
|
516
|
+
const joint = this.joints.get(jointId);
|
|
517
|
+
if (!joint) {
|
|
518
|
+
resolve();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const comp2 = this.components.get(joint.comp2Id);
|
|
523
|
+
if (!comp2) {
|
|
524
|
+
resolve();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Clamp to limits
|
|
529
|
+
const clampedTarget = Math.max(joint.limits.min, Math.min(joint.limits.max, targetValue));
|
|
530
|
+
|
|
531
|
+
// Save initial state
|
|
532
|
+
const startValue = joint.currentValue;
|
|
533
|
+
const startPosition = comp2.position.clone();
|
|
534
|
+
const startRotation = comp2.rotation.clone();
|
|
535
|
+
const startTime = Date.now();
|
|
536
|
+
|
|
537
|
+
// Animation loop
|
|
538
|
+
const animationStep = () => {
|
|
539
|
+
const elapsed = Date.now() - startTime;
|
|
540
|
+
const progress = Math.min(1, elapsed / duration);
|
|
541
|
+
|
|
542
|
+
// Easing: ease-out cubic
|
|
543
|
+
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
544
|
+
|
|
545
|
+
// Interpolate joint value
|
|
546
|
+
joint.currentValue = startValue + (clampedTarget - startValue) * easeProgress;
|
|
547
|
+
|
|
548
|
+
// Apply joint transformation
|
|
549
|
+
this._applyJointTransform(joint, comp2, startPosition, startRotation);
|
|
550
|
+
|
|
551
|
+
if (progress < 1) {
|
|
552
|
+
requestAnimationFrame(animationStep);
|
|
553
|
+
} else {
|
|
554
|
+
joint.currentValue = clampedTarget;
|
|
555
|
+
this._applyJointTransform(joint, comp2, startPosition, startRotation);
|
|
556
|
+
resolve();
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
requestAnimationFrame(animationStep);
|
|
561
|
+
});
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Internal: Apply joint transformation to component
|
|
566
|
+
* @private
|
|
567
|
+
*/
|
|
568
|
+
_applyJointTransform(joint, component, initialPosition, initialRotation) {
|
|
569
|
+
const transform = new THREE.Matrix4();
|
|
570
|
+
const translation = new THREE.Matrix4().makeTranslation(joint.point.x, joint.point.y, joint.point.z);
|
|
571
|
+
const invTranslation = new THREE.Matrix4().makeTranslation(-joint.point.x, -joint.point.y, -joint.point.z);
|
|
572
|
+
|
|
573
|
+
switch (joint.type) {
|
|
574
|
+
case 'revolute':
|
|
575
|
+
case 'cylindrical':
|
|
576
|
+
{
|
|
577
|
+
const rotation = new THREE.Matrix4().makeRotationAxis(joint.axis, joint.currentValue);
|
|
578
|
+
transform.multiplyMatrices(translation, rotation);
|
|
579
|
+
transform.multiplyMatrices(transform, invTranslation);
|
|
580
|
+
}
|
|
581
|
+
break;
|
|
582
|
+
|
|
583
|
+
case 'prismatic':
|
|
584
|
+
{
|
|
585
|
+
const translation2 = new THREE.Matrix4().makeTranslation(
|
|
586
|
+
joint.axis.x * joint.currentValue,
|
|
587
|
+
joint.axis.y * joint.currentValue,
|
|
588
|
+
joint.axis.z * joint.currentValue
|
|
589
|
+
);
|
|
590
|
+
transform.copy(translation2);
|
|
591
|
+
}
|
|
592
|
+
break;
|
|
593
|
+
|
|
594
|
+
case 'ball':
|
|
595
|
+
// Simplified: only rotation around Z
|
|
596
|
+
{
|
|
597
|
+
const rotation = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 0, 1), joint.currentValue);
|
|
598
|
+
transform.multiplyMatrices(translation, rotation);
|
|
599
|
+
transform.multiplyMatrices(transform, invTranslation);
|
|
600
|
+
}
|
|
601
|
+
break;
|
|
602
|
+
|
|
603
|
+
case 'planar':
|
|
604
|
+
{
|
|
605
|
+
const translation2 = new THREE.Matrix4().makeTranslation(
|
|
606
|
+
joint.axis.x * joint.currentValue,
|
|
607
|
+
joint.axis.y * joint.currentValue,
|
|
608
|
+
0
|
|
609
|
+
);
|
|
610
|
+
transform.copy(translation2);
|
|
611
|
+
}
|
|
612
|
+
break;
|
|
613
|
+
|
|
614
|
+
case 'fixed':
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Apply to initial position/rotation
|
|
619
|
+
const pos = initialPosition.clone().applyMatrix4(transform);
|
|
620
|
+
component.position.copy(pos);
|
|
621
|
+
component.mesh.position.copy(pos);
|
|
622
|
+
|
|
623
|
+
const rot = new THREE.Quaternion().setFromEuler(initialRotation);
|
|
624
|
+
const rotMatrix = new THREE.Matrix4().makeRotationFromQuaternion(rot);
|
|
625
|
+
rotMatrix.multiply(transform);
|
|
626
|
+
const resultRot = new THREE.Quaternion().setFromRotationMatrix(rotMatrix);
|
|
627
|
+
component.rotation.setFromQuaternion(resultRot);
|
|
628
|
+
component.mesh.rotation.setFromQuaternion(resultRot);
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Explode assembly view (move parts outward)
|
|
633
|
+
* @param {number} factor - Explode distance factor (0-1, 1 = extreme)
|
|
634
|
+
*/
|
|
635
|
+
explodeAssembly(factor) {
|
|
636
|
+
if (!this.ground) return;
|
|
637
|
+
|
|
638
|
+
const groundComp = this.components.get(this.ground);
|
|
639
|
+
const center = groundComp.position.clone();
|
|
640
|
+
|
|
641
|
+
for (const comp of this.components.values()) {
|
|
642
|
+
if (comp.grounded) continue;
|
|
643
|
+
|
|
644
|
+
// Save original transform
|
|
645
|
+
comp.originalTransform.position.copy(comp.position);
|
|
646
|
+
comp.originalTransform.rotation.copy(comp.rotation);
|
|
647
|
+
|
|
648
|
+
// Move outward from center
|
|
649
|
+
const direction = comp.position.clone().sub(center).normalize();
|
|
650
|
+
const distance = comp.position.clone().sub(center).length();
|
|
651
|
+
const explodeDistance = distance * factor * 5;
|
|
652
|
+
|
|
653
|
+
comp.position.copy(center).addScaledVector(direction, distance + explodeDistance);
|
|
654
|
+
comp.mesh.position.copy(comp.position);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
console.log(`[Assembly] Exploded by factor ${factor}`);
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Collapse assembly to original state
|
|
662
|
+
*/
|
|
663
|
+
collapseAssembly() {
|
|
664
|
+
for (const comp of this.components.values()) {
|
|
665
|
+
comp.position.copy(comp.originalTransform.position);
|
|
666
|
+
comp.rotation.copy(comp.originalTransform.rotation);
|
|
667
|
+
comp.mesh.position.copy(comp.position);
|
|
668
|
+
comp.mesh.rotation.copy(comp.rotation);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
console.log('[Assembly] Collapsed to assembled state');
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Generate bill of materials
|
|
676
|
+
* @returns {Array} BOM entries: { id, name, quantity, volume, mass }
|
|
677
|
+
*/
|
|
678
|
+
getAssemblyBOM() {
|
|
679
|
+
const bom = [];
|
|
680
|
+
const nameMap = new Map();
|
|
681
|
+
|
|
682
|
+
for (const comp of this.components.values()) {
|
|
683
|
+
const key = comp.name;
|
|
684
|
+
if (nameMap.has(key)) {
|
|
685
|
+
nameMap.get(key).quantity++;
|
|
686
|
+
} else {
|
|
687
|
+
// Estimate volume from mesh bounding box
|
|
688
|
+
const bbox = new THREE.Box3().setFromObject(comp.mesh);
|
|
689
|
+
const size = bbox.getSize(new THREE.Vector3());
|
|
690
|
+
const volume = size.x * size.y * size.z;
|
|
691
|
+
|
|
692
|
+
nameMap.set(key, {
|
|
693
|
+
name: key,
|
|
694
|
+
quantity: 1,
|
|
695
|
+
volume: volume.toFixed(2),
|
|
696
|
+
components: [comp.id],
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Convert to array
|
|
702
|
+
let index = 0;
|
|
703
|
+
for (const [name, entry] of nameMap.entries()) {
|
|
704
|
+
bom.push({
|
|
705
|
+
id: index++,
|
|
706
|
+
name: entry.name,
|
|
707
|
+
quantity: entry.quantity,
|
|
708
|
+
volume: entry.volume,
|
|
709
|
+
components: entry.components,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return bom;
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Serialize assembly to JSON
|
|
718
|
+
* @returns {Object} assembly state JSON
|
|
719
|
+
*/
|
|
720
|
+
saveAssembly() {
|
|
721
|
+
const data = {
|
|
722
|
+
components: [],
|
|
723
|
+
mates: [],
|
|
724
|
+
joints: [],
|
|
725
|
+
subAssemblies: [],
|
|
726
|
+
ground: this.ground,
|
|
727
|
+
timestamp: new Date().toISOString(),
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// Serialize components
|
|
731
|
+
for (const comp of this.components.values()) {
|
|
732
|
+
data.components.push({
|
|
733
|
+
id: comp.id,
|
|
734
|
+
name: comp.name,
|
|
735
|
+
position: { x: comp.position.x, y: comp.position.y, z: comp.position.z },
|
|
736
|
+
rotation: { x: comp.rotation.x, y: comp.rotation.y, z: comp.rotation.z },
|
|
737
|
+
grounded: comp.grounded,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Serialize mates
|
|
742
|
+
for (const mate of this.mates.values()) {
|
|
743
|
+
data.mates.push({
|
|
744
|
+
id: mate.id,
|
|
745
|
+
type: mate.type,
|
|
746
|
+
comp1Id: mate.comp1Id,
|
|
747
|
+
comp2Id: mate.comp2Id,
|
|
748
|
+
face1Normal: { x: mate.face1Normal.x, y: mate.face1Normal.y, z: mate.face1Normal.z },
|
|
749
|
+
face1Point: { x: mate.face1Point.x, y: mate.face1Point.y, z: mate.face1Point.z },
|
|
750
|
+
face2Normal: { x: mate.face2Normal.x, y: mate.face2Normal.y, z: mate.face2Normal.z },
|
|
751
|
+
face2Point: { x: mate.face2Point.x, y: mate.face2Point.y, z: mate.face2Point.z },
|
|
752
|
+
offset: mate.offset,
|
|
753
|
+
angle: mate.angle,
|
|
754
|
+
flip: mate.flip,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Serialize joints
|
|
759
|
+
for (const joint of this.joints.values()) {
|
|
760
|
+
data.joints.push({
|
|
761
|
+
id: joint.id,
|
|
762
|
+
type: joint.type,
|
|
763
|
+
comp1Id: joint.comp1Id,
|
|
764
|
+
comp2Id: joint.comp2Id,
|
|
765
|
+
axis: { x: joint.axis.x, y: joint.axis.y, z: joint.axis.z },
|
|
766
|
+
point: { x: joint.point.x, y: joint.point.y, z: joint.point.z },
|
|
767
|
+
limits: joint.limits,
|
|
768
|
+
damping: joint.damping,
|
|
769
|
+
currentValue: joint.currentValue,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Serialize sub-assemblies
|
|
774
|
+
for (const [id, subAsm] of this.subAssemblies.entries()) {
|
|
775
|
+
data.subAssemblies.push({
|
|
776
|
+
id,
|
|
777
|
+
name: subAsm.name,
|
|
778
|
+
componentIds: subAsm.componentIds,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return data;
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Restore assembly from JSON
|
|
787
|
+
* Requires mesh references to be added back manually
|
|
788
|
+
* @param {Object} json - Assembly state JSON from saveAssembly()
|
|
789
|
+
*/
|
|
790
|
+
loadAssembly(json) {
|
|
791
|
+
this.components.clear();
|
|
792
|
+
this.mates.clear();
|
|
793
|
+
this.joints.clear();
|
|
794
|
+
this.subAssemblies.clear();
|
|
795
|
+
this.ground = json.ground;
|
|
796
|
+
|
|
797
|
+
// Restore components (mesh data not preserved; need to re-add)
|
|
798
|
+
for (const compData of json.components) {
|
|
799
|
+
const comp = {
|
|
800
|
+
id: compData.id,
|
|
801
|
+
name: compData.name,
|
|
802
|
+
mesh: null, // Must be set externally
|
|
803
|
+
position: new THREE.Vector3(compData.position.x, compData.position.y, compData.position.z),
|
|
804
|
+
rotation: new THREE.Euler(compData.rotation.x, compData.rotation.y, compData.rotation.z),
|
|
805
|
+
constraints: [],
|
|
806
|
+
joints: [],
|
|
807
|
+
grounded: compData.grounded,
|
|
808
|
+
originalTransform: {
|
|
809
|
+
position: new THREE.Vector3(compData.position.x, compData.position.y, compData.position.z),
|
|
810
|
+
rotation: new THREE.Euler(compData.rotation.x, compData.rotation.y, compData.rotation.z),
|
|
811
|
+
},
|
|
812
|
+
parentSubAssembly: null,
|
|
813
|
+
};
|
|
814
|
+
this.components.set(compData.id, comp);
|
|
815
|
+
this.componentIdCounter = Math.max(this.componentIdCounter, parseInt(compData.id.split('_')[1]) + 1);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Restore mates
|
|
819
|
+
for (const mateData of json.mates) {
|
|
820
|
+
const mate = {
|
|
821
|
+
id: mateData.id,
|
|
822
|
+
type: mateData.type,
|
|
823
|
+
comp1Id: mateData.comp1Id,
|
|
824
|
+
comp2Id: mateData.comp2Id,
|
|
825
|
+
face1Normal: new THREE.Vector3(mateData.face1Normal.x, mateData.face1Normal.y, mateData.face1Normal.z),
|
|
826
|
+
face1Point: new THREE.Vector3(mateData.face1Point.x, mateData.face1Point.y, mateData.face1Point.z),
|
|
827
|
+
face2Normal: new THREE.Vector3(mateData.face2Normal.x, mateData.face2Normal.y, mateData.face2Normal.z),
|
|
828
|
+
face2Point: new THREE.Vector3(mateData.face2Point.x, mateData.face2Point.y, mateData.face2Point.z),
|
|
829
|
+
offset: mateData.offset,
|
|
830
|
+
angle: mateData.angle,
|
|
831
|
+
flip: mateData.flip,
|
|
832
|
+
tolerance: this.solverTolerance,
|
|
833
|
+
};
|
|
834
|
+
this.mates.set(mateData.id, mate);
|
|
835
|
+
|
|
836
|
+
const comp1 = this.components.get(mateData.comp1Id);
|
|
837
|
+
const comp2 = this.components.get(mateData.comp2Id);
|
|
838
|
+
if (comp1) comp1.constraints.push(mateData.id);
|
|
839
|
+
if (comp2) comp2.constraints.push(mateData.id);
|
|
840
|
+
|
|
841
|
+
this.mateIdCounter = Math.max(this.mateIdCounter, parseInt(mateData.id.split('_')[1]) + 1);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Restore joints
|
|
845
|
+
for (const jointData of json.joints) {
|
|
846
|
+
const joint = {
|
|
847
|
+
id: jointData.id,
|
|
848
|
+
type: jointData.type,
|
|
849
|
+
comp1Id: jointData.comp1Id,
|
|
850
|
+
comp2Id: jointData.comp2Id,
|
|
851
|
+
axis: new THREE.Vector3(jointData.axis.x, jointData.axis.y, jointData.axis.z),
|
|
852
|
+
point: new THREE.Vector3(jointData.point.x, jointData.point.y, jointData.point.z),
|
|
853
|
+
limits: jointData.limits,
|
|
854
|
+
damping: jointData.damping,
|
|
855
|
+
currentValue: jointData.currentValue,
|
|
856
|
+
keyframes: [],
|
|
857
|
+
};
|
|
858
|
+
this.joints.set(jointData.id, joint);
|
|
859
|
+
|
|
860
|
+
const comp1 = this.components.get(jointData.comp1Id);
|
|
861
|
+
const comp2 = this.components.get(jointData.comp2Id);
|
|
862
|
+
if (comp1) comp1.joints.push(jointData.id);
|
|
863
|
+
if (comp2) comp2.joints.push(jointData.id);
|
|
864
|
+
|
|
865
|
+
this.jointIdCounter = Math.max(this.jointIdCounter, parseInt(jointData.id.split('_')[1]) + 1);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Restore sub-assemblies
|
|
869
|
+
for (const subAsmData of json.subAssemblies) {
|
|
870
|
+
this.subAssemblies.set(subAsmData.id, {
|
|
871
|
+
name: subAsmData.name,
|
|
872
|
+
componentIds: subAsmData.componentIds,
|
|
873
|
+
});
|
|
874
|
+
this.subAssemblyIdCounter = Math.max(this.subAssemblyIdCounter, parseInt(subAsmData.id.split('_')[1]) + 1);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
console.log(`[Assembly] Loaded assembly: ${json.components.length} components, ${json.mates.length} mates, ${json.joints.length} joints`);
|
|
878
|
+
},
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Get assembly statistics
|
|
882
|
+
* @returns {Object} { componentCount, mateCount, jointCount, groundedId, dof }
|
|
883
|
+
*/
|
|
884
|
+
getAssemblyStats() {
|
|
885
|
+
// DOF = 6 * (n-1) - constraints
|
|
886
|
+
// Each fixed component reduces DOF by 6
|
|
887
|
+
// Each mate removes up to 5 DOF (varies by type)
|
|
888
|
+
const numComponents = this.components.size;
|
|
889
|
+
const numMates = this.mates.size;
|
|
890
|
+
const numJoints = this.joints.size;
|
|
891
|
+
|
|
892
|
+
// Simplified DOF calculation
|
|
893
|
+
let baseDOF = 6 * Math.max(0, numComponents - 1);
|
|
894
|
+
let constrainedDOF = numMates * 3 + numJoints * 0; // Mates remove ~3 DOF each, joints are prescribed
|
|
895
|
+
const dof = baseDOF - constrainedDOF;
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
componentCount: numComponents,
|
|
899
|
+
mateCount: numMates,
|
|
900
|
+
jointCount: numJoints,
|
|
901
|
+
groundedId: this.ground,
|
|
902
|
+
dof: Math.max(0, dof),
|
|
903
|
+
};
|
|
904
|
+
},
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Show/hide constraint visualization markers
|
|
908
|
+
* @param {boolean} visible - Show markers?
|
|
909
|
+
*/
|
|
910
|
+
showConstraintMarkers(visible) {
|
|
911
|
+
if (visible) {
|
|
912
|
+
// Clear existing markers
|
|
913
|
+
this.constraintMarkers.forEach(marker => this.assemblyGroup.remove(marker));
|
|
914
|
+
this.constraintMarkers = [];
|
|
915
|
+
|
|
916
|
+
// Create markers for each mate
|
|
917
|
+
for (const mate of this.mates.values()) {
|
|
918
|
+
const comp1 = this.components.get(mate.comp1Id);
|
|
919
|
+
const comp2 = this.components.get(mate.comp2Id);
|
|
920
|
+
if (!comp1 || !comp2) continue;
|
|
921
|
+
|
|
922
|
+
// Line connecting constraint points
|
|
923
|
+
const pos1 = mate.face1Point.clone().applyEuler(comp1.rotation).add(comp1.position);
|
|
924
|
+
const pos2 = mate.face2Point.clone().applyEuler(comp2.rotation).add(comp2.position);
|
|
925
|
+
|
|
926
|
+
const geometry = new THREE.BufferGeometry();
|
|
927
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
|
|
928
|
+
pos1.x, pos1.y, pos1.z,
|
|
929
|
+
pos2.x, pos2.y, pos2.z,
|
|
930
|
+
]), 3));
|
|
931
|
+
|
|
932
|
+
const material = new THREE.LineBasicMaterial({ color: this.constraintColor, linewidth: 2 });
|
|
933
|
+
const line = new THREE.Line(geometry, material);
|
|
934
|
+
this.assemblyGroup.add(line);
|
|
935
|
+
this.constraintMarkers.push(line);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
console.log(`[Assembly] Showing ${this.constraintMarkers.length} constraint markers`);
|
|
939
|
+
} else {
|
|
940
|
+
this.constraintMarkers.forEach(marker => this.assemblyGroup.remove(marker));
|
|
941
|
+
this.constraintMarkers = [];
|
|
942
|
+
console.log('[Assembly] Hiding constraint markers');
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Highlight a component with color
|
|
948
|
+
* @param {string} componentId - Component ID
|
|
949
|
+
* @param {number} color - THREE.js color hex
|
|
950
|
+
*/
|
|
951
|
+
highlightComponent(componentId, color = 0xffff00) {
|
|
952
|
+
const comp = this.components.get(componentId);
|
|
953
|
+
if (!comp) return;
|
|
954
|
+
|
|
955
|
+
// Store original material
|
|
956
|
+
if (!comp._originalMaterial) {
|
|
957
|
+
comp._originalMaterial = comp.mesh.material;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Apply highlight material
|
|
961
|
+
const highlightMaterial = new THREE.MeshPhongMaterial({ color, emissive: color, emissiveIntensity: 0.5 });
|
|
962
|
+
comp.mesh.material = highlightMaterial;
|
|
963
|
+
},
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Clear component highlight
|
|
967
|
+
* @param {string} componentId - Component ID
|
|
968
|
+
*/
|
|
969
|
+
clearHighlight(componentId) {
|
|
970
|
+
const comp = this.components.get(componentId);
|
|
971
|
+
if (!comp || !comp._originalMaterial) return;
|
|
972
|
+
|
|
973
|
+
comp.mesh.material = comp._originalMaterial;
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Show/hide joint axis visualization
|
|
978
|
+
* @param {boolean} visible - Show axes?
|
|
979
|
+
*/
|
|
980
|
+
showJointAxes(visible) {
|
|
981
|
+
if (visible) {
|
|
982
|
+
this.jointAxisMarkers.forEach(marker => this.assemblyGroup.remove(marker));
|
|
983
|
+
this.jointAxisMarkers = [];
|
|
984
|
+
|
|
985
|
+
// Create axis arrows for each joint
|
|
986
|
+
for (const joint of this.joints.values()) {
|
|
987
|
+
const origin = joint.point.clone();
|
|
988
|
+
const direction = joint.axis.clone();
|
|
989
|
+
|
|
990
|
+
// Arrow geometry
|
|
991
|
+
const arrowLength = 50;
|
|
992
|
+
const arrowHelper = new THREE.ArrowHelper(direction, origin, arrowLength, this.jointColor);
|
|
993
|
+
this.assemblyGroup.add(arrowHelper);
|
|
994
|
+
this.jointAxisMarkers.push(arrowHelper);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
console.log(`[Assembly] Showing ${this.jointAxisMarkers.length} joint axes`);
|
|
998
|
+
} else {
|
|
999
|
+
this.jointAxisMarkers.forEach(marker => this.assemblyGroup.remove(marker));
|
|
1000
|
+
this.jointAxisMarkers = [];
|
|
1001
|
+
console.log('[Assembly] Hiding joint axes');
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Get assembly tree (for hierarchical display)
|
|
1007
|
+
* @returns {Object} tree structure: { id, name, type, children, components }
|
|
1008
|
+
*/
|
|
1009
|
+
getAssemblyTree() {
|
|
1010
|
+
const tree = {
|
|
1011
|
+
id: 'root',
|
|
1012
|
+
name: 'Assembly',
|
|
1013
|
+
type: 'assembly',
|
|
1014
|
+
children: [],
|
|
1015
|
+
stats: this.getAssemblyStats(),
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
// Add sub-assemblies
|
|
1019
|
+
for (const [subId, subAsm] of this.subAssemblies.entries()) {
|
|
1020
|
+
const subNode = {
|
|
1021
|
+
id: subId,
|
|
1022
|
+
name: subAsm.name,
|
|
1023
|
+
type: 'subAssembly',
|
|
1024
|
+
children: [],
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
// Add components in sub-assembly
|
|
1028
|
+
for (const compId of subAsm.componentIds) {
|
|
1029
|
+
const comp = this.components.get(compId);
|
|
1030
|
+
if (comp) {
|
|
1031
|
+
subNode.children.push({
|
|
1032
|
+
id: compId,
|
|
1033
|
+
name: comp.name,
|
|
1034
|
+
type: 'component',
|
|
1035
|
+
grounded: comp.grounded,
|
|
1036
|
+
constraintCount: comp.constraints.length,
|
|
1037
|
+
jointCount: comp.joints.length,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
tree.children.push(subNode);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Add ungrouped components
|
|
1046
|
+
const ungroupedNode = {
|
|
1047
|
+
id: 'ungrouped',
|
|
1048
|
+
name: 'Components',
|
|
1049
|
+
type: 'group',
|
|
1050
|
+
children: [],
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
for (const comp of this.components.values()) {
|
|
1054
|
+
if (!comp.parentSubAssembly) {
|
|
1055
|
+
ungroupedNode.children.push({
|
|
1056
|
+
id: comp.id,
|
|
1057
|
+
name: comp.name,
|
|
1058
|
+
type: 'component',
|
|
1059
|
+
grounded: comp.grounded,
|
|
1060
|
+
constraintCount: comp.constraints.length,
|
|
1061
|
+
jointCount: comp.joints.length,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (ungroupedNode.children.length > 0) {
|
|
1067
|
+
tree.children.push(ungroupedNode);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return tree;
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Create a sub-assembly from a group of components
|
|
1075
|
+
* @param {string} name - Sub-assembly name
|
|
1076
|
+
* @param {string[]} componentIds - Component IDs to group
|
|
1077
|
+
* @returns {string} sub-assembly ID
|
|
1078
|
+
*/
|
|
1079
|
+
createSubAssembly(name, componentIds) {
|
|
1080
|
+
const id = `subasm_${this.subAssemblyIdCounter++}`;
|
|
1081
|
+
|
|
1082
|
+
const subAsm = {
|
|
1083
|
+
name,
|
|
1084
|
+
componentIds,
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
this.subAssemblies.set(id, subAsm);
|
|
1088
|
+
|
|
1089
|
+
// Mark components as belonging to sub-assembly
|
|
1090
|
+
for (const compId of componentIds) {
|
|
1091
|
+
const comp = this.components.get(compId);
|
|
1092
|
+
if (comp) {
|
|
1093
|
+
comp.parentSubAssembly = id;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
console.log(`[Assembly] Created sub-assembly: ${name} with ${componentIds.length} components`);
|
|
1098
|
+
return id;
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
export default Assembly;
|