cyclecad 3.2.1 → 3.5.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/CLAUDE.md +155 -1
- package/DOCKER-SETUP-VERIFICATION.md +399 -0
- package/DOCKER-TESTING.md +463 -0
- package/FUSION360_MODULES.md +478 -0
- package/FUSION_MODULES_README.md +352 -0
- package/INTEGRATION_SNIPPETS.md +608 -0
- package/KILLER-FEATURES-DELIVERY.md +469 -0
- package/MODULES_SUMMARY.txt +337 -0
- package/QUICK_REFERENCE.txt +298 -0
- package/README-DOCKER-TESTING.txt +438 -0
- package/app/index.html +23 -10
- package/app/js/fusion-help.json +1808 -0
- package/app/js/help-module-v3.js +1096 -0
- package/app/js/killer-features-help.json +395 -0
- package/app/js/killer-features.js +1508 -0
- package/app/js/modules/fusion-assembly.js +842 -0
- package/app/js/modules/fusion-cam.js +785 -0
- package/app/js/modules/fusion-data.js +814 -0
- package/app/js/modules/fusion-drawing.js +844 -0
- package/app/js/modules/fusion-inspection.js +756 -0
- package/app/js/modules/fusion-render.js +774 -0
- package/app/js/modules/fusion-simulation.js +986 -0
- package/app/js/modules/fusion-sketch.js +1044 -0
- package/app/js/modules/fusion-solid.js +1095 -0
- package/app/js/modules/fusion-surface.js +949 -0
- package/app/tests/FUSION_TEST_SUITE.md +266 -0
- package/app/tests/README.md +77 -0
- package/app/tests/TESTING-CHECKLIST.md +177 -0
- package/app/tests/TEST_SUITE_SUMMARY.txt +236 -0
- package/app/tests/brep-live-test.html +848 -0
- package/app/tests/docker-integration-test.html +811 -0
- package/app/tests/fusion-all-tests.html +670 -0
- package/app/tests/fusion-assembly-tests.html +461 -0
- package/app/tests/fusion-cam-tests.html +421 -0
- package/app/tests/fusion-simulation-tests.html +421 -0
- package/app/tests/fusion-sketch-tests.html +613 -0
- package/app/tests/fusion-solid-tests.html +529 -0
- package/app/tests/index.html +453 -0
- package/app/tests/killer-features-test.html +509 -0
- package/app/tests/run-tests.html +874 -0
- package/app/tests/step-import-live-test.html +1115 -0
- package/app/tests/test-agent-v3.html +93 -696
- package/architecture-dashboard.html +1970 -0
- package/docs/API-REFERENCE.md +1423 -0
- package/docs/BREP-LIVE-TEST-GUIDE.md +453 -0
- package/docs/DEVELOPER-GUIDE-v3.md +795 -0
- package/docs/DOCKER-QUICK-TEST.md +376 -0
- package/docs/FUSION-FEATURES-GUIDE.md +2513 -0
- package/docs/FUSION-TUTORIAL.md +1203 -0
- package/docs/INFRASTRUCTURE-GUIDE-INDEX.md +327 -0
- package/docs/KEYBOARD-SHORTCUTS.md +402 -0
- package/docs/KILLER-FEATURES-INTEGRATION.md +412 -0
- package/docs/KILLER-FEATURES-SUMMARY.md +424 -0
- package/docs/KILLER-FEATURES-TUTORIAL.md +784 -0
- package/docs/KILLER-FEATURES.md +562 -0
- package/docs/QUICK-REFERENCE.md +282 -0
- package/docs/README-v3-DOCS.md +274 -0
- package/docs/TUTORIAL-v3.md +1190 -0
- package/docs/architecture-dashboard.html +1970 -0
- package/docs/architecture-v3.html +1038 -0
- package/linkedin-post-v3.md +58 -0
- package/package.json +1 -1
- package/scripts/dev-setup.sh +338 -0
- package/scripts/docker-health-check.sh +159 -0
- package/scripts/integration-test.sh +311 -0
- package/scripts/test-docker.sh +515 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cycleCAD — Fusion 360 Assembly Module
|
|
3
|
+
* Full assembly workspace with joints, constraints, motion studies, and exploded views.
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - 7 joint types: Rigid, Revolute, Slider, Cylindrical, Pin-Slot, Planar, Ball
|
|
7
|
+
* - Joint Origins, As-Built Joints, Rigid Groups
|
|
8
|
+
* - Motion Links (gear ratios), Motion Studies, Contact Sets
|
|
9
|
+
* - Drive Joints (animated), Exploded Views (step-by-step)
|
|
10
|
+
* - Interference detection, Assembly tree, Ground components
|
|
11
|
+
* - Insert from file/library, Full keyframe animation support
|
|
12
|
+
*
|
|
13
|
+
* Version: 1.0.0
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fusion Assembly Module
|
|
20
|
+
* Manages complex assemblies with joints, motion studies, and interference detection
|
|
21
|
+
*/
|
|
22
|
+
class FusionAssemblyModule {
|
|
23
|
+
constructor(scene, camera, renderer) {
|
|
24
|
+
this.scene = scene;
|
|
25
|
+
this.camera = camera;
|
|
26
|
+
this.renderer = renderer;
|
|
27
|
+
|
|
28
|
+
// Assembly data structures
|
|
29
|
+
this.components = new Map(); // componentId -> { mesh, instances, properties }
|
|
30
|
+
this.joints = new Map(); // jointId -> joint definition
|
|
31
|
+
this.rigidGroups = new Map(); // groupId -> set of component IDs
|
|
32
|
+
this.motionLinks = new Map(); // linkId -> { drivingJoint, drivenJoint, gearRatio }
|
|
33
|
+
this.motionStudies = new Map(); // studyId -> { keyframes, duration, playback }
|
|
34
|
+
this.contactSets = new Map(); // setId -> { comp1, comp2, type }
|
|
35
|
+
this.explodedViews = new Map(); // viewId -> { steps, components, positions }
|
|
36
|
+
|
|
37
|
+
// Joint origins and assembly origins
|
|
38
|
+
this.jointOrigins = new Map(); // jointId -> { position, quaternion }
|
|
39
|
+
this.assemblyOrigin = new THREE.Vector3(0, 0, 0);
|
|
40
|
+
|
|
41
|
+
// Animation state
|
|
42
|
+
this.isAnimating = false;
|
|
43
|
+
this.currentMotionStudy = null;
|
|
44
|
+
this.currentExplodedView = null;
|
|
45
|
+
this.currentTime = 0;
|
|
46
|
+
|
|
47
|
+
// Collision detection state
|
|
48
|
+
this.collisionPairs = [];
|
|
49
|
+
this.groundComponents = new Set(); // IDs of fixed components
|
|
50
|
+
|
|
51
|
+
// Animation loop handle
|
|
52
|
+
this.animationFrameId = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Initialize assembly module UI
|
|
57
|
+
*/
|
|
58
|
+
init() {
|
|
59
|
+
this.setupEventListeners();
|
|
60
|
+
this.setupKeyframes();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get UI panel for assembly controls
|
|
65
|
+
*/
|
|
66
|
+
getUI() {
|
|
67
|
+
const panel = document.createElement('div');
|
|
68
|
+
panel.className = 'fusion-assembly-panel';
|
|
69
|
+
panel.innerHTML = `
|
|
70
|
+
<style>
|
|
71
|
+
.fusion-assembly-panel {
|
|
72
|
+
padding: 16px;
|
|
73
|
+
font-size: 12px;
|
|
74
|
+
background: var(--bg-secondary);
|
|
75
|
+
color: var(--text-primary);
|
|
76
|
+
border-radius: 4px;
|
|
77
|
+
max-height: 600px;
|
|
78
|
+
overflow-y: auto;
|
|
79
|
+
}
|
|
80
|
+
.fusion-assembly-panel h3 {
|
|
81
|
+
margin: 0 0 12px 0;
|
|
82
|
+
font-size: 14px;
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
color: var(--text-primary);
|
|
85
|
+
}
|
|
86
|
+
.assembly-section {
|
|
87
|
+
margin-bottom: 16px;
|
|
88
|
+
padding-bottom: 12px;
|
|
89
|
+
border-bottom: 1px solid var(--border-color);
|
|
90
|
+
}
|
|
91
|
+
.assembly-section:last-child {
|
|
92
|
+
border-bottom: none;
|
|
93
|
+
margin-bottom: 0;
|
|
94
|
+
}
|
|
95
|
+
.joint-list {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
98
|
+
gap: 8px;
|
|
99
|
+
}
|
|
100
|
+
.joint-item {
|
|
101
|
+
padding: 8px;
|
|
102
|
+
background: var(--bg-primary);
|
|
103
|
+
border-radius: 3px;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
transition: background 0.2s;
|
|
106
|
+
}
|
|
107
|
+
.joint-item:hover {
|
|
108
|
+
background: var(--bg-tertiary);
|
|
109
|
+
}
|
|
110
|
+
.joint-type {
|
|
111
|
+
font-weight: 600;
|
|
112
|
+
color: var(--accent-color);
|
|
113
|
+
}
|
|
114
|
+
.motion-controls {
|
|
115
|
+
display: flex;
|
|
116
|
+
gap: 6px;
|
|
117
|
+
margin-top: 8px;
|
|
118
|
+
}
|
|
119
|
+
.motion-controls button {
|
|
120
|
+
padding: 6px 10px;
|
|
121
|
+
font-size: 11px;
|
|
122
|
+
background: var(--button-bg);
|
|
123
|
+
border: 1px solid var(--border-color);
|
|
124
|
+
border-radius: 3px;
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
color: var(--text-primary);
|
|
127
|
+
flex: 1;
|
|
128
|
+
}
|
|
129
|
+
.motion-controls button:hover {
|
|
130
|
+
background: var(--button-hover-bg);
|
|
131
|
+
}
|
|
132
|
+
.motion-controls button:active {
|
|
133
|
+
background: var(--button-active-bg);
|
|
134
|
+
}
|
|
135
|
+
.slider-group {
|
|
136
|
+
display: flex;
|
|
137
|
+
gap: 8px;
|
|
138
|
+
align-items: center;
|
|
139
|
+
margin: 8px 0;
|
|
140
|
+
}
|
|
141
|
+
.slider-group label {
|
|
142
|
+
font-weight: 600;
|
|
143
|
+
width: 60px;
|
|
144
|
+
font-size: 11px;
|
|
145
|
+
}
|
|
146
|
+
.slider-group input[type="range"] {
|
|
147
|
+
flex: 1;
|
|
148
|
+
}
|
|
149
|
+
.slider-group span {
|
|
150
|
+
width: 40px;
|
|
151
|
+
text-align: right;
|
|
152
|
+
font-size: 11px;
|
|
153
|
+
color: var(--text-secondary);
|
|
154
|
+
}
|
|
155
|
+
.component-tree {
|
|
156
|
+
max-height: 250px;
|
|
157
|
+
overflow-y: auto;
|
|
158
|
+
border: 1px solid var(--border-color);
|
|
159
|
+
border-radius: 3px;
|
|
160
|
+
padding: 6px;
|
|
161
|
+
}
|
|
162
|
+
.component-node {
|
|
163
|
+
padding: 4px 6px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
border-radius: 2px;
|
|
166
|
+
transition: background 0.2s;
|
|
167
|
+
user-select: none;
|
|
168
|
+
font-size: 11px;
|
|
169
|
+
}
|
|
170
|
+
.component-node:hover {
|
|
171
|
+
background: var(--bg-tertiary);
|
|
172
|
+
}
|
|
173
|
+
.component-node.ground::before {
|
|
174
|
+
content: '🔒 ';
|
|
175
|
+
}
|
|
176
|
+
.component-node-indent {
|
|
177
|
+
margin-left: 16px;
|
|
178
|
+
}
|
|
179
|
+
</style>
|
|
180
|
+
|
|
181
|
+
<div class="assembly-section">
|
|
182
|
+
<h3>Components</h3>
|
|
183
|
+
<div class="component-tree" id="assemblyComponentTree"></div>
|
|
184
|
+
<div style="margin-top: 8px; display: flex; gap: 6px;">
|
|
185
|
+
<button onclick="window.fusionAssembly?.insertComponent()">Insert Component</button>
|
|
186
|
+
<button onclick="window.fusionAssembly?.groundComponent()">Ground</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div class="assembly-section">
|
|
191
|
+
<h3>Joints</h3>
|
|
192
|
+
<div class="joint-list" id="assemblyJointList"></div>
|
|
193
|
+
<button onclick="window.fusionAssembly?.createJoint()" style="width: 100%; padding: 8px; margin-top: 8px;">Create Joint</button>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div class="assembly-section">
|
|
197
|
+
<h3>Motion Studies</h3>
|
|
198
|
+
<div id="assemblyMotionStudies" style="display: flex; gap: 6px; margin-bottom: 8px;">
|
|
199
|
+
<button onclick="window.fusionAssembly?.createMotionStudy()" style="flex: 1;">New Study</button>
|
|
200
|
+
<button onclick="window.fusionAssembly?.playMotionStudy()" style="flex: 1;">Play</button>
|
|
201
|
+
</div>
|
|
202
|
+
<div class="slider-group">
|
|
203
|
+
<label>Time:</label>
|
|
204
|
+
<input type="range" id="assemblyTimeSlider" min="0" max="100" value="0" step="1">
|
|
205
|
+
<span id="assemblyTimeDisplay">0.0s</span>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div class="assembly-section">
|
|
210
|
+
<h3>Exploded View</h3>
|
|
211
|
+
<div style="display: flex; gap: 6px;">
|
|
212
|
+
<button onclick="window.fusionAssembly?.createExplodedView()" style="flex: 1;">Create</button>
|
|
213
|
+
<button onclick="window.fusionAssembly?.editExplode()" style="flex: 1;">Edit</button>
|
|
214
|
+
<button onclick="window.fusionAssembly?.assembleAll()" style="flex: 1;">Assemble</button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="assembly-section">
|
|
219
|
+
<h3>Analysis</h3>
|
|
220
|
+
<div style="display: flex; gap: 6px;">
|
|
221
|
+
<button onclick="window.fusionAssembly?.checkInterference()" style="flex: 1;">Interference</button>
|
|
222
|
+
<button onclick="window.fusionAssembly?.contactSet()" style="flex: 1;">Contact Set</button>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
window.fusionAssembly = this;
|
|
228
|
+
return panel;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create a component instance in the assembly
|
|
233
|
+
* @param {THREE.Mesh} mesh - The 3D geometry
|
|
234
|
+
* @param {string} name - Component name
|
|
235
|
+
* @returns {string} Component ID
|
|
236
|
+
*/
|
|
237
|
+
addComponent(mesh, name = 'Component') {
|
|
238
|
+
const componentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
239
|
+
|
|
240
|
+
const component = {
|
|
241
|
+
id: componentId,
|
|
242
|
+
name: name,
|
|
243
|
+
mesh: mesh,
|
|
244
|
+
instances: [mesh],
|
|
245
|
+
originalMatrix: mesh.matrix.clone(),
|
|
246
|
+
position: mesh.position.clone(),
|
|
247
|
+
quaternion: mesh.quaternion.clone(),
|
|
248
|
+
isGrounded: false,
|
|
249
|
+
properties: {
|
|
250
|
+
mass: 1.0,
|
|
251
|
+
material: 'Steel',
|
|
252
|
+
appearance: 0x888888
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
this.components.set(componentId, component);
|
|
257
|
+
this.updateComponentTree();
|
|
258
|
+
return componentId;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Create a joint between two components
|
|
263
|
+
* Supports: Rigid, Revolute, Slider, Cylindrical, Pin-Slot, Planar, Ball
|
|
264
|
+
*/
|
|
265
|
+
createJoint(type = 'Revolute', comp1Id, comp2Id, origin = null, axis = new THREE.Vector3(0, 0, 1)) {
|
|
266
|
+
if (!this.components.has(comp1Id) || !this.components.has(comp2Id)) {
|
|
267
|
+
console.warn('Invalid component IDs for joint');
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const jointId = `joint_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
272
|
+
|
|
273
|
+
// Default origin is midpoint between component centers
|
|
274
|
+
if (!origin) {
|
|
275
|
+
const comp1 = this.components.get(comp1Id);
|
|
276
|
+
const comp2 = this.components.get(comp2Id);
|
|
277
|
+
origin = new THREE.Vector3()
|
|
278
|
+
.addVectors(comp1.position, comp2.position)
|
|
279
|
+
.multiplyScalar(0.5);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const joint = {
|
|
283
|
+
id: jointId,
|
|
284
|
+
type: type, // Rigid | Revolute | Slider | Cylindrical | Pin-Slot | Planar | Ball
|
|
285
|
+
component1: comp1Id,
|
|
286
|
+
component2: comp2Id,
|
|
287
|
+
origin: origin,
|
|
288
|
+
axis: axis.normalize(),
|
|
289
|
+
|
|
290
|
+
// Joint parameters (varies by type)
|
|
291
|
+
minAngle: type === 'Revolute' ? 0 : null,
|
|
292
|
+
maxAngle: type === 'Revolute' ? Math.PI * 2 : null,
|
|
293
|
+
minDistance: type === 'Slider' ? 0 : null,
|
|
294
|
+
maxDistance: type === 'Slider' ? 100 : null,
|
|
295
|
+
|
|
296
|
+
// Current state
|
|
297
|
+
currentValue: 0, // Angle for Revolute, distance for Slider
|
|
298
|
+
velocity: 0,
|
|
299
|
+
acceleration: 0,
|
|
300
|
+
|
|
301
|
+
// Drive properties
|
|
302
|
+
isDriven: false,
|
|
303
|
+
driveExpression: null, // Time-based function
|
|
304
|
+
|
|
305
|
+
// Rigid group (for Rigid joints)
|
|
306
|
+
rigidGroupId: null
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
this.joints.set(jointId, joint);
|
|
310
|
+
|
|
311
|
+
// Store joint origin
|
|
312
|
+
this.jointOrigins.set(jointId, {
|
|
313
|
+
position: origin.clone(),
|
|
314
|
+
quaternion: new THREE.Quaternion()
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// For Rigid joints, create a rigid group
|
|
318
|
+
if (type === 'Rigid') {
|
|
319
|
+
const groupId = `rgroup_${jointId}`;
|
|
320
|
+
this.rigidGroups.set(groupId, new Set([comp1Id, comp2Id]));
|
|
321
|
+
joint.rigidGroupId = groupId;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.updateJointList();
|
|
325
|
+
return jointId;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Set joint as driven with time-based animation
|
|
330
|
+
*/
|
|
331
|
+
driveJoint(jointId, expression) {
|
|
332
|
+
const joint = this.joints.get(jointId);
|
|
333
|
+
if (!joint) return;
|
|
334
|
+
|
|
335
|
+
joint.isDriven = true;
|
|
336
|
+
joint.driveExpression = expression; // e.g., (t) => t * Math.PI / 2
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Create a motion link (gear ratio between joints)
|
|
341
|
+
*/
|
|
342
|
+
createMotionLink(drivingJointId, drivenJointId, gearRatio = 1.0) {
|
|
343
|
+
const linkId = `mlink_${Date.now()}`;
|
|
344
|
+
this.motionLinks.set(linkId, {
|
|
345
|
+
id: linkId,
|
|
346
|
+
drivingJoint: drivingJointId,
|
|
347
|
+
drivenJoint: drivenJointId,
|
|
348
|
+
gearRatio: gearRatio
|
|
349
|
+
});
|
|
350
|
+
return linkId;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Create a motion study (keyframe animation)
|
|
355
|
+
*/
|
|
356
|
+
createMotionStudy(name = 'Study1') {
|
|
357
|
+
const studyId = `study_${Date.now()}`;
|
|
358
|
+
this.motionStudies.set(studyId, {
|
|
359
|
+
id: studyId,
|
|
360
|
+
name: name,
|
|
361
|
+
keyframes: [], // { time, joints: { jointId: value } }
|
|
362
|
+
duration: 5000, // ms
|
|
363
|
+
playbackSpeed: 1.0,
|
|
364
|
+
loop: true,
|
|
365
|
+
isPlaying: false
|
|
366
|
+
});
|
|
367
|
+
this.currentMotionStudy = studyId;
|
|
368
|
+
return studyId;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Add keyframe to current motion study
|
|
373
|
+
*/
|
|
374
|
+
addKeyframe(time, jointValues) {
|
|
375
|
+
if (!this.currentMotionStudy) return;
|
|
376
|
+
|
|
377
|
+
const study = this.motionStudies.get(this.currentMotionStudy);
|
|
378
|
+
study.keyframes.push({
|
|
379
|
+
time: time,
|
|
380
|
+
joints: jointValues // { jointId: angle/distance }
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Sort keyframes by time
|
|
384
|
+
study.keyframes.sort((a, b) => a.time - b.time);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Play motion study with smooth interpolation
|
|
389
|
+
*/
|
|
390
|
+
playMotionStudy(studyId = null) {
|
|
391
|
+
const study = this.motionStudies.get(studyId || this.currentMotionStudy);
|
|
392
|
+
if (!study || study.isPlaying) return;
|
|
393
|
+
|
|
394
|
+
study.isPlaying = true;
|
|
395
|
+
this.isAnimating = true;
|
|
396
|
+
this.currentTime = 0;
|
|
397
|
+
|
|
398
|
+
const startTime = performance.now();
|
|
399
|
+
const duration = study.duration;
|
|
400
|
+
|
|
401
|
+
const animate = (currentTime) => {
|
|
402
|
+
const elapsed = currentTime - startTime;
|
|
403
|
+
const progress = (elapsed % duration) / duration;
|
|
404
|
+
this.currentTime = progress * duration;
|
|
405
|
+
|
|
406
|
+
// Interpolate joint values
|
|
407
|
+
this.interpolateJoints(study, progress);
|
|
408
|
+
this.updateComponentTransforms();
|
|
409
|
+
|
|
410
|
+
if (study.loop || elapsed < duration) {
|
|
411
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
412
|
+
} else {
|
|
413
|
+
study.isPlaying = false;
|
|
414
|
+
this.isAnimating = false;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Interpolate joint values between keyframes
|
|
423
|
+
*/
|
|
424
|
+
interpolateJoints(study, progress) {
|
|
425
|
+
const time = progress * study.duration;
|
|
426
|
+
|
|
427
|
+
for (const joint of this.joints.values()) {
|
|
428
|
+
if (!joint.isDriven) continue;
|
|
429
|
+
|
|
430
|
+
// Find surrounding keyframes
|
|
431
|
+
let kf1 = null, kf2 = null;
|
|
432
|
+
for (let i = 0; i < study.keyframes.length; i++) {
|
|
433
|
+
if (study.keyframes[i].time <= time) kf1 = study.keyframes[i];
|
|
434
|
+
if (study.keyframes[i].time >= time && !kf2) kf2 = study.keyframes[i];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!kf1 || !kf2) continue;
|
|
438
|
+
|
|
439
|
+
// Linear interpolation
|
|
440
|
+
const t = kf1 === kf2 ? 0 : (time - kf1.time) / (kf2.time - kf1.time);
|
|
441
|
+
const val1 = kf1.joints[joint.id] || 0;
|
|
442
|
+
const val2 = kf2.joints[joint.id] || 0;
|
|
443
|
+
|
|
444
|
+
joint.currentValue = val1 + (val2 - val1) * t;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Update component transforms based on joint values
|
|
450
|
+
*/
|
|
451
|
+
updateComponentTransforms() {
|
|
452
|
+
// For each joint, calculate component positions
|
|
453
|
+
for (const [jointId, joint] of this.joints) {
|
|
454
|
+
const comp1 = this.components.get(joint.component1);
|
|
455
|
+
const comp2 = this.components.get(joint.component2);
|
|
456
|
+
|
|
457
|
+
if (!comp1 || !comp2) continue;
|
|
458
|
+
|
|
459
|
+
// Apply joint transformations
|
|
460
|
+
switch (joint.type) {
|
|
461
|
+
case 'Revolute':
|
|
462
|
+
// Rotate comp2 around joint axis
|
|
463
|
+
const axis = joint.axis;
|
|
464
|
+
const quat = new THREE.Quaternion();
|
|
465
|
+
quat.setFromAxisAngle(axis, joint.currentValue);
|
|
466
|
+
comp2.mesh.quaternion.multiplyQuaternions(quat, comp2.quaternion);
|
|
467
|
+
break;
|
|
468
|
+
|
|
469
|
+
case 'Slider':
|
|
470
|
+
// Translate comp2 along joint axis
|
|
471
|
+
const displacement = joint.axis.clone().multiplyScalar(joint.currentValue);
|
|
472
|
+
comp2.mesh.position.copy(comp2.position).add(displacement);
|
|
473
|
+
break;
|
|
474
|
+
|
|
475
|
+
case 'Cylindrical':
|
|
476
|
+
// Both rotation and translation
|
|
477
|
+
const axis2 = joint.axis;
|
|
478
|
+
const quat2 = new THREE.Quaternion();
|
|
479
|
+
quat2.setFromAxisAngle(axis2, joint.currentValue * 0.5);
|
|
480
|
+
comp2.mesh.quaternion.multiplyQuaternions(quat2, comp2.quaternion);
|
|
481
|
+
|
|
482
|
+
const disp = joint.axis.clone().multiplyScalar(joint.currentValue * 10);
|
|
483
|
+
comp2.mesh.position.copy(comp2.position).add(disp);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Handle rigid groups (locked together)
|
|
489
|
+
for (const [groupId, componentSet] of this.rigidGroups) {
|
|
490
|
+
const comps = Array.from(componentSet).map(id => this.components.get(id));
|
|
491
|
+
// All components in group maintain relative transforms
|
|
492
|
+
// (simplified for demo)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Apply motion links (gear ratios)
|
|
496
|
+
for (const [linkId, link] of this.motionLinks) {
|
|
497
|
+
const drivingJoint = this.joints.get(link.drivingJoint);
|
|
498
|
+
const drivenJoint = this.joints.get(link.drivenJoint);
|
|
499
|
+
|
|
500
|
+
if (drivingJoint && drivenJoint) {
|
|
501
|
+
drivenJoint.currentValue = drivingJoint.currentValue * link.gearRatio;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Create exploded view with auto-generated steps
|
|
508
|
+
*/
|
|
509
|
+
createExplodedView(name = 'Exploded') {
|
|
510
|
+
const viewId = `explode_${Date.now()}`;
|
|
511
|
+
const steps = [];
|
|
512
|
+
|
|
513
|
+
// Auto-generate explosion steps (one per component)
|
|
514
|
+
let stepNum = 0;
|
|
515
|
+
for (const [compId, comp] of this.components) {
|
|
516
|
+
const direction = new THREE.Vector3(
|
|
517
|
+
Math.cos(stepNum * Math.PI / this.components.size),
|
|
518
|
+
0,
|
|
519
|
+
Math.sin(stepNum * Math.PI / this.components.size)
|
|
520
|
+
);
|
|
521
|
+
const distance = 50 + stepNum * 10;
|
|
522
|
+
|
|
523
|
+
steps.push({
|
|
524
|
+
stepNum: stepNum,
|
|
525
|
+
component: compId,
|
|
526
|
+
targetPosition: comp.mesh.position.clone().add(direction.multiplyScalar(distance)),
|
|
527
|
+
targetRotation: comp.mesh.quaternion.clone(),
|
|
528
|
+
duration: 500
|
|
529
|
+
});
|
|
530
|
+
stepNum++;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this.explodedViews.set(viewId, {
|
|
534
|
+
id: viewId,
|
|
535
|
+
name: name,
|
|
536
|
+
steps: steps,
|
|
537
|
+
currentStep: 0,
|
|
538
|
+
components: Array.from(this.components.keys()),
|
|
539
|
+
positions: new Map(),
|
|
540
|
+
isExploded: false
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
this.currentExplodedView = viewId;
|
|
544
|
+
return viewId;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Animate to exploded view
|
|
549
|
+
*/
|
|
550
|
+
explode(viewId = null) {
|
|
551
|
+
const view = this.explodedViews.get(viewId || this.currentExplodedView);
|
|
552
|
+
if (!view || view.isExploded) return;
|
|
553
|
+
|
|
554
|
+
view.isExploded = true;
|
|
555
|
+
const startTime = performance.now();
|
|
556
|
+
const totalDuration = view.steps.reduce((sum, step) => sum + step.duration, 0);
|
|
557
|
+
|
|
558
|
+
const animate = (currentTime) => {
|
|
559
|
+
const elapsed = currentTime - startTime;
|
|
560
|
+
let cumulativeTime = 0;
|
|
561
|
+
|
|
562
|
+
for (const step of view.steps) {
|
|
563
|
+
const stepStart = cumulativeTime;
|
|
564
|
+
const stepEnd = cumulativeTime + step.duration;
|
|
565
|
+
|
|
566
|
+
if (elapsed >= stepStart && elapsed <= stepEnd) {
|
|
567
|
+
const progress = (elapsed - stepStart) / step.duration;
|
|
568
|
+
const comp = this.components.get(step.component);
|
|
569
|
+
|
|
570
|
+
if (comp) {
|
|
571
|
+
// Smooth interpolation
|
|
572
|
+
comp.mesh.position.lerpVectors(
|
|
573
|
+
comp.position,
|
|
574
|
+
step.targetPosition,
|
|
575
|
+
this.easeInOutCubic(progress)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
cumulativeTime = stepEnd;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (elapsed < totalDuration) {
|
|
584
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Animate to assembled view
|
|
593
|
+
*/
|
|
594
|
+
assembleAll(viewId = null) {
|
|
595
|
+
const view = this.explodedViews.get(viewId || this.currentExplodedView);
|
|
596
|
+
if (!view || !view.isExploded) return;
|
|
597
|
+
|
|
598
|
+
view.isExploded = false;
|
|
599
|
+
const startTime = performance.now();
|
|
600
|
+
const totalDuration = view.steps.reduce((sum, step) => sum + step.duration, 0);
|
|
601
|
+
|
|
602
|
+
const animate = (currentTime) => {
|
|
603
|
+
const elapsed = currentTime - startTime;
|
|
604
|
+
let cumulativeTime = 0;
|
|
605
|
+
|
|
606
|
+
for (const step of view.steps) {
|
|
607
|
+
const stepStart = cumulativeTime;
|
|
608
|
+
const stepEnd = cumulativeTime + step.duration;
|
|
609
|
+
|
|
610
|
+
if (elapsed >= stepStart && elapsed <= stepEnd) {
|
|
611
|
+
const progress = (elapsed - stepStart) / step.duration;
|
|
612
|
+
const comp = this.components.get(step.component);
|
|
613
|
+
|
|
614
|
+
if (comp) {
|
|
615
|
+
// Reverse interpolation
|
|
616
|
+
comp.mesh.position.lerpVectors(
|
|
617
|
+
step.targetPosition,
|
|
618
|
+
comp.position,
|
|
619
|
+
this.easeInOutCubic(progress)
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
cumulativeTime = stepEnd;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (elapsed < totalDuration) {
|
|
628
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Check for interference (collision) between components
|
|
637
|
+
*/
|
|
638
|
+
checkInterference() {
|
|
639
|
+
this.collisionPairs = [];
|
|
640
|
+
const componentArray = Array.from(this.components.values());
|
|
641
|
+
|
|
642
|
+
for (let i = 0; i < componentArray.length; i++) {
|
|
643
|
+
for (let j = i + 1; j < componentArray.length; j++) {
|
|
644
|
+
const comp1 = componentArray[i];
|
|
645
|
+
const comp2 = componentArray[j];
|
|
646
|
+
|
|
647
|
+
if (this.checkBBoxCollision(comp1.mesh, comp2.mesh)) {
|
|
648
|
+
this.collisionPairs.push({
|
|
649
|
+
component1: comp1.name,
|
|
650
|
+
component2: comp2.name,
|
|
651
|
+
comp1Id: comp1.id,
|
|
652
|
+
comp2Id: comp2.id,
|
|
653
|
+
severity: 'medium'
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// Highlight colliding components
|
|
657
|
+
comp1.mesh.material.color.setHex(0xff6666);
|
|
658
|
+
comp2.mesh.material.color.setHex(0xff6666);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return this.collisionPairs;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Check bbox collision between two meshes
|
|
668
|
+
*/
|
|
669
|
+
checkBBoxCollision(mesh1, mesh2) {
|
|
670
|
+
const box1 = new THREE.Box3().setFromObject(mesh1);
|
|
671
|
+
const box2 = new THREE.Box3().setFromObject(mesh2);
|
|
672
|
+
return box1.intersectsBox(box2);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Create contact set (physical contact between components)
|
|
677
|
+
*/
|
|
678
|
+
contactSet(comp1Id, comp2Id, type = 'face-to-face') {
|
|
679
|
+
const setId = `contact_${Date.now()}`;
|
|
680
|
+
this.contactSets.set(setId, {
|
|
681
|
+
id: setId,
|
|
682
|
+
component1: comp1Id,
|
|
683
|
+
component2: comp2Id,
|
|
684
|
+
type: type, // face-to-face | edge-to-face | vertex-to-face
|
|
685
|
+
restitution: 0.5,
|
|
686
|
+
friction: 0.3
|
|
687
|
+
});
|
|
688
|
+
return setId;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Ground component (fix in place)
|
|
693
|
+
*/
|
|
694
|
+
groundComponent(componentId = null) {
|
|
695
|
+
if (!componentId) {
|
|
696
|
+
// Show dialog to select component
|
|
697
|
+
console.log('Select component to ground');
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const comp = this.components.get(componentId);
|
|
702
|
+
if (comp) {
|
|
703
|
+
comp.isGrounded = true;
|
|
704
|
+
this.groundComponents.add(componentId);
|
|
705
|
+
this.updateComponentTree();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Insert component from file
|
|
711
|
+
*/
|
|
712
|
+
insertComponent(filePath = null) {
|
|
713
|
+
if (!filePath) {
|
|
714
|
+
console.log('Open file picker to import component');
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Load geometry from file (STL, STEP, OBJ, etc.)
|
|
719
|
+
// For now, create a placeholder box
|
|
720
|
+
const geometry = new THREE.BoxGeometry(20, 20, 20);
|
|
721
|
+
const material = new THREE.MeshPhongMaterial({ color: 0x888888 });
|
|
722
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
723
|
+
|
|
724
|
+
this.scene.add(mesh);
|
|
725
|
+
this.addComponent(mesh, 'Imported_Component');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Update component tree display
|
|
730
|
+
*/
|
|
731
|
+
updateComponentTree() {
|
|
732
|
+
const treeDiv = document.getElementById('assemblyComponentTree');
|
|
733
|
+
if (!treeDiv) return;
|
|
734
|
+
|
|
735
|
+
treeDiv.innerHTML = '';
|
|
736
|
+
for (const [compId, comp] of this.components) {
|
|
737
|
+
const nodeDiv = document.createElement('div');
|
|
738
|
+
nodeDiv.className = `component-node ${comp.isGrounded ? 'ground' : ''}`;
|
|
739
|
+
nodeDiv.textContent = comp.name;
|
|
740
|
+
nodeDiv.onclick = () => {
|
|
741
|
+
comp.mesh.material.color.setHex(0xffff00);
|
|
742
|
+
setTimeout(() => {
|
|
743
|
+
comp.mesh.material.color.setHex(comp.properties.appearance);
|
|
744
|
+
}, 200);
|
|
745
|
+
};
|
|
746
|
+
treeDiv.appendChild(nodeDiv);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Update joint list display
|
|
752
|
+
*/
|
|
753
|
+
updateJointList() {
|
|
754
|
+
const listDiv = document.getElementById('assemblyJointList');
|
|
755
|
+
if (!listDiv) return;
|
|
756
|
+
|
|
757
|
+
listDiv.innerHTML = '';
|
|
758
|
+
for (const [jointId, joint] of this.joints) {
|
|
759
|
+
const itemDiv = document.createElement('div');
|
|
760
|
+
itemDiv.className = 'joint-item';
|
|
761
|
+
itemDiv.innerHTML = `
|
|
762
|
+
<div><span class="joint-type">${joint.type}</span></div>
|
|
763
|
+
<div style="font-size: 10px; color: var(--text-secondary);">
|
|
764
|
+
${this.components.get(joint.component1)?.name || 'Unknown'} ↔
|
|
765
|
+
${this.components.get(joint.component2)?.name || 'Unknown'}
|
|
766
|
+
</div>
|
|
767
|
+
`;
|
|
768
|
+
listDiv.appendChild(itemDiv);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Setup event listeners
|
|
774
|
+
*/
|
|
775
|
+
setupEventListeners() {
|
|
776
|
+
const timeSlider = document.getElementById('assemblyTimeSlider');
|
|
777
|
+
const timeDisplay = document.getElementById('assemblyTimeDisplay');
|
|
778
|
+
|
|
779
|
+
if (timeSlider) {
|
|
780
|
+
timeSlider.addEventListener('input', (e) => {
|
|
781
|
+
const study = this.motionStudies.get(this.currentMotionStudy);
|
|
782
|
+
if (study) {
|
|
783
|
+
this.currentTime = parseFloat(e.target.value) * study.duration / 100;
|
|
784
|
+
const progress = this.currentTime / study.duration;
|
|
785
|
+
this.interpolateJoints(study, progress);
|
|
786
|
+
this.updateComponentTransforms();
|
|
787
|
+
if (timeDisplay) {
|
|
788
|
+
timeDisplay.textContent = (this.currentTime / 1000).toFixed(1) + 's';
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Setup keyframe controls
|
|
797
|
+
*/
|
|
798
|
+
setupKeyframes() {
|
|
799
|
+
// Initialize keyframe system (ready for animation)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Easing function for smooth animation
|
|
804
|
+
*/
|
|
805
|
+
easeInOutCubic(t) {
|
|
806
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Execute command from agent API
|
|
811
|
+
*/
|
|
812
|
+
execute(command, params) {
|
|
813
|
+
switch (command) {
|
|
814
|
+
case 'addComponent':
|
|
815
|
+
return this.addComponent(params.mesh, params.name);
|
|
816
|
+
case 'createJoint':
|
|
817
|
+
return this.createJoint(params.type, params.comp1, params.comp2, params.origin, params.axis);
|
|
818
|
+
case 'driveJoint':
|
|
819
|
+
return this.driveJoint(params.jointId, params.expression);
|
|
820
|
+
case 'createMotionStudy':
|
|
821
|
+
return this.createMotionStudy(params.name);
|
|
822
|
+
case 'addKeyframe':
|
|
823
|
+
return this.addKeyframe(params.time, params.values);
|
|
824
|
+
case 'playMotionStudy':
|
|
825
|
+
return this.playMotionStudy(params.studyId);
|
|
826
|
+
case 'createExplodedView':
|
|
827
|
+
return this.createExplodedView(params.name);
|
|
828
|
+
case 'explode':
|
|
829
|
+
return this.explode(params.viewId);
|
|
830
|
+
case 'assemble':
|
|
831
|
+
return this.assembleAll(params.viewId);
|
|
832
|
+
case 'checkInterference':
|
|
833
|
+
return this.checkInterference();
|
|
834
|
+
case 'groundComponent':
|
|
835
|
+
return this.groundComponent(params.componentId);
|
|
836
|
+
default:
|
|
837
|
+
console.warn(`Unknown assembly command: ${command}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export default FusionAssemblyModule;
|