cyclecad 3.6.0 → 3.7.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.
@@ -0,0 +1,1244 @@
1
+ /**
2
+ * Multi-Physics Real-Time Simulation Module
3
+ * cycleCAD v3.4.0
4
+ *
5
+ * Integrates structural FEA, thermal analysis, modal analysis, and drop test simulation.
6
+ * Uses Three.js for visualization, iterative solvers for performance.
7
+ *
8
+ * @namespace window.CycleCAD.MultiPhysics
9
+ */
10
+
11
+ window.CycleCAD = window.CycleCAD || {};
12
+
13
+ window.CycleCAD.MultiPhysics = (() => {
14
+ 'use strict';
15
+
16
+ // ========== STATE ==========
17
+ let scene = null;
18
+ let meshData = null;
19
+ let currentAnalysis = 'static';
20
+ let simulationState = {
21
+ isRunning: false,
22
+ progress: 0,
23
+ results: null,
24
+ deformedGeometry: null,
25
+ stressTexture: null,
26
+ originalPositions: null,
27
+ selectedProbe: null,
28
+ };
29
+
30
+ // Material database with mechanical + thermal properties
31
+ const MATERIALS = {
32
+ steel: {
33
+ name: 'Structural Steel',
34
+ density: 7850, // kg/m³
35
+ youngsModulus: 210e9, // Pa
36
+ poissonsRatio: 0.3,
37
+ yieldStress: 250e6, // Pa
38
+ thermalConductivity: 50, // W/m·K
39
+ specificHeat: 490, // J/kg·K
40
+ thermalExpansion: 12e-6, // 1/K
41
+ },
42
+ aluminum: {
43
+ name: 'Aluminum 6061',
44
+ density: 2700,
45
+ youngsModulus: 69e9,
46
+ poissonsRatio: 0.33,
47
+ yieldStress: 275e6,
48
+ thermalConductivity: 167,
49
+ specificHeat: 896,
50
+ thermalExpansion: 23.6e-6,
51
+ },
52
+ titanium: {
53
+ name: 'Titanium Grade 5',
54
+ density: 4510,
55
+ youngsModulus: 103e9,
56
+ poissonsRatio: 0.342,
57
+ yieldStress: 880e6,
58
+ thermalConductivity: 7.4,
59
+ specificHeat: 526,
60
+ thermalExpansion: 8.6e-6,
61
+ },
62
+ abs: {
63
+ name: 'ABS Plastic',
64
+ density: 1040,
65
+ youngsModulus: 2.3e9,
66
+ poissonsRatio: 0.35,
67
+ yieldStress: 40e6,
68
+ thermalConductivity: 0.2,
69
+ specificHeat: 1500,
70
+ thermalExpansion: 70e-6,
71
+ },
72
+ };
73
+
74
+ // ========== 1. MESH DISCRETIZATION ==========
75
+
76
+ /**
77
+ * Convert Three.js geometry to mesh with nodes and elements
78
+ * Uses surface triangles + interior sampling for tetrahedra-like structure
79
+ */
80
+ function discretizeMesh(geometry, resolution = 'medium') {
81
+ const posAttr = geometry.getAttribute('position');
82
+ const positions = posAttr.array;
83
+ const indices = geometry.index ? geometry.index.array : null;
84
+
85
+ // Extract unique nodes
86
+ const nodes = [];
87
+ const nodeMap = new Map();
88
+ const vertexCount = positions.length / 3;
89
+
90
+ for (let i = 0; i < vertexCount; i++) {
91
+ const x = positions[i * 3];
92
+ const y = positions[i * 3 + 1];
93
+ const z = positions[i * 3 + 2];
94
+ const key = `${x.toFixed(6)},${y.toFixed(6)},${z.toFixed(6)}`;
95
+ if (!nodeMap.has(key)) {
96
+ nodeMap.set(key, nodes.length);
97
+ nodes.push({ x, y, z, id: nodes.length });
98
+ }
99
+ }
100
+
101
+ // Build surface elements (triangles)
102
+ const elements = [];
103
+ if (indices) {
104
+ for (let i = 0; i < indices.length; i += 3) {
105
+ const i0 = indices[i];
106
+ const i1 = indices[i + 1];
107
+ const i2 = indices[i + 2];
108
+ const n0 = nodeMap.get(`${positions[i0*3].toFixed(6)},${positions[i0*3+1].toFixed(6)},${positions[i0*3+2].toFixed(6)}`);
109
+ const n1 = nodeMap.get(`${positions[i1*3].toFixed(6)},${positions[i1*3+1].toFixed(6)},${positions[i1*3+2].toFixed(6)}`);
110
+ const n2 = nodeMap.get(`${positions[i2*3].toFixed(6)},${positions[i2*3+1].toFixed(6)},${positions[i2*3+2].toFixed(6)}`);
111
+ if (n0 !== undefined && n1 !== undefined && n2 !== undefined) {
112
+ elements.push({ nodes: [n0, n1, n2], type: 'triangle', stress: 0, strain: 0 });
113
+ }
114
+ }
115
+ } else {
116
+ // If no indices, build triangles from vertex sequence
117
+ for (let i = 0; i < vertexCount - 2; i += 3) {
118
+ const key0 = `${positions[i*3].toFixed(6)},${positions[i*3+1].toFixed(6)},${positions[i*3+2].toFixed(6)}`;
119
+ const key1 = `${positions[(i+1)*3].toFixed(6)},${positions[(i+1)*3+1].toFixed(6)},${positions[(i+1)*3+2].toFixed(6)}`;
120
+ const key2 = `${positions[(i+2)*3].toFixed(6)},${positions[(i+2)*3+1].toFixed(6)},${positions[(i+2)*3+2].toFixed(6)}`;
121
+ const n0 = nodeMap.get(key0);
122
+ const n1 = nodeMap.get(key1);
123
+ const n2 = nodeMap.get(key2);
124
+ if (n0 !== undefined && n1 !== undefined && n2 !== undefined) {
125
+ elements.push({ nodes: [n0, n1, n2], type: 'triangle', stress: 0, strain: 0 });
126
+ }
127
+ }
128
+ }
129
+
130
+ // Mesh quality assessment
131
+ const meshQuality = assessMeshQuality(nodes, elements);
132
+
133
+ return {
134
+ nodes,
135
+ elements,
136
+ nodeMap,
137
+ quality: meshQuality,
138
+ elementCount: elements.length,
139
+ nodeCount: nodes.length,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Assess mesh quality: aspect ratio, skewness
145
+ */
146
+ function assessMeshQuality(nodes, elements) {
147
+ let totalAspectRatio = 0;
148
+ let totalSkewness = 0;
149
+ let count = 0;
150
+
151
+ elements.forEach(elem => {
152
+ if (elem.nodes.length === 3) {
153
+ const n0 = nodes[elem.nodes[0]];
154
+ const n1 = nodes[elem.nodes[1]];
155
+ const n2 = nodes[elem.nodes[2]];
156
+
157
+ const e1 = { x: n1.x - n0.x, y: n1.y - n0.y, z: n1.z - n0.z };
158
+ const e2 = { x: n2.x - n0.x, y: n2.y - n0.y, z: n2.z - n0.z };
159
+
160
+ const len1 = Math.sqrt(e1.x**2 + e1.y**2 + e1.z**2);
161
+ const len2 = Math.sqrt(e2.x**2 + e2.y**2 + e2.z**2);
162
+ const aspectRatio = Math.max(len1, len2) / Math.min(len1, len2);
163
+ totalAspectRatio += aspectRatio;
164
+
165
+ const dot = e1.x*e2.x + e1.y*e2.y + e1.z*e2.z;
166
+ const mag1 = Math.sqrt(e1.x**2 + e1.y**2 + e1.z**2);
167
+ const mag2 = Math.sqrt(e2.x**2 + e2.y**2 + e2.z**2);
168
+ const angle = mag1 > 0 && mag2 > 0 ? Math.acos(Math.max(-1, Math.min(1, dot/(mag1*mag2)))) : 0;
169
+ const skewness = Math.abs(angle - Math.PI/3) / (Math.PI/3);
170
+ totalSkewness += skewness;
171
+
172
+ count++;
173
+ }
174
+ });
175
+
176
+ return {
177
+ avgAspectRatio: count > 0 ? totalAspectRatio / count : 1,
178
+ avgSkewness: count > 0 ? totalSkewness / count : 0,
179
+ quality: count > 0 && (totalAspectRatio/count) < 5 ? 'good' : count > 0 && (totalAspectRatio/count) < 10 ? 'acceptable' : 'poor',
180
+ };
181
+ }
182
+
183
+ // ========== 2. STRUCTURAL FEA SOLVER ==========
184
+
185
+ /**
186
+ * Run linear static structural analysis
187
+ * Uses conjugate gradient solver for K·u = F
188
+ */
189
+ function solveStructural(material, loads, constraints) {
190
+ if (!meshData) return { error: 'No mesh data' };
191
+
192
+ const { nodes, elements } = meshData;
193
+ const nDOF = nodes.length * 3; // 3 DOF per node (x, y, z)
194
+
195
+ // Initialize global stiffness matrix (sparse, stored as COO)
196
+ const K = new SparseMatrix(nDOF);
197
+ const M = new SparseMatrix(nDOF); // Mass matrix for factor of safety
198
+
199
+ // Assemble element stiffness matrices
200
+ const E = material.youngsModulus;
201
+ const nu = material.poissonsRatio;
202
+ const rho = material.density;
203
+
204
+ // Simplified isotropic stiffness for triangular elements
205
+ const G = E / (2 * (1 + nu));
206
+ const lambda = (E * nu) / ((1 + nu) * (1 - 2*nu));
207
+
208
+ elements.forEach((elem, eIdx) => {
209
+ if (elem.nodes.length !== 3) return;
210
+
211
+ const n0 = nodes[elem.nodes[0]];
212
+ const n1 = nodes[elem.nodes[1]];
213
+ const n2 = nodes[elem.nodes[2]];
214
+
215
+ // Element vectors
216
+ const v1 = { x: n1.x - n0.x, y: n1.y - n0.y, z: n1.z - n0.z };
217
+ const v2 = { x: n2.x - n0.x, y: n2.y - n0.y, z: n2.z - n0.z };
218
+
219
+ // Area vector (cross product)
220
+ const area = 0.5 * Math.sqrt(
221
+ (v1.y*v2.z - v1.z*v2.y)**2 +
222
+ (v1.z*v2.x - v1.x*v2.z)**2 +
223
+ (v1.x*v2.y - v1.y*v2.x)**2
224
+ );
225
+
226
+ if (area < 1e-10) return;
227
+
228
+ // Simplified 3D spring element stiffness
229
+ const k_spring = (E * area) / Math.sqrt(v1.x**2 + v1.y**2 + v1.z**2 + 1e-10);
230
+
231
+ // Add to global stiffness (simplified)
232
+ const dof0 = elem.nodes[0] * 3;
233
+ const dof1 = elem.nodes[1] * 3;
234
+ const dof2 = elem.nodes[2] * 3;
235
+
236
+ [dof0, dof1, dof2].forEach(i => {
237
+ [dof0, dof1, dof2].forEach(j => {
238
+ K.add(i, j, i === j ? k_spring : -k_spring * 0.5);
239
+ if (i === j) M.add(i, j, rho * area / 3);
240
+ });
241
+ });
242
+ });
243
+
244
+ // Force vector
245
+ const F = new Float64Array(nDOF);
246
+ loads.forEach(load => {
247
+ if (load.type === 'point') {
248
+ const dof = load.nodeId * 3;
249
+ F[dof] += load.force.x || 0;
250
+ F[dof + 1] += load.force.y || 0;
251
+ F[dof + 2] += load.force.z || 0;
252
+ }
253
+ });
254
+
255
+ // Apply gravity
256
+ elements.forEach(elem => {
257
+ const nodeIds = elem.nodes;
258
+ nodeIds.forEach(nId => {
259
+ const dof = nId * 3 + 1; // Y-direction
260
+ F[dof] -= material.density * 9.81 * meshData.quality.avgAspectRatio * 0.01; // Simplified
261
+ });
262
+ });
263
+
264
+ // Boundary conditions: fix constrained DOFs
265
+ constraints.forEach(constraint => {
266
+ if (constraint.type === 'fixed') {
267
+ const dof = constraint.nodeId * 3;
268
+ K.set(dof, dof, 1e30);
269
+ K.set(dof + 1, dof + 1, 1e30);
270
+ K.set(dof + 2, dof + 2, 1e30);
271
+ F[dof] = 0;
272
+ F[dof + 1] = 0;
273
+ F[dof + 2] = 0;
274
+ }
275
+ });
276
+
277
+ // Solve K·u = F using conjugate gradient
278
+ const u = conjugateGradient(K, F, nDOF, 1000, 1e-6);
279
+
280
+ // Compute stresses
281
+ const stresses = computeStresses(u, material);
282
+
283
+ // Compute factor of safety
284
+ const maxStress = Math.max(...stresses);
285
+ const factorOfSafety = maxStress > 0 ? material.yieldStress / maxStress : Infinity;
286
+
287
+ return {
288
+ displacement: u,
289
+ stresses,
290
+ maxStress,
291
+ maxStressLocation: stresses.indexOf(maxStress),
292
+ avgStress: stresses.reduce((a, b) => a + b, 0) / stresses.length,
293
+ factorOfSafety,
294
+ converged: true,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Conjugate gradient iterative solver for Ax = b
300
+ */
301
+ function conjugateGradient(A, b, n, maxIter, tol) {
302
+ const x = new Float64Array(n);
303
+ let r = new Float64Array(b);
304
+ let p = new Float64Array(r);
305
+ let rsold = dotProduct(r, r);
306
+
307
+ for (let i = 0; i < maxIter; i++) {
308
+ const Ap = A.multiply(p);
309
+ const alpha = rsold / (dotProduct(p, Ap) + 1e-15);
310
+
311
+ for (let j = 0; j < n; j++) x[j] += alpha * p[j];
312
+ for (let j = 0; j < n; j++) r[j] -= alpha * Ap[j];
313
+
314
+ const rsnew = dotProduct(r, r);
315
+ if (Math.sqrt(rsnew) < tol) break;
316
+
317
+ const beta = rsnew / (rsold + 1e-15);
318
+ for (let j = 0; j < n; j++) p[j] = r[j] + beta * p[j];
319
+
320
+ rsold = rsnew;
321
+ }
322
+
323
+ return x;
324
+ }
325
+
326
+ /**
327
+ * Compute Von Mises stress from displacement field
328
+ */
329
+ function computeStresses(u, material) {
330
+ const { nodes, elements } = meshData;
331
+ const stresses = new Float64Array(elements.length);
332
+ const E = material.youngsModulus;
333
+ const nu = material.poissonsRatio;
334
+
335
+ elements.forEach((elem, idx) => {
336
+ if (elem.nodes.length !== 3) {
337
+ stresses[idx] = 0;
338
+ return;
339
+ }
340
+
341
+ const n0 = nodes[elem.nodes[0]];
342
+ const n1 = nodes[elem.nodes[1]];
343
+ const n2 = nodes[elem.nodes[2]];
344
+
345
+ // Strain at element center (simplified)
346
+ const du_dx = (u[elem.nodes[1]*3] - u[elem.nodes[0]*3]) / (n1.x - n0.x + 1e-10);
347
+ const du_dy = (u[elem.nodes[1]*3+1] - u[elem.nodes[0]*3+1]) / (n1.y - n0.y + 1e-10);
348
+ const du_dz = (u[elem.nodes[1]*3+2] - u[elem.nodes[0]*3+2]) / (n1.z - n0.z + 1e-10);
349
+
350
+ const strain = Math.sqrt(du_dx**2 + du_dy**2 + du_dz**2);
351
+ const stress = E * strain; // Hook's law (simplified)
352
+
353
+ // Von Mises (simplified)
354
+ stresses[idx] = Math.abs(stress) * (1 + 0.5 * (strain**2));
355
+ });
356
+
357
+ return stresses;
358
+ }
359
+
360
+ // ========== 3. THERMAL ANALYSIS ==========
361
+
362
+ /**
363
+ * Solve steady-state heat conduction: ∇·(k∇T) = Q
364
+ */
365
+ function solveThermal(material, heatSources, boundaryConditions) {
366
+ if (!meshData) return { error: 'No mesh data' };
367
+
368
+ const { nodes, elements } = meshData;
369
+ const n = nodes.length;
370
+
371
+ const K_thermal = new SparseMatrix(n);
372
+ const Q = new Float64Array(n);
373
+
374
+ const k = material.thermalConductivity;
375
+
376
+ elements.forEach(elem => {
377
+ if (elem.nodes.length !== 3) return;
378
+
379
+ const n0 = nodes[elem.nodes[0]];
380
+ const n1 = nodes[elem.nodes[1]];
381
+ const n2 = nodes[elem.nodes[2]];
382
+
383
+ const v1 = { x: n1.x - n0.x, y: n1.y - n0.y, z: n1.z - n0.z };
384
+ const v2 = { x: n2.x - n0.x, y: n2.y - n0.y, z: n2.z - n0.z };
385
+
386
+ const area = 0.5 * Math.sqrt(
387
+ (v1.y*v2.z - v1.z*v2.y)**2 +
388
+ (v1.z*v2.x - v1.x*v2.z)**2 +
389
+ (v1.x*v2.y - v1.y*v2.x)**2
390
+ );
391
+
392
+ const k_e = (k * area) / (Math.sqrt(v1.x**2 + v1.y**2 + v1.z**2) + 1e-10);
393
+
394
+ elem.nodes.forEach((i, ii) => {
395
+ elem.nodes.forEach((j, jj) => {
396
+ K_thermal.add(i, j, ii === jj ? k_e : -k_e);
397
+ });
398
+ });
399
+ });
400
+
401
+ // Add heat sources
402
+ heatSources.forEach(source => {
403
+ if (source.type === 'point') {
404
+ Q[source.nodeId] += source.magnitude || 100;
405
+ }
406
+ });
407
+
408
+ // Boundary conditions
409
+ boundaryConditions.forEach(bc => {
410
+ if (bc.type === 'fixed') {
411
+ K_thermal.set(bc.nodeId, bc.nodeId, 1e30);
412
+ Q[bc.nodeId] = bc.temperature * 1e30;
413
+ } else if (bc.type === 'convection') {
414
+ const h = bc.convectionCoeff || 10;
415
+ const T_inf = bc.ambientTemp || 293;
416
+ K_thermal.add(bc.nodeId, bc.nodeId, h);
417
+ Q[bc.nodeId] += h * T_inf;
418
+ }
419
+ });
420
+
421
+ const T = conjugateGradient(K_thermal, Q, n, 1000, 1e-6);
422
+
423
+ return {
424
+ temperature: T,
425
+ maxTemp: Math.max(...T),
426
+ minTemp: Math.min(...T),
427
+ avgTemp: T.reduce((a, b) => a + b, 0) / T.length,
428
+ };
429
+ }
430
+
431
+ // ========== 4. MODAL/FREQUENCY ANALYSIS ==========
432
+
433
+ /**
434
+ * Compute natural frequencies and mode shapes via power iteration
435
+ */
436
+ function solveModal(material, constraints, numModes = 6) {
437
+ if (!meshData) return { error: 'No mesh data' };
438
+
439
+ const { nodes, elements } = meshData;
440
+ const nDOF = nodes.length * 3;
441
+
442
+ // Build mass and stiffness matrices (same as structural)
443
+ const K = new SparseMatrix(nDOF);
444
+ const M = new SparseMatrix(nDOF);
445
+
446
+ const E = material.youngsModulus;
447
+ const nu = material.poissonsRatio;
448
+ const rho = material.density;
449
+
450
+ elements.forEach(elem => {
451
+ if (elem.nodes.length !== 3) return;
452
+
453
+ const n0 = nodes[elem.nodes[0]];
454
+ const n1 = nodes[elem.nodes[1]];
455
+ const n2 = nodes[elem.nodes[2]];
456
+
457
+ const v1 = { x: n1.x - n0.x, y: n1.y - n0.y, z: n1.z - n0.z };
458
+ const v2 = { x: n2.x - n0.x, y: n2.y - n0.y, z: n2.z - n0.z };
459
+
460
+ const area = 0.5 * Math.sqrt(
461
+ (v1.y*v2.z - v1.z*v2.y)**2 +
462
+ (v1.z*v2.x - v1.x*v2.z)**2 +
463
+ (v1.x*v2.y - v1.y*v2.x)**2
464
+ );
465
+
466
+ const k_spring = (E * area) / Math.sqrt(v1.x**2 + v1.y**2 + v1.z**2 + 1e-10);
467
+ const mass = rho * area / 3;
468
+
469
+ elem.nodes.forEach(i => {
470
+ elem.nodes.forEach(j => {
471
+ K.add(i * 3, j * 3, i === j ? k_spring : -k_spring * 0.5);
472
+ M.add(i * 3, j * 3, i === j ? mass : 0);
473
+ });
474
+ });
475
+ });
476
+
477
+ // Apply constraints
478
+ constraints.forEach(constraint => {
479
+ if (constraint.type === 'fixed') {
480
+ for (let d = 0; d < 3; d++) {
481
+ const dof = constraint.nodeId * 3 + d;
482
+ K.set(dof, dof, 1e30);
483
+ M.set(dof, dof, 1);
484
+ }
485
+ }
486
+ });
487
+
488
+ const modes = [];
489
+ for (let m = 0; m < Math.min(numModes, 6); m++) {
490
+ const v = new Float64Array(nDOF);
491
+ for (let i = 0; i < nDOF; i++) v[i] = Math.random();
492
+
493
+ // Power iteration: K·v = λ·M·v
494
+ let lambda = 1;
495
+ for (let iter = 0; iter < 20; iter++) {
496
+ const Kv = K.multiply(v);
497
+ const Mv = M.multiply(v);
498
+ const lambdaNew = dotProduct(v, Kv) / (dotProduct(v, Mv) + 1e-15);
499
+ const denom = Math.sqrt(dotProduct(Mv, Mv)) + 1e-15;
500
+ for (let i = 0; i < nDOF; i++) v[i] = Kv[i] / (denom + 1e-15);
501
+ lambda = lambdaNew;
502
+ }
503
+
504
+ const frequency = Math.sqrt(Math.max(0, lambda)) / (2 * Math.PI);
505
+ modes.push({
506
+ modeNumber: m + 1,
507
+ frequency,
508
+ shapeVector: new Float64Array(v),
509
+ });
510
+ }
511
+
512
+ return {
513
+ modes,
514
+ frequencies: modes.map(m => m.frequency),
515
+ };
516
+ }
517
+
518
+ // ========== 5. DROP TEST / IMPACT SIMULATION ==========
519
+
520
+ /**
521
+ * Simulate drop test with time-domain integration
522
+ */
523
+ function solveDropTest(material, dropHeight, orientation = 'flat') {
524
+ if (!meshData) return { error: 'No mesh data' };
525
+
526
+ const { nodes, elements } = meshData;
527
+
528
+ // Calculate impact velocity
529
+ const g = 9.81;
530
+ const v_impact = Math.sqrt(2 * g * dropHeight);
531
+
532
+ // Time integration (Newmark-beta)
533
+ const dt = 0.001; // 1ms time step
534
+ const maxTime = 0.5; // 0.5 second simulation
535
+ const nSteps = Math.floor(maxTime / dt);
536
+
537
+ const nDOF = nodes.length * 3;
538
+ const u = new Float64Array(nDOF); // displacement
539
+ const v = new Float64Array(nDOF); // velocity
540
+ const a = new Float64Array(nDOF); // acceleration
541
+
542
+ // Initial velocity (downward impact)
543
+ for (let i = 1; i < nodes.length; i++) {
544
+ v[i * 3 + 1] = -v_impact; // Y-direction
545
+ }
546
+
547
+ let maxStress = 0;
548
+ let maxStressTime = 0;
549
+ let peakDeceleration = 0;
550
+ const stressHistory = [];
551
+
552
+ // Time stepping
553
+ for (let step = 0; step < nSteps; step++) {
554
+ // Contact with ground (y = 0)
555
+ for (let i = 0; i < nodes.length; i++) {
556
+ const y = nodes[i].y + u[i * 3 + 1];
557
+ if (y < 0) {
558
+ u[i * 3 + 1] = -nodes[i].y; // Snap back to ground
559
+ v[i * 3 + 1] = Math.max(0, v[i * 3 + 1] * 0.7); // Damping on contact
560
+ a[i * 3 + 1] = 0;
561
+ }
562
+ }
563
+
564
+ // Update velocities and displacements
565
+ for (let i = 0; i < nDOF; i++) {
566
+ v[i] += a[i] * dt;
567
+ u[i] += v[i] * dt + 0.5 * a[i] * dt * dt;
568
+ a[i] *= 0.99; // Damping
569
+ }
570
+
571
+ // Compute peak deceleration
572
+ const decel = Math.max(...a.map(Math.abs));
573
+ peakDeceleration = Math.max(peakDeceleration, decel);
574
+
575
+ // Compute stresses at this time step
576
+ const elemStresses = [];
577
+ elements.forEach(elem => {
578
+ if (elem.nodes.length === 3) {
579
+ const stress = Math.abs(material.youngsModulus * Math.sqrt(
580
+ Math.pow((u[elem.nodes[0]*3+1] - u[elem.nodes[1]*3+1]), 2) +
581
+ Math.pow((u[elem.nodes[1]*3+1] - u[elem.nodes[2]*3+1]), 2) + 1e-10
582
+ ));
583
+ elemStresses.push(stress);
584
+ }
585
+ });
586
+
587
+ if (elemStresses.length > 0) {
588
+ const stepMaxStress = Math.max(...elemStresses);
589
+ stressHistory.push(stepMaxStress);
590
+ if (stepMaxStress > maxStress) {
591
+ maxStress = stepMaxStress;
592
+ maxStressTime = step * dt;
593
+ }
594
+ }
595
+ }
596
+
597
+ const factorOfSafety = maxStress > 0 ? material.yieldStress / maxStress : Infinity;
598
+ const passed = factorOfSafety >= 1.5;
599
+
600
+ return {
601
+ maxStress,
602
+ maxStressTime,
603
+ peakDeceleration,
604
+ factorOfSafety,
605
+ passed,
606
+ displacement: u,
607
+ stressHistory,
608
+ impactVelocity: v_impact,
609
+ };
610
+ }
611
+
612
+ // ========== 6. RESULT VISUALIZATION ==========
613
+
614
+ /**
615
+ * Apply heatmap visualization to mesh
616
+ */
617
+ function visualizeResults(results, resultType = 'stress') {
618
+ if (!scene || !meshData) return;
619
+
620
+ const { nodes, elements } = meshData;
621
+ const values = resultType === 'stress' ? results.stresses :
622
+ resultType === 'displacement' ? results.displacement :
623
+ resultType === 'temperature' ? results.temperature : results.stresses;
624
+
625
+ if (!values) return;
626
+
627
+ // Create canvas texture for heatmap
628
+ const canvas = document.createElement('canvas');
629
+ canvas.width = 512;
630
+ canvas.height = 512;
631
+ const ctx = canvas.getContext('2d');
632
+
633
+ const minVal = Math.min(...values);
634
+ const maxVal = Math.max(...values);
635
+ const range = maxVal - minVal + 1e-10;
636
+
637
+ // Draw heatmap gradient bar (for reference)
638
+ for (let i = 0; i < 512; i++) {
639
+ const val = minVal + (i / 512) * range;
640
+ const hue = (1 - (val - minVal) / range) * 240;
641
+ ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
642
+ ctx.fillRect(i, 0, 1, 20);
643
+ }
644
+
645
+ // Add stress data as small visualization elements
646
+ ctx.fillStyle = '#fff';
647
+ ctx.font = '12px monospace';
648
+ ctx.fillText(`Min: ${minVal.toExponential(2)}`, 10, 50);
649
+ ctx.fillText(`Max: ${maxVal.toExponential(2)}`, 10, 70);
650
+
651
+ const texture = new THREE.CanvasTexture(canvas);
652
+ simulationState.stressTexture = texture;
653
+
654
+ // Update geometry colors based on stress
655
+ const geometry = scene.getObjectByName('mainGeometry')?.geometry;
656
+ if (geometry) {
657
+ const colors = new Float32Array(nodes.length * 3);
658
+
659
+ elements.forEach((elem, idx) => {
660
+ const stress = values[idx] || 0;
661
+ const normalized = Math.max(0, Math.min(1, (stress - minVal) / range));
662
+ const hue = (1 - normalized) * 240;
663
+ const rgb = hslToRgb(hue, 100, 50);
664
+
665
+ elem.nodes.forEach(nId => {
666
+ colors[nId * 3] = rgb.r / 255;
667
+ colors[nId * 3 + 1] = rgb.g / 255;
668
+ colors[nId * 3 + 2] = rgb.b / 255;
669
+ });
670
+ });
671
+
672
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
673
+ geometry.computeVertexNormals();
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Apply deformation to geometry
679
+ */
680
+ function visualizeDeformation(results, scale = 100) {
681
+ if (!scene || !meshData) return;
682
+
683
+ const geometry = scene.getObjectByName('mainGeometry')?.geometry;
684
+ if (!geometry) return;
685
+
686
+ const posAttr = geometry.getAttribute('position');
687
+ const positions = posAttr.array;
688
+
689
+ // Store original if not already done
690
+ if (!simulationState.originalPositions) {
691
+ simulationState.originalPositions = new Float32Array(positions);
692
+ }
693
+
694
+ const { nodes } = meshData;
695
+ const displacement = results.displacement;
696
+
697
+ for (let i = 0; i < nodes.length; i++) {
698
+ positions[i * 3] = simulationState.originalPositions[i * 3] + (displacement[i * 3] * scale);
699
+ positions[i * 3 + 1] = simulationState.originalPositions[i * 3 + 1] + (displacement[i * 3 + 1] * scale);
700
+ positions[i * 3 + 2] = simulationState.originalPositions[i * 3 + 2] + (displacement[i * 3 + 2] * scale);
701
+ }
702
+
703
+ posAttr.needsUpdate = true;
704
+ geometry.computeVertexNormals();
705
+ geometry.computeBoundingSphere();
706
+ }
707
+
708
+ /**
709
+ * Probe tool: get local values at clicked position
710
+ */
711
+ function probeAt(worldPos, results) {
712
+ if (!meshData) return null;
713
+
714
+ const { nodes } = meshData;
715
+ let closestNode = null;
716
+ let minDist = Infinity;
717
+
718
+ nodes.forEach((node, idx) => {
719
+ const dist = Math.sqrt(
720
+ (node.x - worldPos.x)**2 +
721
+ (node.y - worldPos.y)**2 +
722
+ (node.z - worldPos.z)**2
723
+ );
724
+ if (dist < minDist) {
725
+ minDist = dist;
726
+ closestNode = idx;
727
+ }
728
+ });
729
+
730
+ if (closestNode === null) return null;
731
+
732
+ return {
733
+ nodeId: closestNode,
734
+ position: nodes[closestNode],
735
+ stress: results.stresses ? results.stresses[closestNode] : null,
736
+ temperature: results.temperature ? results.temperature[closestNode] : null,
737
+ displacement: results.displacement ? {
738
+ x: results.displacement[closestNode * 3],
739
+ y: results.displacement[closestNode * 3 + 1],
740
+ z: results.displacement[closestNode * 3 + 2],
741
+ } : null,
742
+ };
743
+ }
744
+
745
+ // ========== UTILITIES ==========
746
+
747
+ /**
748
+ * Sparse matrix class using COO format
749
+ */
750
+ class SparseMatrix {
751
+ constructor(n) {
752
+ this.n = n;
753
+ this.entries = new Map(); // key: "i,j", value: coefficient
754
+ }
755
+
756
+ add(i, j, val) {
757
+ const key = `${i},${j}`;
758
+ this.entries.set(key, (this.entries.get(key) || 0) + val);
759
+ }
760
+
761
+ set(i, j, val) {
762
+ const key = `${i},${j}`;
763
+ this.entries.set(key, val);
764
+ }
765
+
766
+ multiply(x) {
767
+ const result = new Float64Array(this.n);
768
+ for (const [key, val] of this.entries) {
769
+ const [i, j] = key.split(',').map(Number);
770
+ result[i] += val * x[j];
771
+ }
772
+ return result;
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Dot product of two vectors
778
+ */
779
+ function dotProduct(a, b) {
780
+ let sum = 0;
781
+ for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
782
+ return sum;
783
+ }
784
+
785
+ /**
786
+ * HSL to RGB conversion
787
+ */
788
+ function hslToRgb(h, s, l) {
789
+ s /= 100;
790
+ l /= 100;
791
+ const k = n => (n + h / 30) % 12;
792
+ const a = s * Math.min(l, 1 - l);
793
+ const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
794
+ return {
795
+ r: Math.round(255 * f(0)),
796
+ g: Math.round(255 * f(8)),
797
+ b: Math.round(255 * f(4)),
798
+ };
799
+ }
800
+
801
+ // ========== INITIALIZATION & UI ==========
802
+
803
+ /**
804
+ * Initialize module with scene
805
+ */
806
+ function init(sceneRef) {
807
+ scene = sceneRef;
808
+
809
+ // Listen for geometry changes
810
+ if (window.CycleCAD && window.CycleCAD.events) {
811
+ window.CycleCAD.events.on('geometryUpdated', (geometry) => {
812
+ meshData = discretizeMesh(geometry, 'medium');
813
+ });
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Get UI panel (HTML)
819
+ */
820
+ function getUI() {
821
+ const panel = document.createElement('div');
822
+ panel.className = 'multi-physics-panel';
823
+ panel.style.cssText = `
824
+ background: var(--bg-secondary, #252526);
825
+ color: var(--text-primary, #e0e0e0);
826
+ border: 1px solid var(--border-color, #3e3e42);
827
+ border-radius: 4px;
828
+ padding: 12px;
829
+ font-family: 'Segoe UI', sans-serif;
830
+ font-size: 12px;
831
+ max-width: 320px;
832
+ max-height: 600px;
833
+ overflow-y: auto;
834
+ `;
835
+
836
+ panel.innerHTML = `
837
+ <div style="margin-bottom: 12px;">
838
+ <h3 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 600;">Multi-Physics Simulation</h3>
839
+
840
+ <!-- Analysis Type Selector -->
841
+ <label style="display: block; margin-bottom: 8px;">
842
+ Analysis Type:
843
+ <select id="analysisType" style="width: 100%; padding: 4px; background: var(--bg-primary, #1e1e1e); color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color, #3e3e42); border-radius: 2px;">
844
+ <option value="static">Structural (Static FEA)</option>
845
+ <option value="thermal">Thermal</option>
846
+ <option value="modal">Modal (Frequencies)</option>
847
+ <option value="droptest">Drop Test / Impact</option>
848
+ </select>
849
+ </label>
850
+
851
+ <!-- Material Selector -->
852
+ <label style="display: block; margin-bottom: 8px;">
853
+ Material:
854
+ <select id="materialSelect" style="width: 100%; padding: 4px; background: var(--bg-primary, #1e1e1e); color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color, #3e3e42); border-radius: 2px;">
855
+ <option value="steel">Structural Steel</option>
856
+ <option value="aluminum">Aluminum 6061</option>
857
+ <option value="titanium">Titanium Grade 5</option>
858
+ <option value="abs">ABS Plastic</option>
859
+ </select>
860
+ </label>
861
+
862
+ <!-- Mesh Resolution -->
863
+ <label style="display: block; margin-bottom: 8px;">
864
+ Mesh Resolution:
865
+ <input type="range" id="meshResolution" min="1" max="3" value="2" style="width: 100%;">
866
+ <span id="meshResLabel" style="font-size: 11px; color: var(--text-secondary, #cccccc);">Medium</span>
867
+ </label>
868
+
869
+ <!-- Load Definition -->
870
+ <fieldset style="border: 1px solid var(--border-color, #3e3e42); border-radius: 2px; padding: 8px; margin-bottom: 8px;">
871
+ <legend style="padding: 0 4px; font-size: 11px; font-weight: 600;">Load Definition</legend>
872
+
873
+ <div style="margin-bottom: 6px;">
874
+ <label style="display: block; font-size: 11px;">Force (N):</label>
875
+ <input type="number" id="forceX" placeholder="X" min="-1000" max="1000" value="0" style="width: 32%; padding: 2px; margin-right: 2px;">
876
+ <input type="number" id="forceY" placeholder="Y" min="-1000" max="1000" value="-100" style="width: 32%; padding: 2px; margin-right: 2px;">
877
+ <input type="number" id="forceZ" placeholder="Z" min="-1000" max="1000" value="0" style="width: 32%; padding: 2px;">
878
+ </div>
879
+
880
+ <label style="display: block; margin-bottom: 4px;">
881
+ <input type="checkbox" id="applyGravity" checked>
882
+ <span style="font-size: 11px;">Include Gravity</span>
883
+ </label>
884
+ </fieldset>
885
+
886
+ <!-- Boundary Conditions -->
887
+ <fieldset style="border: 1px solid var(--border-color, #3e3e42); border-radius: 2px; padding: 8px; margin-bottom: 8px;">
888
+ <legend style="padding: 0 4px; font-size: 11px; font-weight: 600;">Constraints</legend>
889
+
890
+ <label style="display: block; margin-bottom: 4px;">
891
+ <input type="checkbox" id="fixBase" checked>
892
+ <span style="font-size: 11px;">Fix Base (Y=0)</span>
893
+ </label>
894
+ </fieldset>
895
+
896
+ <!-- Drop Test Parameters -->
897
+ <div id="dropTestParams" style="display: none; border: 1px solid var(--border-color, #3e3e42); border-radius: 2px; padding: 8px; margin-bottom: 8px;">
898
+ <label style="display: block; margin-bottom: 6px;">
899
+ Drop Height (m):
900
+ <input type="number" id="dropHeight" min="0.1" max="10" step="0.1" value="1" style="width: 100%; padding: 4px; background: var(--bg-primary, #1e1e1e); color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color, #3e3e42); border-radius: 2px;">
901
+ </label>
902
+ </div>
903
+
904
+ <!-- Modal Parameters -->
905
+ <div id="modalParams" style="display: none; border: 1px solid var(--border-color, #3e3e42); border-radius: 2px; padding: 8px; margin-bottom: 8px;">
906
+ <label style="display: block; margin-bottom: 6px;">
907
+ Number of Modes (1-6):
908
+ <input type="number" id="numModes" min="1" max="6" value="6" style="width: 100%; padding: 4px; background: var(--bg-primary, #1e1e1e); color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color, #3e3e42); border-radius: 2px;">
909
+ </label>
910
+ </div>
911
+
912
+ <!-- Visualization Controls -->
913
+ <fieldset style="border: 1px solid var(--border-color, #3e3e42); border-radius: 2px; padding: 8px; margin-bottom: 8px;">
914
+ <legend style="padding: 0 4px; font-size: 11px; font-weight: 600;">Visualization</legend>
915
+
916
+ <label style="display: block; margin-bottom: 6px;">
917
+ Result Type:
918
+ <select id="resultType" style="width: 100%; padding: 4px; background: var(--bg-primary, #1e1e1e); color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color, #3e3e42); border-radius: 2px;">
919
+ <option value="stress">Von Mises Stress</option>
920
+ <option value="displacement">Displacement Magnitude</option>
921
+ <option value="temperature">Temperature</option>
922
+ </select>
923
+ </label>
924
+
925
+ <label style="display: block; margin-bottom: 4px;">
926
+ Deformation Scale:
927
+ <input type="range" id="deformScale" min="0" max="1000" value="100" style="width: 100%;">
928
+ <span id="deformLabel" style="font-size: 11px; color: var(--text-secondary, #cccccc);">100×</span>
929
+ </label>
930
+ </fieldset>
931
+
932
+ <!-- Results Section -->
933
+ <div id="resultsSection" style="display: none; background: var(--bg-primary, #1e1e1e); border-radius: 2px; padding: 8px; margin-bottom: 8px; border: 1px solid var(--border-color, #3e3e42);">
934
+ <h4 style="margin: 0 0 6px 0; font-size: 12px; font-weight: 600;">Results</h4>
935
+ <div id="resultsList" style="font-size: 11px; line-height: 1.6;">
936
+ <!-- Populated dynamically -->
937
+ </div>
938
+ </div>
939
+
940
+ <!-- Progress Bar -->
941
+ <div id="progressContainer" style="display: none; margin-bottom: 8px;">
942
+ <div style="background: var(--bg-primary, #1e1e1e); border: 1px solid var(--border-color, #3e3e42); border-radius: 2px; overflow: hidden; height: 20px;">
943
+ <div id="progressBar" style="background: linear-gradient(90deg, var(--accent-blue, #0284C7), var(--accent-green, #16a34a)); height: 100%; width: 0%; transition: width 0.1s;"></div>
944
+ </div>
945
+ <div id="progressText" style="font-size: 10px; margin-top: 2px; text-align: center;">0%</div>
946
+ </div>
947
+
948
+ <!-- Action Buttons -->
949
+ <div style="display: flex; gap: 6px;">
950
+ <button id="runButton" style="flex: 1; padding: 6px 8px; background: var(--accent-blue, #0284C7); color: white; border: none; border-radius: 2px; cursor: pointer; font-size: 12px; font-weight: 600;">Run Analysis</button>
951
+ <button id="resetButton" style="flex: 1; padding: 6px 8px; background: var(--border-color, #3e3e42); color: var(--text-primary, #e0e0e0); border: none; border-radius: 2px; cursor: pointer; font-size: 12px;">Reset</button>
952
+ <button id="exportButton" style="flex: 0.8; padding: 6px 8px; background: var(--border-color, #3e3e42); color: var(--text-primary, #e0e0e0); border: none; border-radius: 2px; cursor: pointer; font-size: 11px;">Export</button>
953
+ </div>
954
+ </div>
955
+ `;
956
+
957
+ // Event listeners
958
+ const analysisTypeSelect = panel.querySelector('#analysisType');
959
+ const dropTestParams = panel.querySelector('#dropTestParams');
960
+ const modalParams = panel.querySelector('#modalParams');
961
+ const meshResSlider = panel.querySelector('#meshResolution');
962
+ const meshResLabel = panel.querySelector('#meshResLabel');
963
+ const deformSlider = panel.querySelector('#deformScale');
964
+ const deformLabel = panel.querySelector('#deformLabel');
965
+ const runButton = panel.querySelector('#runButton');
966
+ const resetButton = panel.querySelector('#resetButton');
967
+ const exportButton = panel.querySelector('#exportButton');
968
+
969
+ analysisTypeSelect.addEventListener('change', (e) => {
970
+ currentAnalysis = e.target.value;
971
+ dropTestParams.style.display = currentAnalysis === 'droptest' ? 'block' : 'none';
972
+ modalParams.style.display = currentAnalysis === 'modal' ? 'block' : 'none';
973
+ });
974
+
975
+ meshResSlider.addEventListener('input', (e) => {
976
+ const labels = ['Coarse', 'Medium', 'Fine'];
977
+ meshResLabel.textContent = labels[parseInt(e.target.value) - 1];
978
+ });
979
+
980
+ deformSlider.addEventListener('input', (e) => {
981
+ deformLabel.textContent = `${e.target.value}×`;
982
+ if (simulationState.results) {
983
+ visualizeDeformation(simulationState.results, parseInt(e.target.value));
984
+ }
985
+ });
986
+
987
+ runButton.addEventListener('click', () => runAnalysis(panel));
988
+ resetButton.addEventListener('click', () => resetSimulation());
989
+ exportButton.addEventListener('click', () => exportResults(panel));
990
+
991
+ return panel;
992
+ }
993
+
994
+ /**
995
+ * Run selected analysis
996
+ */
997
+ function runAnalysis(panel) {
998
+ if (simulationState.isRunning || !meshData) return;
999
+
1000
+ simulationState.isRunning = true;
1001
+ simulationState.progress = 0;
1002
+
1003
+ const materialKey = panel.querySelector('#materialSelect').value;
1004
+ const material = MATERIALS[materialKey];
1005
+ const progressContainer = panel.querySelector('#progressContainer');
1006
+ const progressBar = panel.querySelector('#progressBar');
1007
+ const progressText = panel.querySelector('#progressText');
1008
+
1009
+ progressContainer.style.display = 'block';
1010
+
1011
+ const updateProgress = (pct) => {
1012
+ simulationState.progress = pct;
1013
+ progressBar.style.width = `${pct}%`;
1014
+ progressText.textContent = `${Math.round(pct)}%`;
1015
+ };
1016
+
1017
+ requestAnimationFrame(() => {
1018
+ updateProgress(20);
1019
+
1020
+ setTimeout(() => {
1021
+ let result;
1022
+
1023
+ if (currentAnalysis === 'static') {
1024
+ const loads = [{
1025
+ type: 'point',
1026
+ nodeId: 0,
1027
+ force: {
1028
+ x: parseFloat(panel.querySelector('#forceX').value),
1029
+ y: parseFloat(panel.querySelector('#forceY').value),
1030
+ z: parseFloat(panel.querySelector('#forceZ').value),
1031
+ },
1032
+ }];
1033
+
1034
+ const constraints = panel.querySelector('#fixBase').checked ? [{
1035
+ type: 'fixed',
1036
+ nodeId: meshData.nodes.length - 1,
1037
+ }] : [];
1038
+
1039
+ result = solveStructural(material, loads, constraints);
1040
+ } else if (currentAnalysis === 'thermal') {
1041
+ const heatSources = [{
1042
+ type: 'point',
1043
+ nodeId: 0,
1044
+ magnitude: 100,
1045
+ }];
1046
+
1047
+ const bc = [{
1048
+ type: 'fixed',
1049
+ nodeId: meshData.nodes.length - 1,
1050
+ temperature: 293,
1051
+ }];
1052
+
1053
+ result = solveThermal(material, heatSources, bc);
1054
+ } else if (currentAnalysis === 'modal') {
1055
+ const numModes = parseInt(panel.querySelector('#numModes').value);
1056
+ const constraints = [{
1057
+ type: 'fixed',
1058
+ nodeId: meshData.nodes.length - 1,
1059
+ }];
1060
+
1061
+ result = solveModal(material, constraints, numModes);
1062
+ } else if (currentAnalysis === 'droptest') {
1063
+ const dropHeight = parseFloat(panel.querySelector('#dropHeight').value);
1064
+ result = solveDropTest(material, dropHeight);
1065
+ }
1066
+
1067
+ updateProgress(80);
1068
+
1069
+ if (result && !result.error) {
1070
+ simulationState.results = result;
1071
+ displayResults(panel, result);
1072
+ visualizeResults(result, panel.querySelector('#resultType').value);
1073
+ updateProgress(100);
1074
+
1075
+ setTimeout(() => {
1076
+ progressContainer.style.display = 'none';
1077
+ simulationState.isRunning = false;
1078
+ }, 500);
1079
+ }
1080
+ }, 100);
1081
+ });
1082
+ }
1083
+
1084
+ /**
1085
+ * Display results in panel
1086
+ */
1087
+ function displayResults(panel, results) {
1088
+ const resultsSection = panel.querySelector('#resultsSection');
1089
+ const resultsList = panel.querySelector('#resultsList');
1090
+
1091
+ resultsSection.style.display = 'block';
1092
+ let html = '';
1093
+
1094
+ if (currentAnalysis === 'static') {
1095
+ html = `
1096
+ <div><strong>Max Stress:</strong> ${(results.maxStress / 1e6).toFixed(2)} MPa</div>
1097
+ <div><strong>Avg Stress:</strong> ${(results.avgStress / 1e6).toFixed(2)} MPa</div>
1098
+ <div><strong>Factor of Safety:</strong> ${results.factorOfSafety.toFixed(2)}</div>
1099
+ <div style="color: ${results.factorOfSafety < 1 ? '#f87171' : results.factorOfSafety < 1.5 ? '#fbbf24' : '#86efac'};">
1100
+ Status: ${results.factorOfSafety < 1 ? '⚠ FAILURE' : results.factorOfSafety < 1.5 ? '⚠ CAUTION' : '✓ SAFE'}
1101
+ </div>
1102
+ `;
1103
+ } else if (currentAnalysis === 'thermal') {
1104
+ html = `
1105
+ <div><strong>Max Temp:</strong> ${results.maxTemp.toFixed(1)} K</div>
1106
+ <div><strong>Min Temp:</strong> ${results.minTemp.toFixed(1)} K</div>
1107
+ <div><strong>Avg Temp:</strong> ${results.avgTemp.toFixed(1)} K</div>
1108
+ `;
1109
+ } else if (currentAnalysis === 'modal') {
1110
+ html = '<div><strong>Natural Frequencies:</strong></div>';
1111
+ results.modes.forEach(mode => {
1112
+ html += `<div style="padding-left: 8px;">Mode ${mode.modeNumber}: ${mode.frequency.toFixed(2)} Hz</div>`;
1113
+ });
1114
+ } else if (currentAnalysis === 'droptest') {
1115
+ html = `
1116
+ <div><strong>Impact Velocity:</strong> ${results.impactVelocity.toFixed(2)} m/s</div>
1117
+ <div><strong>Peak Deceleration:</strong> ${(results.peakDeceleration / 9.81).toFixed(1)} g</div>
1118
+ <div><strong>Max Stress:</strong> ${(results.maxStress / 1e6).toFixed(2)} MPa</div>
1119
+ <div><strong>Factor of Safety:</strong> ${results.factorOfSafety.toFixed(2)}</div>
1120
+ <div style="color: ${results.passed ? '#86efac' : '#f87171'};">
1121
+ Result: ${results.passed ? '✓ PASS' : '✗ FAIL'}
1122
+ </div>
1123
+ `;
1124
+ }
1125
+
1126
+ resultsList.innerHTML = html;
1127
+ }
1128
+
1129
+ /**
1130
+ * Reset simulation
1131
+ */
1132
+ function resetSimulation() {
1133
+ simulationState = {
1134
+ isRunning: false,
1135
+ progress: 0,
1136
+ results: null,
1137
+ deformedGeometry: null,
1138
+ stressTexture: null,
1139
+ originalPositions: null,
1140
+ selectedProbe: null,
1141
+ };
1142
+
1143
+ // Restore original geometry
1144
+ const geometry = scene?.getObjectByName('mainGeometry')?.geometry;
1145
+ if (geometry && simulationState.originalPositions) {
1146
+ const posAttr = geometry.getAttribute('position');
1147
+ posAttr.array.set(simulationState.originalPositions);
1148
+ posAttr.needsUpdate = true;
1149
+ geometry.computeVertexNormals();
1150
+ geometry.computeBoundingSphere();
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * Export results as JSON + CSV
1156
+ */
1157
+ function exportResults(panel) {
1158
+ if (!simulationState.results) {
1159
+ alert('Run an analysis first');
1160
+ return;
1161
+ }
1162
+
1163
+ const results = simulationState.results;
1164
+ const json = JSON.stringify({
1165
+ analysisType: currentAnalysis,
1166
+ timestamp: new Date().toISOString(),
1167
+ results: {
1168
+ maxStress: results.maxStress,
1169
+ maxStressLocation: results.maxStressLocation,
1170
+ factorOfSafety: results.factorOfSafety,
1171
+ frequencies: results.frequencies,
1172
+ },
1173
+ }, null, 2);
1174
+
1175
+ const link = document.createElement('a');
1176
+ link.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(json);
1177
+ link.download = `simulation_${currentAnalysis}_${Date.now()}.json`;
1178
+ link.click();
1179
+ }
1180
+
1181
+ /**
1182
+ * Execute command (for agent API)
1183
+ */
1184
+ function execute(command, params = {}) {
1185
+ if (command === 'runFEA') {
1186
+ return solveStructural(
1187
+ MATERIALS[params.material] || MATERIALS.steel,
1188
+ params.loads || [],
1189
+ params.constraints || []
1190
+ );
1191
+ } else if (command === 'runThermal') {
1192
+ return solveThermal(
1193
+ MATERIALS[params.material] || MATERIALS.steel,
1194
+ params.heatSources || [],
1195
+ params.boundaryConditions || []
1196
+ );
1197
+ } else if (command === 'runModal') {
1198
+ return solveModal(
1199
+ MATERIALS[params.material] || MATERIALS.steel,
1200
+ params.constraints || [],
1201
+ params.numModes || 6
1202
+ );
1203
+ } else if (command === 'runDropTest') {
1204
+ return solveDropTest(
1205
+ MATERIALS[params.material] || MATERIALS.steel,
1206
+ params.dropHeight || 1,
1207
+ params.orientation || 'flat'
1208
+ );
1209
+ }
1210
+ return { error: 'Unknown command' };
1211
+ }
1212
+
1213
+ // ========== PUBLIC API ==========
1214
+
1215
+ return {
1216
+ init,
1217
+ getUI,
1218
+ execute,
1219
+ runSimulation: runAnalysis,
1220
+ getResults: () => simulationState.results,
1221
+ discretizeMesh,
1222
+ solveStructural,
1223
+ solveThermal,
1224
+ solveModal,
1225
+ solveDropTest,
1226
+ visualizeResults,
1227
+ visualizeDeformation,
1228
+ probeAt,
1229
+ resetSimulation,
1230
+ };
1231
+ })();
1232
+
1233
+ // Auto-init if in DOM
1234
+ if (document.readyState === 'loading') {
1235
+ document.addEventListener('DOMContentLoaded', () => {
1236
+ if (window.CycleCAD?.MultiPhysics?.init) {
1237
+ window.CycleCAD.MultiPhysics.init(window.scene || null);
1238
+ }
1239
+ });
1240
+ } else {
1241
+ if (window.CycleCAD?.MultiPhysics?.init) {
1242
+ window.CycleCAD.MultiPhysics.init(window.scene || null);
1243
+ }
1244
+ }