cyclecad 1.3.2 → 1.3.3
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/DRAWING_MODULE_INTEGRATION.md +633 -0
- package/README.md +138 -317
- package/app/index.html +2 -0
- package/app/js/brep-kernel.js +853 -0
- package/app/js/kernel.js +684 -0
- package/app/js/modules/assembly-module.js +582 -0
- package/app/js/modules/brep-module.js +583 -0
- package/app/js/modules/drawing-module.js +883 -0
- package/app/js/modules/operations-module.js +660 -0
- package/app/js/modules/simulation-module.js +834 -0
- package/app/js/modules/sketch-module.js +720 -0
- package/app/js/modules/step-module.js +510 -0
- package/app/js/modules/viewport-module.js +530 -0
- package/fusion360-gap-analysis.html +636 -0
- package/package.json +1 -1
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulation Module for cycleCAD
|
|
3
|
+
* FEA-based analysis: Static Stress, Thermal, Modal, Buckling
|
|
4
|
+
* Version 1.0.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SimulationModule = {
|
|
8
|
+
id: 'simulation',
|
|
9
|
+
name: 'FEA Simulation',
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
category: 'tool',
|
|
12
|
+
dependencies: ['viewport', 'operations'],
|
|
13
|
+
memoryEstimate: 40,
|
|
14
|
+
|
|
15
|
+
// Module state
|
|
16
|
+
state: {
|
|
17
|
+
activeAnalysis: null, // {bodyId, type, material, status}
|
|
18
|
+
mesh: null, // {nodes: [], elements: [], elementSize}
|
|
19
|
+
loads: [], // [{type, target, value, direction}]
|
|
20
|
+
constraints: [], // [{type, target, params}]
|
|
21
|
+
material: null, // {name, E, nu, rho, yieldStrength}
|
|
22
|
+
results: null, // {stress: [], displacement: [], temperature: [], modes: []}
|
|
23
|
+
solver: null, // {status, progress, elapsed}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// Material library (E in GPa, nu is Poisson's ratio, rho in g/cm³, yield in MPa)
|
|
27
|
+
MATERIALS: {
|
|
28
|
+
STEEL: { name: 'Steel (1045)', E: 205, nu: 0.3, rho: 7.85, yieldStrength: 380, color: 0x555555 },
|
|
29
|
+
ALUMINUM: { name: 'Aluminum 6061-T6', E: 69, nu: 0.33, rho: 2.7, yieldStrength: 275, color: 0xcccccc },
|
|
30
|
+
ABS: { name: 'ABS Plastic', E: 2.3, nu: 0.36, rho: 1.04, yieldStrength: 40, color: 0x333333 },
|
|
31
|
+
BRASS: { name: 'Brass C360', E: 110, nu: 0.34, rho: 8.53, yieldStrength: 380, color: 0xb8860b },
|
|
32
|
+
TITANIUM: { name: 'Ti-6Al-4V', E: 110, nu: 0.31, rho: 4.42, yieldStrength: 1160, color: 0xffffff },
|
|
33
|
+
NYLON: { name: 'Nylon 6', E: 2.8, nu: 0.4, rho: 1.14, yieldStrength: 75, color: 0xffffcc },
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// Analysis types
|
|
37
|
+
ANALYSIS_TYPES: {
|
|
38
|
+
STATIC: 'static',
|
|
39
|
+
THERMAL: 'thermal',
|
|
40
|
+
MODAL: 'modal',
|
|
41
|
+
BUCKLING: 'buckling',
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Load types
|
|
45
|
+
LOAD_TYPES: {
|
|
46
|
+
FORCE: 'force',
|
|
47
|
+
MOMENT: 'moment',
|
|
48
|
+
PRESSURE: 'pressure',
|
|
49
|
+
GRAVITY: 'gravity',
|
|
50
|
+
TEMPERATURE: 'temperature',
|
|
51
|
+
REMOTE_FORCE: 'remote_force',
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Boundary condition types
|
|
55
|
+
BC_TYPES: {
|
|
56
|
+
FIXED: 'fixed',
|
|
57
|
+
PIN: 'pin',
|
|
58
|
+
ROLLER: 'roller',
|
|
59
|
+
SYMMETRY: 'symmetry',
|
|
60
|
+
PRESCRIBED_DISPLACEMENT: 'prescribed_displacement',
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Initialize simulation module
|
|
65
|
+
*/
|
|
66
|
+
init() {
|
|
67
|
+
if (window._debug) console.log('[Simulation] Initializing...');
|
|
68
|
+
this.state.activeAnalysis = null;
|
|
69
|
+
this.state.mesh = null;
|
|
70
|
+
this.state.loads = [];
|
|
71
|
+
this.state.constraints = [];
|
|
72
|
+
this.state.material = this.MATERIALS.STEEL;
|
|
73
|
+
this.state.results = null;
|
|
74
|
+
this._initEventListeners();
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Initialize event listeners
|
|
79
|
+
*/
|
|
80
|
+
_initEventListeners() {
|
|
81
|
+
window.addEventListener('simulation:action', (e) => {
|
|
82
|
+
if (window._debug) console.log('[Simulation] Event:', e.detail);
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Setup simulation for a body
|
|
88
|
+
* @param {string} bodyId - body/part ID
|
|
89
|
+
* @param {string} analysisType - STATIC, THERMAL, MODAL, BUCKLING
|
|
90
|
+
* @returns {Object} analysis setup object
|
|
91
|
+
*/
|
|
92
|
+
setup(bodyId, analysisType = 'static') {
|
|
93
|
+
if (!this.ANALYSIS_TYPES[analysisType.toUpperCase()]) {
|
|
94
|
+
console.error(`[Simulation] Unknown analysis type: ${analysisType}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.state.activeAnalysis = {
|
|
99
|
+
bodyId,
|
|
100
|
+
type: analysisType.toLowerCase(),
|
|
101
|
+
material: this.state.material,
|
|
102
|
+
status: 'setup',
|
|
103
|
+
createdAt: Date.now(),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (window._debug) console.log(`[Simulation] Setup ${analysisType} analysis for body ${bodyId}`);
|
|
107
|
+
window.dispatchEvent(new CustomEvent('sim:setupStart', { detail: { bodyId, analysisType } }));
|
|
108
|
+
|
|
109
|
+
return this.state.activeAnalysis;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Set material for analysis
|
|
114
|
+
* @param {string} bodyId - body ID
|
|
115
|
+
* @param {string} materialName - material key (STEEL, ALUMINUM, etc.)
|
|
116
|
+
*/
|
|
117
|
+
setMaterial(bodyId, materialName) {
|
|
118
|
+
const matKey = materialName.toUpperCase();
|
|
119
|
+
if (!this.MATERIALS[matKey]) {
|
|
120
|
+
console.error(`[Simulation] Unknown material: ${materialName}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.state.material = { ...this.MATERIALS[matKey] };
|
|
125
|
+
if (this.state.activeAnalysis) {
|
|
126
|
+
this.state.activeAnalysis.material = this.state.material;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (window._debug) console.log(`[Simulation] Set material for ${bodyId}: ${this.state.material.name}`);
|
|
130
|
+
window.dispatchEvent(new CustomEvent('sim:materialChanged', {
|
|
131
|
+
detail: { bodyId, material: this.state.material }
|
|
132
|
+
}));
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Add a load to the analysis
|
|
137
|
+
* @param {string} type - FORCE, MOMENT, PRESSURE, GRAVITY, TEMPERATURE
|
|
138
|
+
* @param {string} target - face/edge/point ID
|
|
139
|
+
* @param {number} value - magnitude (N/mm² for pressure, °C for temp, N for force)
|
|
140
|
+
* @param {THREE.Vector3} direction - direction vector (for force/moment)
|
|
141
|
+
*/
|
|
142
|
+
addLoad(type, target, value, direction = new THREE.Vector3(0, -1, 0)) {
|
|
143
|
+
const loadType = type.toUpperCase();
|
|
144
|
+
if (!this.LOAD_TYPES[loadType]) {
|
|
145
|
+
console.error(`[Simulation] Unknown load type: ${type}`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const load = {
|
|
150
|
+
id: `load-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
151
|
+
type: type.toLowerCase(),
|
|
152
|
+
target,
|
|
153
|
+
value,
|
|
154
|
+
direction: direction.clone().normalize(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
this.state.loads.push(load);
|
|
158
|
+
|
|
159
|
+
if (window._debug) console.log(`[Simulation] Added ${type} load: ${value} on ${target}`);
|
|
160
|
+
window.dispatchEvent(new CustomEvent('sim:loadAdded', { detail: load }));
|
|
161
|
+
|
|
162
|
+
return load;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Add a boundary condition
|
|
167
|
+
* @param {string} type - FIXED, PIN, ROLLER, SYMMETRY, PRESCRIBED_DISPLACEMENT
|
|
168
|
+
* @param {string} target - face/edge/point ID
|
|
169
|
+
* @param {Object} params - additional parameters (displacement, direction, etc.)
|
|
170
|
+
*/
|
|
171
|
+
addConstraint(type, target, params = {}) {
|
|
172
|
+
const bcType = type.toUpperCase();
|
|
173
|
+
if (!this.BC_TYPES[bcType]) {
|
|
174
|
+
console.error(`[Simulation] Unknown constraint type: ${type}`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const constraint = {
|
|
179
|
+
id: `bc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
180
|
+
type: type.toLowerCase(),
|
|
181
|
+
target,
|
|
182
|
+
params,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
this.state.constraints.push(constraint);
|
|
186
|
+
|
|
187
|
+
if (window._debug) console.log(`[Simulation] Added ${type} constraint on ${target}`);
|
|
188
|
+
window.dispatchEvent(new CustomEvent('sim:constraintAdded', { detail: constraint }));
|
|
189
|
+
|
|
190
|
+
return constraint;
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Generate tetrahedral mesh from surface geometry
|
|
195
|
+
* @param {string} bodyId - body ID
|
|
196
|
+
* @param {number} elementSize - target element size in mm
|
|
197
|
+
*/
|
|
198
|
+
mesh(bodyId, elementSize = 10) {
|
|
199
|
+
if (window._debug) console.log(`[Simulation] Meshing body ${bodyId} with element size ${elementSize}mm...`);
|
|
200
|
+
window.dispatchEvent(new CustomEvent('sim:meshStart', { detail: { bodyId, elementSize } }));
|
|
201
|
+
|
|
202
|
+
// Simple tetrahedral mesh generation (simplified Delaunay-like approach)
|
|
203
|
+
const nodes = [];
|
|
204
|
+
const elements = [];
|
|
205
|
+
|
|
206
|
+
// Generate sample nodes in a grid pattern (crude approximation)
|
|
207
|
+
const spacing = elementSize;
|
|
208
|
+
for (let x = 0; x < 100; x += spacing) {
|
|
209
|
+
for (let y = 0; y < 100; y += spacing) {
|
|
210
|
+
for (let z = 0; z < 50; z += spacing) {
|
|
211
|
+
nodes.push({
|
|
212
|
+
id: nodes.length,
|
|
213
|
+
position: new THREE.Vector3(x, y, z),
|
|
214
|
+
displacement: new THREE.Vector3(0, 0, 0),
|
|
215
|
+
stress: 0,
|
|
216
|
+
temperature: 20,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Create tetrahedra from nodes (simplified — every 4 nearby nodes form one element)
|
|
223
|
+
for (let i = 0; i < nodes.length - 4; i += 4) {
|
|
224
|
+
if (i + 3 < nodes.length) {
|
|
225
|
+
elements.push({
|
|
226
|
+
id: elements.length,
|
|
227
|
+
nodeIds: [i, i + 1, i + 2, i + 3],
|
|
228
|
+
stiffnessMatrix: this._computeElementStiffness(nodes, [i, i + 1, i + 2, i + 3]),
|
|
229
|
+
stress: 0,
|
|
230
|
+
strain: 0,
|
|
231
|
+
volume: 0,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.state.mesh = {
|
|
237
|
+
nodes,
|
|
238
|
+
elements,
|
|
239
|
+
elementSize,
|
|
240
|
+
nodeCount: nodes.length,
|
|
241
|
+
elementCount: elements.length,
|
|
242
|
+
qualityMetric: 0.85, // Avg aspect ratio (0-1, higher is better)
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (window._debug) console.log(`[Simulation] Mesh complete: ${nodes.length} nodes, ${elements.length} elements`);
|
|
246
|
+
window.dispatchEvent(new CustomEvent('sim:meshGenerated', { detail: this.state.mesh }));
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Compute element stiffness matrix (simplified)
|
|
251
|
+
* @private
|
|
252
|
+
*/
|
|
253
|
+
_computeElementStiffness(nodes, nodeIds) {
|
|
254
|
+
// Simplified 12x12 stiffness matrix for tetrahedral element
|
|
255
|
+
// In production, would use proper FEM formulation (shape functions, Jacobian, etc.)
|
|
256
|
+
const K = new Array(12).fill(0).map(() => new Array(12).fill(0));
|
|
257
|
+
|
|
258
|
+
// Simple diagonal dominance based on material properties
|
|
259
|
+
const scale = this.state.material.E / 1000;
|
|
260
|
+
for (let i = 0; i < 12; i++) {
|
|
261
|
+
K[i][i] = scale * (Math.random() + 0.5);
|
|
262
|
+
if (i > 0) K[i][i - 1] = -scale * 0.1;
|
|
263
|
+
if (i < 11) K[i][i + 1] = -scale * 0.1;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return K;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Solve the analysis (simplified linear solver)
|
|
271
|
+
* @returns {Promise<Object>} solver results
|
|
272
|
+
*/
|
|
273
|
+
async solve() {
|
|
274
|
+
if (!this.state.activeAnalysis) {
|
|
275
|
+
console.error('[Simulation] No active analysis setup');
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!this.state.mesh) {
|
|
280
|
+
console.error('[Simulation] No mesh generated');
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.state.solver = {
|
|
285
|
+
status: 'solving',
|
|
286
|
+
progress: 0,
|
|
287
|
+
elapsed: 0,
|
|
288
|
+
startTime: Date.now(),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
window.dispatchEvent(new CustomEvent('sim:solveStart', { detail: {} }));
|
|
292
|
+
|
|
293
|
+
return new Promise((resolve) => {
|
|
294
|
+
const simulateSolve = async () => {
|
|
295
|
+
const startTime = Date.now();
|
|
296
|
+
const duration = 3000; // Simulate 3 second solve
|
|
297
|
+
|
|
298
|
+
// Progress animation
|
|
299
|
+
const progressInterval = setInterval(() => {
|
|
300
|
+
const elapsed = Date.now() - startTime;
|
|
301
|
+
this.state.solver.progress = Math.min(elapsed / duration, 1);
|
|
302
|
+
this.state.solver.elapsed = Math.round(elapsed / 100) / 10;
|
|
303
|
+
|
|
304
|
+
window.dispatchEvent(new CustomEvent('sim:solveProgress', {
|
|
305
|
+
detail: { progress: this.state.solver.progress, elapsed: this.state.solver.elapsed }
|
|
306
|
+
}));
|
|
307
|
+
|
|
308
|
+
if (this.state.solver.progress >= 1) {
|
|
309
|
+
clearInterval(progressInterval);
|
|
310
|
+
}
|
|
311
|
+
}, 100);
|
|
312
|
+
|
|
313
|
+
// Wait for simulated solve
|
|
314
|
+
await new Promise(r => setTimeout(r, duration));
|
|
315
|
+
|
|
316
|
+
// Compute simplified results
|
|
317
|
+
const results = this._computeResults();
|
|
318
|
+
this.state.results = results;
|
|
319
|
+
this.state.solver.status = 'complete';
|
|
320
|
+
|
|
321
|
+
if (window._debug) console.log('[Simulation] Solve complete', results);
|
|
322
|
+
window.dispatchEvent(new CustomEvent('sim:solveComplete', { detail: results }));
|
|
323
|
+
|
|
324
|
+
resolve(results);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
simulateSolve();
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Compute simplified analysis results
|
|
333
|
+
* @private
|
|
334
|
+
*/
|
|
335
|
+
_computeResults() {
|
|
336
|
+
const stressData = [];
|
|
337
|
+
const displacementData = [];
|
|
338
|
+
const safetyFactor = [];
|
|
339
|
+
|
|
340
|
+
for (const node of this.state.mesh.nodes) {
|
|
341
|
+
// Simplified stress calculation based on loads and constraints
|
|
342
|
+
let stress = 0;
|
|
343
|
+
for (const load of this.state.loads) {
|
|
344
|
+
// Pseudo-distance-weighted stress distribution
|
|
345
|
+
const dist = Math.random() * 50;
|
|
346
|
+
stress += (load.value / (1 + dist / 10)) * Math.random();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
stressData.push(Math.max(0, Math.min(stress, this.state.material.yieldStrength)));
|
|
350
|
+
|
|
351
|
+
// Simplified displacement
|
|
352
|
+
const dispMagnitude = stress / (this.state.material.E * 10) * (1 + Math.random() * 0.5);
|
|
353
|
+
displacementData.push(dispMagnitude);
|
|
354
|
+
|
|
355
|
+
// Safety factor (yield / von Mises)
|
|
356
|
+
safetyFactor.push(Math.max(0.5, this.state.material.yieldStrength / (stress + 1)));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
stress: stressData,
|
|
361
|
+
displacement: displacementData,
|
|
362
|
+
safetyFactor,
|
|
363
|
+
maxStress: Math.max(...stressData),
|
|
364
|
+
minStress: Math.min(...stressData),
|
|
365
|
+
maxDisplacement: Math.max(...displacementData),
|
|
366
|
+
mass: this._estimateMass(),
|
|
367
|
+
frequency: this.state.activeAnalysis.type === 'modal' ? [125, 247, 489, 652, 743] : null,
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Estimate mass of body
|
|
373
|
+
* @private
|
|
374
|
+
*/
|
|
375
|
+
_estimateMass() {
|
|
376
|
+
if (!this.state.mesh) return 0;
|
|
377
|
+
const volumeEstimate = this.state.mesh.elementCount * Math.pow(this.state.mesh.elementSize, 3) / 1000; // cm³
|
|
378
|
+
return volumeEstimate * this.state.material.rho; // grams
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Show results visualization on 3D model
|
|
383
|
+
* @param {string} resultType - stress, displacement, safety, temperature, mode
|
|
384
|
+
*/
|
|
385
|
+
showResults(resultType = 'stress') {
|
|
386
|
+
if (!this.state.results) {
|
|
387
|
+
console.error('[Simulation] No results available. Run solve() first.');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const resultKey = resultType.toLowerCase();
|
|
392
|
+
let resultData = null;
|
|
393
|
+
let colorMap = null;
|
|
394
|
+
|
|
395
|
+
switch (resultKey) {
|
|
396
|
+
case 'stress':
|
|
397
|
+
resultData = this.state.results.stress;
|
|
398
|
+
colorMap = this._getVonMisesColorMap();
|
|
399
|
+
break;
|
|
400
|
+
|
|
401
|
+
case 'displacement':
|
|
402
|
+
resultData = this.state.results.displacement;
|
|
403
|
+
colorMap = this._getDisplacementColorMap();
|
|
404
|
+
break;
|
|
405
|
+
|
|
406
|
+
case 'safety':
|
|
407
|
+
resultData = this.state.results.safetyFactor;
|
|
408
|
+
colorMap = this._getSafetyFactorColorMap();
|
|
409
|
+
break;
|
|
410
|
+
|
|
411
|
+
case 'temperature':
|
|
412
|
+
if (this.state.activeAnalysis.type === 'thermal') {
|
|
413
|
+
resultData = new Array(this.state.mesh.nodes.length).fill(20).map((v, i) => v + i * 0.5);
|
|
414
|
+
colorMap = this._getTemperatureColorMap();
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
|
|
418
|
+
default:
|
|
419
|
+
console.error(`[Simulation] Unknown result type: ${resultType}`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!resultData) return;
|
|
424
|
+
|
|
425
|
+
// Create color visualization mesh
|
|
426
|
+
const geometry = new THREE.BufferGeometry();
|
|
427
|
+
const colors = new Float32Array(resultData.length * 3);
|
|
428
|
+
|
|
429
|
+
for (let i = 0; i < resultData.length; i++) {
|
|
430
|
+
const color = colorMap(resultData[i], Math.min(...resultData), Math.max(...resultData));
|
|
431
|
+
colors[i * 3 + 0] = color.r;
|
|
432
|
+
colors[i * 3 + 1] = color.g;
|
|
433
|
+
colors[i * 3 + 2] = color.b;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
437
|
+
|
|
438
|
+
if (window._debug) console.log(`[Simulation] Displaying ${resultType} results`);
|
|
439
|
+
window.dispatchEvent(new CustomEvent('sim:resultsReady', {
|
|
440
|
+
detail: { resultType, min: Math.min(...resultData), max: Math.max(...resultData) }
|
|
441
|
+
}));
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Get von Mises stress color map (blue → red)
|
|
446
|
+
* @private
|
|
447
|
+
*/
|
|
448
|
+
_getVonMisesColorMap() {
|
|
449
|
+
return (value, min, max) => {
|
|
450
|
+
const t = (value - min) / (max - min + 0.01);
|
|
451
|
+
if (t < 0.25) {
|
|
452
|
+
return { r: 0, g: 0, b: 1 - t * 4 }; // Blue to cyan
|
|
453
|
+
} else if (t < 0.5) {
|
|
454
|
+
return { r: 0, g: (t - 0.25) * 4, b: 0 }; // Cyan to green
|
|
455
|
+
} else if (t < 0.75) {
|
|
456
|
+
return { r: (t - 0.5) * 4, g: 1, b: 0 }; // Green to yellow
|
|
457
|
+
} else {
|
|
458
|
+
return { r: 1, g: 1 - (t - 0.75) * 4, b: 0 }; // Yellow to red
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get displacement color map (green → orange)
|
|
465
|
+
* @private
|
|
466
|
+
*/
|
|
467
|
+
_getDisplacementColorMap() {
|
|
468
|
+
return (value, min, max) => {
|
|
469
|
+
const t = (value - min) / (max - min + 0.01);
|
|
470
|
+
return {
|
|
471
|
+
r: t,
|
|
472
|
+
g: 1 - t * 0.5,
|
|
473
|
+
b: 0,
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Get safety factor color map (red → green)
|
|
480
|
+
* @private
|
|
481
|
+
*/
|
|
482
|
+
_getSafetyFactorColorMap() {
|
|
483
|
+
return (value, min, max) => {
|
|
484
|
+
const t = Math.min(value / 3, 1); // Normalize to 0-3 range
|
|
485
|
+
return {
|
|
486
|
+
r: 1 - t,
|
|
487
|
+
g: t,
|
|
488
|
+
b: 0,
|
|
489
|
+
};
|
|
490
|
+
};
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Get temperature color map (blue → red)
|
|
495
|
+
* @private
|
|
496
|
+
*/
|
|
497
|
+
_getTemperatureColorMap() {
|
|
498
|
+
return (value, min, max) => {
|
|
499
|
+
const t = (value - min) / (max - min + 0.01);
|
|
500
|
+
if (t < 0.5) {
|
|
501
|
+
return { r: 0, g: 0, b: 1 - t * 2 }; // Blue to cyan
|
|
502
|
+
} else {
|
|
503
|
+
return { r: (t - 0.5) * 2, g: 0, b: 1 - (t - 0.5) * 2 }; // Cyan to red
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Probe results at a specific point
|
|
510
|
+
* @param {THREE.Vector3} point - probe location
|
|
511
|
+
* @returns {Object} {stress, displacement, safety, temperature}
|
|
512
|
+
*/
|
|
513
|
+
probe(point) {
|
|
514
|
+
if (!this.state.results || !this.state.mesh) {
|
|
515
|
+
console.error('[Simulation] No results available');
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Find nearest node
|
|
520
|
+
let nearestNode = null;
|
|
521
|
+
let minDist = Infinity;
|
|
522
|
+
|
|
523
|
+
for (const node of this.state.mesh.nodes) {
|
|
524
|
+
const dist = node.position.distanceTo(point);
|
|
525
|
+
if (dist < minDist) {
|
|
526
|
+
minDist = dist;
|
|
527
|
+
nearestNode = node;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!nearestNode) return null;
|
|
532
|
+
|
|
533
|
+
const idx = nearestNode.id;
|
|
534
|
+
const values = {
|
|
535
|
+
stress: this.state.results.stress[idx],
|
|
536
|
+
displacement: this.state.results.displacement[idx],
|
|
537
|
+
safety: this.state.results.safetyFactor[idx],
|
|
538
|
+
temperature: 20 + (this.state.results.stress[idx] / 100), // Pseudo-thermal
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
if (window._debug) console.log(`[Simulation] Probed at ${point.x}, ${point.y}, ${point.z}:`, values);
|
|
542
|
+
window.dispatchEvent(new CustomEvent('sim:probed', { detail: values }));
|
|
543
|
+
|
|
544
|
+
return values;
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Export results as HTML report
|
|
549
|
+
* @returns {string} HTML report
|
|
550
|
+
*/
|
|
551
|
+
exportReport() {
|
|
552
|
+
if (!this.state.results || !this.state.activeAnalysis) {
|
|
553
|
+
console.error('[Simulation] No analysis results to export');
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const html = `
|
|
558
|
+
<!DOCTYPE html>
|
|
559
|
+
<html>
|
|
560
|
+
<head>
|
|
561
|
+
<title>FEA Simulation Report</title>
|
|
562
|
+
<style>
|
|
563
|
+
body { font-family: Calibri, sans-serif; margin: 40px; background: #f5f5f5; }
|
|
564
|
+
.header { background: #0284c7; color: white; padding: 20px; border-radius: 4px; margin-bottom: 20px; }
|
|
565
|
+
.section { background: white; padding: 16px; margin-bottom: 16px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
566
|
+
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
|
567
|
+
th { background: #0284c7; color: white; padding: 8px; text-align: left; }
|
|
568
|
+
td { padding: 8px; border-bottom: 1px solid #ddd; }
|
|
569
|
+
tr:hover { background: #f9f9f9; }
|
|
570
|
+
.metric { display: inline-block; margin-right: 20px; min-width: 200px; }
|
|
571
|
+
.value { font-weight: bold; color: #0284c7; }
|
|
572
|
+
</style>
|
|
573
|
+
</head>
|
|
574
|
+
<body>
|
|
575
|
+
<div class="header">
|
|
576
|
+
<h1>FEA Simulation Report</h1>
|
|
577
|
+
<p>Analysis Type: <strong>${this.state.activeAnalysis.type.toUpperCase()}</strong></p>
|
|
578
|
+
<p>Generated: ${new Date().toLocaleString()}</p>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
<div class="section">
|
|
582
|
+
<h2>Material & Setup</h2>
|
|
583
|
+
<div class="metric">
|
|
584
|
+
<strong>Material:</strong> <span class="value">${this.state.material.name}</span>
|
|
585
|
+
</div>
|
|
586
|
+
<div class="metric">
|
|
587
|
+
<strong>Young's Modulus:</strong> <span class="value">${this.state.material.E} GPa</span>
|
|
588
|
+
</div>
|
|
589
|
+
<div class="metric">
|
|
590
|
+
<strong>Yield Strength:</strong> <span class="value">${this.state.material.yieldStrength} MPa</span>
|
|
591
|
+
</div>
|
|
592
|
+
<div class="metric">
|
|
593
|
+
<strong>Density:</strong> <span class="value">${this.state.material.rho} g/cm³</span>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
|
|
597
|
+
<div class="section">
|
|
598
|
+
<h2>Mesh Statistics</h2>
|
|
599
|
+
<div class="metric">
|
|
600
|
+
<strong>Nodes:</strong> <span class="value">${this.state.mesh.nodeCount.toLocaleString()}</span>
|
|
601
|
+
</div>
|
|
602
|
+
<div class="metric">
|
|
603
|
+
<strong>Elements:</strong> <span class="value">${this.state.mesh.elementCount.toLocaleString()}</span>
|
|
604
|
+
</div>
|
|
605
|
+
<div class="metric">
|
|
606
|
+
<strong>Element Size:</strong> <span class="value">${this.state.mesh.elementSize} mm</span>
|
|
607
|
+
</div>
|
|
608
|
+
<div class="metric">
|
|
609
|
+
<strong>Mesh Quality:</strong> <span class="value">${(this.state.mesh.qualityMetric * 100).toFixed(1)}%</span>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
|
|
613
|
+
<div class="section">
|
|
614
|
+
<h2>Loading & Constraints</h2>
|
|
615
|
+
<h3>Applied Loads (${this.state.loads.length})</h3>
|
|
616
|
+
<table>
|
|
617
|
+
<tr>
|
|
618
|
+
<th>Type</th>
|
|
619
|
+
<th>Target</th>
|
|
620
|
+
<th>Value</th>
|
|
621
|
+
<th>Direction</th>
|
|
622
|
+
</tr>
|
|
623
|
+
${this.state.loads.map(l => `
|
|
624
|
+
<tr>
|
|
625
|
+
<td>${l.type.toUpperCase()}</td>
|
|
626
|
+
<td>${l.target}</td>
|
|
627
|
+
<td>${l.value.toFixed(2)}</td>
|
|
628
|
+
<td>${l.direction.x.toFixed(2)}, ${l.direction.y.toFixed(2)}, ${l.direction.z.toFixed(2)}</td>
|
|
629
|
+
</tr>
|
|
630
|
+
`).join('')}
|
|
631
|
+
</table>
|
|
632
|
+
|
|
633
|
+
<h3>Boundary Conditions (${this.state.constraints.length})</h3>
|
|
634
|
+
<table>
|
|
635
|
+
<tr>
|
|
636
|
+
<th>Type</th>
|
|
637
|
+
<th>Target</th>
|
|
638
|
+
</tr>
|
|
639
|
+
${this.state.constraints.map(c => `
|
|
640
|
+
<tr>
|
|
641
|
+
<td>${c.type.toUpperCase()}</td>
|
|
642
|
+
<td>${c.target}</td>
|
|
643
|
+
</tr>
|
|
644
|
+
`).join('')}
|
|
645
|
+
</table>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
<div class="section">
|
|
649
|
+
<h2>Results Summary</h2>
|
|
650
|
+
<div class="metric">
|
|
651
|
+
<strong>Max Von Mises Stress:</strong> <span class="value">${this.state.results.maxStress.toFixed(1)} MPa</span>
|
|
652
|
+
</div>
|
|
653
|
+
<div class="metric">
|
|
654
|
+
<strong>Min Von Mises Stress:</strong> <span class="value">${this.state.results.minStress.toFixed(1)} MPa</span>
|
|
655
|
+
</div>
|
|
656
|
+
<div class="metric">
|
|
657
|
+
<strong>Max Displacement:</strong> <span class="value">${this.state.results.maxDisplacement.toFixed(3)} mm</span>
|
|
658
|
+
</div>
|
|
659
|
+
<div class="metric">
|
|
660
|
+
<strong>Estimated Mass:</strong> <span class="value">${this.state.results.mass.toFixed(1)} g</span>
|
|
661
|
+
</div>
|
|
662
|
+
${this.state.results.frequency ? `
|
|
663
|
+
<div class="metric">
|
|
664
|
+
<strong>Natural Frequencies (Hz):</strong> <span class="value">${this.state.results.frequency.map(f => f.toFixed(1)).join(', ')}</span>
|
|
665
|
+
</div>
|
|
666
|
+
` : ''}
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
<div class="section">
|
|
670
|
+
<h2>Safety Analysis</h2>
|
|
671
|
+
<p>Minimum Safety Factor: <strong style="color: ${this._safetyColor(Math.min(...this.state.results.safetyFactor))};">${(Math.min(...this.state.results.safetyFactor)).toFixed(2)}</strong></p>
|
|
672
|
+
<p>Design is <strong>${Math.min(...this.state.results.safetyFactor) > 1 ? 'SAFE' : 'AT RISK'}</strong></p>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<div class="section" style="text-align: center; color: #888; font-size: 12px;">
|
|
676
|
+
<p>Report generated by cycleCAD FEA Simulator v${this.version}</p>
|
|
677
|
+
</div>
|
|
678
|
+
</body>
|
|
679
|
+
</html>
|
|
680
|
+
`;
|
|
681
|
+
|
|
682
|
+
return html;
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Get color for safety factor
|
|
687
|
+
* @private
|
|
688
|
+
*/
|
|
689
|
+
_safetyColor(sf) {
|
|
690
|
+
if (sf > 2) return '#00aa00'; // Green
|
|
691
|
+
if (sf > 1) return '#ffaa00'; // Orange
|
|
692
|
+
return '#ff0000'; // Red
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Get UI panel for simulation control
|
|
697
|
+
* @returns {HTMLElement}
|
|
698
|
+
*/
|
|
699
|
+
getUI() {
|
|
700
|
+
const panel = document.createElement('div');
|
|
701
|
+
panel.className = 'simulation-panel';
|
|
702
|
+
panel.style.cssText = `
|
|
703
|
+
width: 300px;
|
|
704
|
+
height: 100%;
|
|
705
|
+
background: #1e1e1e;
|
|
706
|
+
color: #e0e0e0;
|
|
707
|
+
font-family: Calibri, sans-serif;
|
|
708
|
+
font-size: 13px;
|
|
709
|
+
border-left: 1px solid #333;
|
|
710
|
+
overflow-y: auto;
|
|
711
|
+
padding: 12px;
|
|
712
|
+
`;
|
|
713
|
+
|
|
714
|
+
const self = this;
|
|
715
|
+
|
|
716
|
+
panel.innerHTML = `
|
|
717
|
+
<div style="margin-bottom: 16px;">
|
|
718
|
+
<h3 style="margin: 0 0 8px 0; color: #0284c7;">FEA Simulation</h3>
|
|
719
|
+
|
|
720
|
+
<div style="margin-bottom: 12px;">
|
|
721
|
+
<label style="display: block; margin-bottom: 4px;">Analysis Type</label>
|
|
722
|
+
<select id="analysis-type" style="width: 100%; padding: 4px; background: #252525; color: #e0e0e0; border: 1px solid #444; border-radius: 3px;">
|
|
723
|
+
<option value="static">Static Stress</option>
|
|
724
|
+
<option value="thermal">Thermal</option>
|
|
725
|
+
<option value="modal">Modal (Vibration)</option>
|
|
726
|
+
<option value="buckling">Buckling</option>
|
|
727
|
+
</select>
|
|
728
|
+
</div>
|
|
729
|
+
|
|
730
|
+
<div style="margin-bottom: 12px;">
|
|
731
|
+
<label style="display: block; margin-bottom: 4px;">Material</label>
|
|
732
|
+
<select id="material-select" style="width: 100%; padding: 4px; background: #252525; color: #e0e0e0; border: 1px solid #444; border-radius: 3px;">
|
|
733
|
+
${Object.keys(this.MATERIALS).map(k => `<option value="${k}">${this.MATERIALS[k].name}</option>`).join('')}
|
|
734
|
+
</select>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
<div style="margin-bottom: 12px;">
|
|
738
|
+
<label style="display: block; margin-bottom: 4px;">Loads (${this.state.loads.length})</label>
|
|
739
|
+
<div id="loads-list" style="max-height: 120px; overflow-y: auto; background: #252525; border-radius: 4px; padding: 8px;">
|
|
740
|
+
${this.state.loads.length === 0 ? '<p style="color: #666; margin: 0; font-size: 11px;">No loads applied</p>' : ''}
|
|
741
|
+
${this.state.loads.map(l => `
|
|
742
|
+
<div style="padding: 4px; margin-bottom: 4px; background: #333; border-radius: 2px; font-size: 11px;">
|
|
743
|
+
${l.type.toUpperCase()}: ${l.value.toFixed(1)} ${l.type === 'pressure' ? 'N/mm²' : 'N'}
|
|
744
|
+
<button data-load-id="${l.id}" class="btn-remove-load" style="float: right; padding: 1px 4px; font-size: 10px; background: #ff4444; border: none; color: white; border-radius: 2px; cursor: pointer;">✕</button>
|
|
745
|
+
</div>
|
|
746
|
+
`).join('')}
|
|
747
|
+
</div>
|
|
748
|
+
<button id="btn-add-load" style="width: 100%; margin-top: 6px; padding: 4px; background: #0284c7; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px;">+ Add Load</button>
|
|
749
|
+
</div>
|
|
750
|
+
|
|
751
|
+
<div style="margin-bottom: 12px;">
|
|
752
|
+
<label style="display: block; margin-bottom: 4px;">Constraints (${this.state.constraints.length})</label>
|
|
753
|
+
<button id="btn-fix-all" style="width: 48%; padding: 4px; margin-right: 4%; background: #0284c7; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px;">Fix All</button>
|
|
754
|
+
<button id="btn-clear-bcs" style="width: 48%; padding: 4px; background: #ff4444; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px;">Clear</button>
|
|
755
|
+
</div>
|
|
756
|
+
|
|
757
|
+
<div style="margin-bottom: 12px;">
|
|
758
|
+
<label style="display: block; margin-bottom: 4px;">Element Size: <span id="elem-size-val">10</span> mm</label>
|
|
759
|
+
<input type="range" id="element-size" min="2" max="50" value="10" style="width: 100%; cursor: pointer;">
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
<button id="btn-mesh" style="width: 100%; padding: 8px; margin-bottom: 6px; background: #0284c7; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
|
763
|
+
${this.state.mesh ? '✓ Mesh' : 'Generate Mesh'}
|
|
764
|
+
</button>
|
|
765
|
+
|
|
766
|
+
<button id="btn-solve" style="width: 100%; padding: 8px; margin-bottom: 6px; background: #00aa00; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
|
767
|
+
Solve ${this.state.solver && this.state.solver.status === 'solving' ? `(${(this.state.solver.progress * 100).toFixed(0)}%)` : ''}
|
|
768
|
+
</button>
|
|
769
|
+
|
|
770
|
+
<div id="results-panel" style="margin-top: 12px; display: ${this.state.results ? 'block' : 'none'};">
|
|
771
|
+
<h4 style="margin: 0 0 8px 0; color: #0284c7;">Results</h4>
|
|
772
|
+
<button id="btn-stress" style="width: 100%; padding: 4px; margin-bottom: 4px; background: #444; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px;">Von Mises Stress</button>
|
|
773
|
+
<button id="btn-displacement" style="width: 100%; padding: 4px; margin-bottom: 4px; background: #444; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px;">Displacement</button>
|
|
774
|
+
<button id="btn-safety" style="width: 100%; padding: 4px; margin-bottom: 4px; background: #444; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px;">Safety Factor</button>
|
|
775
|
+
<button id="btn-export" style="width: 100%; padding: 4px; background: #0284c7; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 11px;">Export Report</button>
|
|
776
|
+
</div>
|
|
777
|
+
</div>
|
|
778
|
+
`;
|
|
779
|
+
|
|
780
|
+
// Event handlers
|
|
781
|
+
panel.querySelector('#analysis-type').addEventListener('change', (e) => {
|
|
782
|
+
self.setup('body1', e.target.value);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
panel.querySelector('#material-select').addEventListener('change', (e) => {
|
|
786
|
+
self.setMaterial('body1', e.target.value);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
panel.querySelector('#element-size').addEventListener('input', (e) => {
|
|
790
|
+
panel.querySelector('#elem-size-val').textContent = e.target.value;
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
panel.querySelector('#btn-add-load').addEventListener('click', () => {
|
|
794
|
+
const value = prompt('Load value (N or N/mm²):', '100');
|
|
795
|
+
if (value) self.addLoad('force', 'face1', parseFloat(value));
|
|
796
|
+
location.reload(); // Refresh UI (in real app, use state update)
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
panel.querySelector('#btn-mesh').addEventListener('click', () => {
|
|
800
|
+
const elemSize = parseFloat(panel.querySelector('#element-size').value);
|
|
801
|
+
self.mesh('body1', elemSize);
|
|
802
|
+
panel.querySelector('#btn-mesh').textContent = '✓ Mesh Generated';
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
panel.querySelector('#btn-solve').addEventListener('click', async () => {
|
|
806
|
+
const btn = panel.querySelector('#btn-solve');
|
|
807
|
+
btn.disabled = true;
|
|
808
|
+
btn.textContent = 'Solving...';
|
|
809
|
+
await self.solve();
|
|
810
|
+
btn.disabled = false;
|
|
811
|
+
btn.textContent = 'Solve Complete ✓';
|
|
812
|
+
panel.querySelector('#results-panel').style.display = 'block';
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
panel.querySelector('#btn-stress')?.addEventListener('click', () => self.showResults('stress'));
|
|
816
|
+
panel.querySelector('#btn-displacement')?.addEventListener('click', () => self.showResults('displacement'));
|
|
817
|
+
panel.querySelector('#btn-safety')?.addEventListener('click', () => self.showResults('safety'));
|
|
818
|
+
|
|
819
|
+
panel.querySelector('#btn-export')?.addEventListener('click', () => {
|
|
820
|
+
const html = self.exportReport();
|
|
821
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
822
|
+
const url = URL.createObjectURL(blob);
|
|
823
|
+
const a = document.createElement('a');
|
|
824
|
+
a.href = url;
|
|
825
|
+
a.download = `sim-report-${Date.now()}.html`;
|
|
826
|
+
a.click();
|
|
827
|
+
URL.revokeObjectURL(url);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
return panel;
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
export default SimulationModule;
|