cyclecad 3.9.14 → 3.9.18

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,1141 @@
1
+ /**
2
+ * ExplodeView Full Integration Module for cycleCAD
3
+ * Comprehensive 3D model visualization, analysis, and annotation system
4
+ * Replaces viewer-mode.js with complete feature set (40+ tools)
5
+ *
6
+ * Features: Assembly tree, explode/collapse, section cut, measurement, analysis,
7
+ * BOM, AI narrator, AR mode, animated assembly, collaborative annotations, smart search
8
+ *
9
+ * @module explodeview-full
10
+ * @version 1.0.0
11
+ */
12
+
13
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
14
+ import { STLLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js';
15
+ import { OBJLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/OBJLoader.js';
16
+ import { GLTFLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/GLTFLoader.js';
17
+
18
+ const MATERIALS_DB = {
19
+ steel: { density: 7850, color: 0x444444, roughness: 0.7, metalness: 0.8 },
20
+ aluminum: { density: 2700, color: 0xcccccc, roughness: 0.5, metalness: 0.9 },
21
+ abs: { density: 1050, color: 0xff6600, roughness: 0.8, metalness: 0.1 },
22
+ brass: { density: 8500, color: 0xffcc00, roughness: 0.6, metalness: 0.95 },
23
+ titanium: { density: 4500, color: 0xeeeeee, roughness: 0.4, metalness: 0.85 },
24
+ nylon: { density: 1150, color: 0xffffff, roughness: 0.9, metalness: 0.0 }
25
+ };
26
+
27
+ const LANGUAGE_STRINGS = {
28
+ en: {
29
+ assembly: 'Assembly', explode: 'Explode', collapse: 'Collapse',
30
+ section: 'Section Cut', measure: 'Measure', analysis: 'Analysis',
31
+ bom: 'Bill of Materials', annotations: 'Annotations', properties: 'Properties',
32
+ distance: 'Distance', angle: 'Angle', volume: 'Volume', area: 'Area'
33
+ },
34
+ de: {
35
+ assembly: 'Baugruppe', explode: 'Explodieren', collapse: 'Zusammenklappen',
36
+ section: 'Schnittansicht', measure: 'Messen', analysis: 'Analyse',
37
+ bom: 'Stückliste', annotations: 'Anmerkungen', properties: 'Eigenschaften',
38
+ distance: 'Entfernung', angle: 'Winkel', volume: 'Volumen', area: 'Fläche'
39
+ }
40
+ };
41
+
42
+ export function initExplodeView(viewportExports) {
43
+ const { getScene, getCamera, getRenderer, getControls } = viewportExports;
44
+ const state = {
45
+ parts: [],
46
+ partsByUuid: new Map(),
47
+ selectedPart: null,
48
+ explodeAmount: 0,
49
+ sectionPlanes: { x: null, y: null, z: null },
50
+ sectionPositions: { x: 0, y: 0, z: 0 },
51
+ measurements: [],
52
+ annotations: [],
53
+ currentLanguage: 'en',
54
+ centerOfMass: new THREE.Vector3(),
55
+ partProperties: new Map(),
56
+ assemblyTree: null,
57
+ isARMode: false,
58
+ assemblySteps: [],
59
+ currentStep: 0
60
+ };
61
+
62
+ // ============================================================================
63
+ // 1. MODEL LOADING
64
+ // ============================================================================
65
+
66
+ async function loadModel(file) {
67
+ const ext = file.name.split('.').pop().toLowerCase();
68
+ const scene = getScene();
69
+
70
+ if (ext === 'stl') {
71
+ const loader = new STLLoader();
72
+ const geometry = await new Promise((resolve, reject) => {
73
+ loader.load(URL.createObjectURL(file), resolve, undefined, reject);
74
+ });
75
+ geometry.computeVertexNormals();
76
+ geometry.center();
77
+ const material = new THREE.MeshStandardMaterial({ color: 0x0084ff });
78
+ const mesh = new THREE.Mesh(geometry, material);
79
+ scene.add(mesh);
80
+ addPart(mesh, file.name);
81
+ fitAllParts();
82
+ return mesh;
83
+ } else if (ext === 'obj') {
84
+ const loader = new OBJLoader();
85
+ const obj = await new Promise((resolve, reject) => {
86
+ loader.load(URL.createObjectURL(file), resolve, undefined, reject);
87
+ });
88
+ obj.traverse(child => {
89
+ if (child instanceof THREE.Mesh) {
90
+ child.material = new THREE.MeshStandardMaterial({ color: 0x0084ff });
91
+ }
92
+ });
93
+ obj.position.set(0, 0, 0);
94
+ scene.add(obj);
95
+ addPart(obj, file.name);
96
+ fitAllParts();
97
+ return obj;
98
+ } else if (ext === 'gltf' || ext === 'glb') {
99
+ const loader = new GLTFLoader();
100
+ const gltf = await new Promise((resolve, reject) => {
101
+ loader.load(URL.createObjectURL(file), resolve, undefined, reject);
102
+ });
103
+ const scene_obj = gltf.scene;
104
+ scene_obj.traverse(child => {
105
+ if (child instanceof THREE.Mesh) {
106
+ if (!child.material.map) {
107
+ child.material = new THREE.MeshStandardMaterial({ color: 0x0084ff });
108
+ }
109
+ }
110
+ });
111
+ scene.add(scene_obj);
112
+ addPart(scene_obj, file.name);
113
+ buildAssemblyTree(scene_obj);
114
+ fitAllParts();
115
+ return scene_obj;
116
+ }
117
+ }
118
+
119
+ async function loadSTEP(file) {
120
+ if (file.size > 50000000) {
121
+ console.warn('File > 50MB, requires server-side conversion');
122
+ return null;
123
+ }
124
+ try {
125
+ const { occt } = await import('https://cdn.jsdelivr.net/npm/occt-import-js@0.0.23/dist/index.js');
126
+ const arrayBuffer = await file.arrayBuffer();
127
+ const model = occt.readSTEP(new Uint8Array(arrayBuffer));
128
+ const shapes = model.getShapes();
129
+
130
+ shapes.forEach((shape, idx) => {
131
+ const triIndices = shape.getTriangles();
132
+ const triVertices = shape.getVertices();
133
+ const geometry = new THREE.BufferGeometry();
134
+
135
+ const vertices = new Float32Array(triVertices);
136
+ const indices = new Uint32Array(triIndices);
137
+
138
+ geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
139
+ geometry.setIndex(new THREE.BufferAttribute(indices, 1));
140
+ geometry.computeVertexNormals();
141
+ geometry.center();
142
+
143
+ const material = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff });
144
+ const mesh = new THREE.Mesh(geometry, material);
145
+ getScene().add(mesh);
146
+ addPart(mesh, `shape_${idx}`);
147
+ });
148
+
149
+ computeCenterOfMass();
150
+ fitAllParts();
151
+ return shapes.length;
152
+ } catch (e) {
153
+ console.error('STEP import failed:', e);
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function addPart(object, name) {
159
+ state.parts.push(object);
160
+ state.partsByUuid.set(object.uuid, object);
161
+ state.partProperties.set(object.uuid, {
162
+ name: name || object.name || `Part_${state.parts.length}`,
163
+ visible: true,
164
+ material: 'steel',
165
+ mass: 0,
166
+ volume: 0
167
+ });
168
+ }
169
+
170
+ // ============================================================================
171
+ // 2. ASSEMBLY TREE
172
+ // ============================================================================
173
+
174
+ function buildAssemblyTree(root) {
175
+ const tree = { name: root.name || 'Assembly', children: [], uuid: root.uuid };
176
+
177
+ function traverse(node, treeNode) {
178
+ node.children.forEach(child => {
179
+ if (child instanceof THREE.Mesh) {
180
+ const childNode = {
181
+ name: child.name || `Part_${treeNode.children.length}`,
182
+ uuid: child.uuid,
183
+ children: [],
184
+ isMesh: true
185
+ };
186
+ treeNode.children.push(childNode);
187
+ state.partsByUuid.set(child.uuid, child);
188
+ } else {
189
+ const childNode = {
190
+ name: child.name || `Group_${treeNode.children.length}`,
191
+ uuid: child.uuid,
192
+ children: []
193
+ };
194
+ treeNode.children.push(childNode);
195
+ traverse(child, childNode);
196
+ }
197
+ });
198
+ }
199
+
200
+ traverse(root, tree);
201
+ state.assemblyTree = tree;
202
+ return tree;
203
+ }
204
+
205
+ function togglePartVisibility(uuid) {
206
+ const part = state.partsByUuid.get(uuid);
207
+ if (part) {
208
+ part.visible = !part.visible;
209
+ const props = state.partProperties.get(uuid);
210
+ if (props) props.visible = part.visible;
211
+ }
212
+ }
213
+
214
+ function isolatePart(uuid) {
215
+ state.parts.forEach(p => p.visible = false);
216
+ const part = state.partsByUuid.get(uuid);
217
+ if (part) {
218
+ part.visible = true;
219
+ state.selectedPart = part;
220
+ }
221
+ }
222
+
223
+ function showAllParts() {
224
+ state.parts.forEach(p => p.visible = true);
225
+ }
226
+
227
+ // ============================================================================
228
+ // 3. EXPLODE / COLLAPSE
229
+ // ============================================================================
230
+
231
+ function explodeParts(amount) {
232
+ state.explodeAmount = Math.max(0, Math.min(1, amount));
233
+ const com = state.centerOfMass;
234
+
235
+ state.parts.forEach(part => {
236
+ const direction = new THREE.Vector3();
237
+ const bbox = new THREE.Box3().setFromObject(part);
238
+ bbox.getCenter(direction);
239
+ direction.sub(com).normalize();
240
+
241
+ const basePos = part.userData.basePosition || part.position.clone();
242
+ part.userData.basePosition = basePos;
243
+
244
+ const distance = 200 * state.explodeAmount;
245
+ part.position.copy(basePos).addScaledVector(direction, distance);
246
+ });
247
+ }
248
+
249
+ function collapseParts() {
250
+ explodeParts(0);
251
+ state.parts.forEach(p => {
252
+ if (p.userData.basePosition) {
253
+ p.position.copy(p.userData.basePosition);
254
+ }
255
+ });
256
+ }
257
+
258
+ // ============================================================================
259
+ // 4. SECTION CUT
260
+ // ============================================================================
261
+
262
+ function setSectionCut(axis, position, enabled) {
263
+ const cam = getCamera();
264
+ const renderer = getRenderer();
265
+
266
+ if (!enabled) {
267
+ state.sectionPlanes[axis] = null;
268
+ renderer.clippingPlanes = [];
269
+ return;
270
+ }
271
+
272
+ const normal = new THREE.Vector3();
273
+ if (axis === 'x') normal.set(1, 0, 0);
274
+ else if (axis === 'y') normal.set(0, 1, 0);
275
+ else if (axis === 'z') normal.set(0, 0, 1);
276
+
277
+ const plane = new THREE.Plane(normal, position);
278
+ state.sectionPlanes[axis] = plane;
279
+
280
+ const planes = Object.values(state.sectionPlanes).filter(p => p !== null);
281
+ renderer.clippingPlanes = planes;
282
+
283
+ state.parts.forEach(part => {
284
+ part.traverse(node => {
285
+ if (node instanceof THREE.Mesh && node.material) {
286
+ if (Array.isArray(node.material)) {
287
+ node.material.forEach(m => {
288
+ m.clippingPlanes = planes;
289
+ m.clipIntersection = false;
290
+ });
291
+ } else {
292
+ node.material.clippingPlanes = planes;
293
+ node.material.clipIntersection = false;
294
+ }
295
+ }
296
+ });
297
+ });
298
+ }
299
+
300
+ // ============================================================================
301
+ // 5. MEASUREMENT TOOLS
302
+ // ============================================================================
303
+
304
+ function measureDistance(p1, p2) {
305
+ const dist = p1.distanceTo(p2);
306
+
307
+ const geometry = new THREE.BufferGeometry().setFromPoints([p1, p2]);
308
+ const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 }));
309
+ getScene().add(line);
310
+
311
+ const label = createDimensionLabel(dist.toFixed(2), p1.clone().lerp(p2, 0.5));
312
+ getScene().add(label);
313
+
314
+ state.measurements.push({ type: 'distance', p1, p2, distance: dist, line, label });
315
+ return dist;
316
+ }
317
+
318
+ function measureAngle(p1, center, p2) {
319
+ const v1 = p1.clone().sub(center).normalize();
320
+ const v2 = p2.clone().sub(center).normalize();
321
+ const angle = Math.acos(Math.max(-1, Math.min(1, v1.dot(v2)))) * (180 / Math.PI);
322
+
323
+ const geometry = new THREE.BufferGeometry().setFromPoints([p1, center, p2]);
324
+ const line = new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color: 0x00ff00 }));
325
+ getScene().add(line);
326
+
327
+ const label = createDimensionLabel(angle.toFixed(1) + '°', center);
328
+ getScene().add(label);
329
+
330
+ state.measurements.push({ type: 'angle', angle, line, label });
331
+ return angle;
332
+ }
333
+
334
+ function computeVolume(mesh) {
335
+ if (!mesh.geometry.attributes.position) return 0;
336
+
337
+ const positions = mesh.geometry.attributes.position.array;
338
+ let volume = 0;
339
+
340
+ for (let i = 0; i < positions.length; i += 9) {
341
+ const v0 = new THREE.Vector3(positions[i], positions[i+1], positions[i+2]);
342
+ const v1 = new THREE.Vector3(positions[i+3], positions[i+4], positions[i+5]);
343
+ const v2 = new THREE.Vector3(positions[i+6], positions[i+7], positions[i+8]);
344
+
345
+ const box = new THREE.Box3().setFromPoints([v0, v1, v2]);
346
+ const size = box.getSize(new THREE.Vector3());
347
+ volume += Math.abs(v0.clone().cross(v1).dot(v2)) / 6;
348
+ }
349
+
350
+ return volume;
351
+ }
352
+
353
+ function computeSurfaceArea(mesh) {
354
+ if (!mesh.geometry.attributes.position) return 0;
355
+
356
+ const positions = mesh.geometry.attributes.position.array;
357
+ let area = 0;
358
+
359
+ for (let i = 0; i < positions.length; i += 9) {
360
+ const v0 = new THREE.Vector3(positions[i], positions[i+1], positions[i+2]);
361
+ const v1 = new THREE.Vector3(positions[i+3], positions[i+4], positions[i+5]);
362
+ const v2 = new THREE.Vector3(positions[i+6], positions[i+7], positions[i+8]);
363
+
364
+ const e1 = v1.clone().sub(v0);
365
+ const e2 = v2.clone().sub(v0);
366
+ area += e1.cross(e2).length() / 2;
367
+ }
368
+
369
+ return area;
370
+ }
371
+
372
+ function createDimensionLabel(text, position) {
373
+ const canvas = document.createElement('canvas');
374
+ canvas.width = 256;
375
+ canvas.height = 64;
376
+ const ctx = canvas.getContext('2d');
377
+
378
+ ctx.fillStyle = 'white';
379
+ ctx.fillRect(0, 0, 256, 64);
380
+ ctx.fillStyle = 'black';
381
+ ctx.font = 'bold 24px Arial';
382
+ ctx.textAlign = 'center';
383
+ ctx.fillText(text, 128, 40);
384
+
385
+ const texture = new THREE.CanvasTexture(canvas);
386
+ const geometry = new THREE.PlaneGeometry(1, 0.25);
387
+ const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
388
+ const sprite = new THREE.Mesh(geometry, material);
389
+ sprite.position.copy(position);
390
+
391
+ return sprite;
392
+ }
393
+
394
+ // ============================================================================
395
+ // 6. ANALYSIS TOOLS
396
+ // ============================================================================
397
+
398
+ function analyzeWallThickness(mesh, minThickness = 2) {
399
+ const colors = [];
400
+ const positions = mesh.geometry.attributes.position.array;
401
+
402
+ for (let i = 0; i < positions.length; i += 3) {
403
+ const point = new THREE.Vector3(positions[i], positions[i+1], positions[i+2]);
404
+ const normal = new THREE.Vector3();
405
+
406
+ mesh.geometry.computeVertexNormals();
407
+ const normals = mesh.geometry.attributes.normal.array;
408
+ normal.set(normals[i], normals[i+1], normals[i+2]);
409
+
410
+ const raycaster = new THREE.Raycaster(point, normal);
411
+ const intersections = raycaster.intersectObjects(state.parts);
412
+
413
+ let thickness = 1000;
414
+ if (intersections.length > 0) {
415
+ thickness = intersections[0].distance;
416
+ }
417
+
418
+ const hue = Math.max(0, Math.min(1, (minThickness - thickness) / minThickness));
419
+ const color = new THREE.Color().setHSL(hue * 0.3, 1, 0.5);
420
+ colors.push(color.r, color.g, color.b);
421
+ }
422
+
423
+ mesh.geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
424
+ mesh.material = new THREE.MeshStandardMaterial({ vertexColors: true });
425
+ }
426
+
427
+ function analyzeDraftAngle(mesh, pullDirection = new THREE.Vector3(0, 0, 1)) {
428
+ const colors = [];
429
+ const positions = mesh.geometry.attributes.position.array;
430
+ const normals = mesh.geometry.attributes.normal || mesh.geometry.computeVertexNormals().attributes.normal;
431
+
432
+ for (let i = 0; i < positions.length; i += 3) {
433
+ const normal = new THREE.Vector3(normals.array[i], normals.array[i+1], normals.array[i+2]).normalize();
434
+ const angle = Math.acos(Math.max(-1, Math.min(1, Math.abs(normal.dot(pullDirection))))) * (180 / Math.PI);
435
+
436
+ const hue = Math.max(0, (angle - 90) / 90);
437
+ const color = new THREE.Color().setHSL(hue * 0.3, 1, 0.5);
438
+ colors.push(color.r, color.g, color.b);
439
+ }
440
+
441
+ mesh.geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
442
+ mesh.material = new THREE.MeshStandardMaterial({ vertexColors: true });
443
+ }
444
+
445
+ function checkInterference() {
446
+ const boxes = state.parts.map(p => new THREE.Box3().setFromObject(p));
447
+ const collisions = [];
448
+
449
+ for (let i = 0; i < boxes.length; i++) {
450
+ for (let j = i + 1; j < boxes.length; j++) {
451
+ if (boxes[i].intersectsBox(boxes[j])) {
452
+ collisions.push({
453
+ part1: state.parts[i].name,
454
+ part2: state.parts[j].name,
455
+ severity: 'warning'
456
+ });
457
+ }
458
+ }
459
+ }
460
+
461
+ return collisions;
462
+ }
463
+
464
+ function computeCenterOfMass() {
465
+ let totalMass = 0;
466
+ const com = new THREE.Vector3();
467
+
468
+ state.parts.forEach(part => {
469
+ const volume = computeVolume(part);
470
+ const density = MATERIALS_DB[state.partProperties.get(part.uuid)?.material || 'steel'].density;
471
+ const mass = volume * (density / 1000000);
472
+
473
+ const bbox = new THREE.Box3().setFromObject(part);
474
+ const center = bbox.getCenter(new THREE.Vector3());
475
+
476
+ com.addScaledVector(center, mass);
477
+ totalMass += mass;
478
+
479
+ state.partProperties.get(part.uuid).mass = mass;
480
+ state.partProperties.get(part.uuid).volume = volume;
481
+ });
482
+
483
+ if (totalMass > 0) {
484
+ com.divideScalar(totalMass);
485
+ }
486
+
487
+ state.centerOfMass.copy(com);
488
+ return com;
489
+ }
490
+
491
+ // ============================================================================
492
+ // 7. MATERIAL DATABASE
493
+ // ============================================================================
494
+
495
+ function applyMaterial(uuid, materialName) {
496
+ const mat = MATERIALS_DB[materialName];
497
+ if (!mat) return;
498
+
499
+ const part = state.partsByUuid.get(uuid);
500
+ if (part) {
501
+ part.traverse(child => {
502
+ if (child instanceof THREE.Mesh) {
503
+ child.material = new THREE.MeshStandardMaterial({
504
+ color: mat.color,
505
+ roughness: mat.roughness,
506
+ metalness: mat.metalness
507
+ });
508
+ }
509
+ });
510
+ }
511
+
512
+ const props = state.partProperties.get(uuid);
513
+ if (props) props.material = materialName;
514
+ }
515
+
516
+ // ============================================================================
517
+ // 8. ANNOTATION TOOLS
518
+ // ============================================================================
519
+
520
+ function addAnnotation(position, text, category = 'info') {
521
+ const colors = { info: 0x0084ff, warning: 0xff6600, action: 0x00cc00 };
522
+
523
+ const geometry = new THREE.SphereGeometry(5, 8, 8);
524
+ const material = new THREE.MeshBasicMaterial({ color: colors[category] });
525
+ const pin = new THREE.Mesh(geometry, material);
526
+ pin.position.copy(position);
527
+
528
+ const annotation = {
529
+ uuid: Math.random().toString(36),
530
+ position: position.clone(),
531
+ text: text,
532
+ category: category,
533
+ pin: pin,
534
+ timestamp: Date.now()
535
+ };
536
+
537
+ getScene().add(pin);
538
+ state.annotations.push(annotation);
539
+ saveAnnotations();
540
+
541
+ return annotation;
542
+ }
543
+
544
+ function saveAnnotations() {
545
+ const data = state.annotations.map(a => ({
546
+ position: { x: a.position.x, y: a.position.y, z: a.position.z },
547
+ text: a.text,
548
+ category: a.category
549
+ }));
550
+ localStorage.setItem('cyclecad_annotations', JSON.stringify(data));
551
+ }
552
+
553
+ function loadAnnotations() {
554
+ const data = JSON.parse(localStorage.getItem('cyclecad_annotations') || '[]');
555
+ data.forEach(d => {
556
+ const pos = new THREE.Vector3(d.position.x, d.position.y, d.position.z);
557
+ addAnnotation(pos, d.text, d.category);
558
+ });
559
+ }
560
+
561
+ // ============================================================================
562
+ // 9. BOM (BILL OF MATERIALS)
563
+ // ============================================================================
564
+
565
+ function generateBOM() {
566
+ const bom = state.parts.map(part => {
567
+ const props = state.partProperties.get(part.uuid);
568
+ const volume = computeVolume(part);
569
+ const mat = MATERIALS_DB[props?.material || 'steel'];
570
+ const mass = volume * (mat.density / 1000000);
571
+
572
+ return {
573
+ partNumber: props?.name || part.name,
574
+ quantity: 1,
575
+ material: props?.material || 'steel',
576
+ volume: volume.toFixed(2),
577
+ mass: mass.toFixed(2),
578
+ unit: 'kg'
579
+ };
580
+ });
581
+
582
+ return bom;
583
+ }
584
+
585
+ function exportBOMcsv() {
586
+ const bom = generateBOM();
587
+ const headers = ['Part Number', 'Quantity', 'Material', 'Volume (mm³)', 'Mass (kg)'];
588
+
589
+ let csv = headers.join(',') + '\n';
590
+ bom.forEach(row => {
591
+ csv += `"${row.partNumber}",${row.quantity},${row.material},${row.volume},${row.mass}\n`;
592
+ });
593
+
594
+ const blob = new Blob([csv], { type: 'text/csv' });
595
+ const url = URL.createObjectURL(blob);
596
+ const a = document.createElement('a');
597
+ a.href = url;
598
+ a.download = 'bom.csv';
599
+ a.click();
600
+ }
601
+
602
+ function exportBOMhtml() {
603
+ const bom = generateBOM();
604
+ let html = '<table border="1"><tr><th>Part</th><th>Qty</th><th>Material</th><th>Volume</th><th>Mass</th></tr>';
605
+
606
+ bom.forEach(row => {
607
+ html += `<tr><td>${row.partNumber}</td><td>${row.quantity}</td><td>${row.material}</td><td>${row.volume}</td><td>${row.mass}</td></tr>`;
608
+ });
609
+
610
+ html += '</table>';
611
+ const blob = new Blob([html], { type: 'text/html' });
612
+ const url = URL.createObjectURL(blob);
613
+ window.open(url);
614
+ }
615
+
616
+ // ============================================================================
617
+ // 10. AI TOOLS
618
+ // ============================================================================
619
+
620
+ async function aiAnalyzeModel() {
621
+ const bom = generateBOM();
622
+ const totalMass = bom.reduce((sum, p) => sum + parseFloat(p.mass), 0);
623
+ const totalVolume = bom.reduce((sum, p) => sum + parseFloat(p.volume), 0);
624
+
625
+ const analysis = {
626
+ partCount: state.parts.length,
627
+ totalMass: totalMass.toFixed(2),
628
+ totalVolume: totalVolume.toFixed(2),
629
+ materials: [...new Set(bom.map(p => p.material))],
630
+ estimatedCost: (totalMass * 50).toFixed(2) // Rough estimate: €50/kg
631
+ };
632
+
633
+ return analysis;
634
+ }
635
+
636
+ async function aiNarratePartFunction(partUuid) {
637
+ const part = state.partsByUuid.get(partUuid);
638
+ if (!part) return '';
639
+
640
+ const volume = computeVolume(part);
641
+ const area = computeSurfaceArea(part);
642
+ const bbox = new THREE.Box3().setFromObject(part);
643
+ const size = bbox.getSize(new THREE.Vector3());
644
+
645
+ const narrative = `Part: ${part.name || 'Unknown'}\n` +
646
+ `Volume: ${volume.toFixed(0)} mm³\n` +
647
+ `Surface Area: ${area.toFixed(0)} mm²\n` +
648
+ `Size: ${size.x.toFixed(0)} × ${size.y.toFixed(0)} × ${size.z.toFixed(0)} mm\n` +
649
+ `This appears to be a structural component in the assembly.`;
650
+
651
+ return narrative;
652
+ }
653
+
654
+ // ============================================================================
655
+ // 11. SCREENSHOT & EXPORT
656
+ // ============================================================================
657
+
658
+ function captureScreenshot(scale = 2) {
659
+ const renderer = getRenderer();
660
+ const width = renderer.domElement.clientWidth * scale;
661
+ const height = renderer.domElement.clientHeight * scale;
662
+
663
+ const oldSize = renderer.getSize(new THREE.Vector2());
664
+ renderer.setSize(width, height);
665
+ renderer.render(getScene(), getCamera());
666
+
667
+ const canvas = renderer.domElement;
668
+ const image = canvas.toDataURL('image/png');
669
+
670
+ renderer.setSize(oldSize.x, oldSize.y);
671
+
672
+ const a = document.createElement('a');
673
+ a.href = image;
674
+ a.download = `screenshot_${Date.now()}.png`;
675
+ a.click();
676
+ }
677
+
678
+ function exportSTL(partUuid) {
679
+ const part = state.partsByUuid.get(partUuid);
680
+ if (!part) return;
681
+
682
+ const geometry = part.geometry;
683
+ if (!geometry) return;
684
+
685
+ geometry.computeVertexNormals();
686
+ const positions = geometry.attributes.position.array;
687
+ const indices = geometry.index?.array || Array.from({ length: positions.length / 3 }, (_, i) => i);
688
+
689
+ let stl = 'solid exported\n';
690
+
691
+ for (let i = 0; i < indices.length; i += 3) {
692
+ const i1 = indices[i] * 3;
693
+ const i2 = indices[i + 1] * 3;
694
+ const i3 = indices[i + 2] * 3;
695
+
696
+ const v1 = new THREE.Vector3(positions[i1], positions[i1+1], positions[i1+2]);
697
+ const v2 = new THREE.Vector3(positions[i2], positions[i2+1], positions[i2+2]);
698
+ const v3 = new THREE.Vector3(positions[i3], positions[i3+1], positions[i3+2]);
699
+
700
+ const e1 = v2.clone().sub(v1);
701
+ const e2 = v3.clone().sub(v1);
702
+ const normal = e1.cross(e2).normalize();
703
+
704
+ stl += ` facet normal ${normal.x} ${normal.y} ${normal.z}\n`;
705
+ stl += ` outer loop\n`;
706
+ stl += ` vertex ${v1.x} ${v1.y} ${v1.z}\n`;
707
+ stl += ` vertex ${v2.x} ${v2.y} ${v2.z}\n`;
708
+ stl += ` vertex ${v3.x} ${v3.y} ${v3.z}\n`;
709
+ stl += ` endloop\n`;
710
+ stl += ` endfacet\n`;
711
+ }
712
+
713
+ stl += 'endsolid exported\n';
714
+
715
+ const blob = new Blob([stl], { type: 'text/plain' });
716
+ const url = URL.createObjectURL(blob);
717
+ const a = document.createElement('a');
718
+ a.href = url;
719
+ a.download = `${part.name || 'part'}.stl`;
720
+ a.click();
721
+ }
722
+
723
+ function exportOBJ() {
724
+ let obj = 'mtllib model.mtl\n';
725
+ let vertexCount = 1;
726
+
727
+ state.parts.forEach((part, pidx) => {
728
+ const geometry = part.geometry;
729
+ if (!geometry) return;
730
+
731
+ const positions = geometry.attributes.position.array;
732
+
733
+ obj += `g part_${pidx}\n`;
734
+
735
+ for (let i = 0; i < positions.length; i += 3) {
736
+ obj += `v ${positions[i]} ${positions[i+1]} ${positions[i+2]}\n`;
737
+ }
738
+
739
+ const indices = geometry.index?.array || Array.from({ length: positions.length / 3 }, (_, i) => i);
740
+
741
+ obj += `usemtl material_${pidx}\n`;
742
+ for (let i = 0; i < indices.length; i += 3) {
743
+ const a = indices[i] + vertexCount;
744
+ const b = indices[i + 1] + vertexCount;
745
+ const c = indices[i + 2] + vertexCount;
746
+ obj += `f ${a} ${b} ${c}\n`;
747
+ }
748
+
749
+ vertexCount += positions.length / 3;
750
+ });
751
+
752
+ const blob = new Blob([obj], { type: 'text/plain' });
753
+ const url = URL.createObjectURL(blob);
754
+ const a = document.createElement('a');
755
+ a.href = url;
756
+ a.download = 'model.obj';
757
+ a.click();
758
+ }
759
+
760
+ // ============================================================================
761
+ // 12. DISPLAY MODES
762
+ // ============================================================================
763
+
764
+ function toggleWireframe() {
765
+ state.parts.forEach(part => {
766
+ part.traverse(node => {
767
+ if (node instanceof THREE.Mesh) {
768
+ node.material.wireframe = !node.material.wireframe;
769
+ }
770
+ });
771
+ });
772
+ }
773
+
774
+ function toggleTransparency(alpha = 0.5) {
775
+ state.parts.forEach(part => {
776
+ part.traverse(node => {
777
+ if (node instanceof THREE.Mesh) {
778
+ node.material.transparent = true;
779
+ node.material.opacity = alpha;
780
+ }
781
+ });
782
+ });
783
+ }
784
+
785
+ function toggleXray() {
786
+ state.parts.forEach(part => {
787
+ part.traverse(node => {
788
+ if (node instanceof THREE.Mesh) {
789
+ if (!node.userData.xrayMode) {
790
+ node.userData.xrayMode = true;
791
+ node.material.fog = false;
792
+ } else {
793
+ node.userData.xrayMode = false;
794
+ node.material.fog = true;
795
+ }
796
+ }
797
+ });
798
+ });
799
+ }
800
+
801
+ // ============================================================================
802
+ // 13. VIEW CONTROLS
803
+ // ============================================================================
804
+
805
+ function fitAllParts() {
806
+ const scene = getScene();
807
+ const camera = getCamera();
808
+ const controls = getControls();
809
+
810
+ const box = new THREE.Box3();
811
+ state.parts.forEach(p => box.expandByObject(p));
812
+
813
+ if (!box.isEmpty()) {
814
+ const size = box.getSize(new THREE.Vector3());
815
+ const maxDim = Math.max(size.x, size.y, size.z);
816
+ const fov = camera.fov * (Math.PI / 180);
817
+ let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
818
+
819
+ const center = box.getCenter(new THREE.Vector3());
820
+ camera.position.copy(center);
821
+ camera.position.z = cameraZ;
822
+ camera.lookAt(center);
823
+
824
+ if (controls) {
825
+ controls.target.copy(center);
826
+ controls.update();
827
+ }
828
+ }
829
+ }
830
+
831
+ function setViewDirection(direction) {
832
+ const camera = getCamera();
833
+ const scene = getScene();
834
+ const box = new THREE.Box3();
835
+ state.parts.forEach(p => box.expandByObject(p));
836
+ const center = box.getCenter(new THREE.Vector3());
837
+
838
+ const distance = 150;
839
+ const view = {
840
+ front: new THREE.Vector3(0, 0, distance),
841
+ back: new THREE.Vector3(0, 0, -distance),
842
+ top: new THREE.Vector3(0, distance, 0),
843
+ bottom: new THREE.Vector3(0, -distance, 0),
844
+ left: new THREE.Vector3(-distance, 0, 0),
845
+ right: new THREE.Vector3(distance, 0, 0),
846
+ iso: new THREE.Vector3(100, 100, 100)
847
+ };
848
+
849
+ const dir = view[direction];
850
+ if (dir) {
851
+ camera.position.copy(center).add(dir);
852
+ camera.lookAt(center);
853
+ }
854
+ }
855
+
856
+ // ============================================================================
857
+ // 14. ANIMATED ASSEMBLY
858
+ // ============================================================================
859
+
860
+ function createAssemblyAnimation() {
861
+ state.assemblySteps = state.parts.map((part, idx) => ({
862
+ part: part,
863
+ startTime: idx * 500,
864
+ duration: 1000,
865
+ startPos: part.position.clone(),
866
+ endPos: part.position.clone()
867
+ }));
868
+ }
869
+
870
+ function playAssemblyAnimation() {
871
+ const startTime = Date.now();
872
+
873
+ const animate = () => {
874
+ const elapsed = Date.now() - startTime;
875
+
876
+ state.assemblySteps.forEach(step => {
877
+ if (elapsed >= step.startTime && elapsed < step.startTime + step.duration) {
878
+ const progress = (elapsed - step.startTime) / step.duration;
879
+ step.part.position.lerpVectors(step.startPos, step.endPos, progress);
880
+ }
881
+ });
882
+
883
+ if (elapsed < state.assemblySteps[state.assemblySteps.length - 1].startTime + 1000) {
884
+ requestAnimationFrame(animate);
885
+ }
886
+ };
887
+
888
+ animate();
889
+ }
890
+
891
+ // ============================================================================
892
+ // 15. SMART PART SEARCH
893
+ // ============================================================================
894
+
895
+ function searchParts(query) {
896
+ const results = [];
897
+ const lower = query.toLowerCase();
898
+
899
+ state.parts.forEach(part => {
900
+ const props = state.partProperties.get(part.uuid);
901
+ if (props?.name.toLowerCase().includes(lower)) {
902
+ results.push(part);
903
+ part.traverse(node => {
904
+ if (node instanceof THREE.Mesh) {
905
+ node.material.emissive.setHex(0xffff00);
906
+ }
907
+ });
908
+ }
909
+ });
910
+
911
+ return results;
912
+ }
913
+
914
+ function clearSearch() {
915
+ state.parts.forEach(part => {
916
+ part.traverse(node => {
917
+ if (node instanceof THREE.Mesh) {
918
+ node.material.emissive.setHex(0x000000);
919
+ }
920
+ });
921
+ });
922
+ }
923
+
924
+ // ============================================================================
925
+ // 16. LANGUAGE SUPPORT
926
+ // ============================================================================
927
+
928
+ function setLanguage(lang) {
929
+ state.currentLanguage = lang;
930
+ }
931
+
932
+ function getTranslation(key) {
933
+ return LANGUAGE_STRINGS[state.currentLanguage]?.[key] || key;
934
+ }
935
+
936
+ // ============================================================================
937
+ // 17. AR MODE (Stub for WebXR)
938
+ // ============================================================================
939
+
940
+ async function enterARMode() {
941
+ if (!navigator.xr) {
942
+ console.warn('WebXR not supported');
943
+ return false;
944
+ }
945
+
946
+ try {
947
+ const session = await navigator.xr.requestSession('immersive-ar', {
948
+ requiredFeatures: ['hit-test'],
949
+ optionalFeatures: ['dom-overlay'],
950
+ domOverlay: { root: document.body }
951
+ });
952
+
953
+ state.isARMode = true;
954
+ return true;
955
+ } catch (e) {
956
+ console.error('AR mode failed:', e);
957
+ return false;
958
+ }
959
+ }
960
+
961
+ function exitARMode() {
962
+ state.isARMode = false;
963
+ }
964
+
965
+ // ============================================================================
966
+ // 18. GD&T ANNOTATIONS
967
+ // ============================================================================
968
+
969
+ function addGDTAnnotation(position, symbol, label) {
970
+ const canvas = document.createElement('canvas');
971
+ canvas.width = 128;
972
+ canvas.height = 128;
973
+ const ctx = canvas.getContext('2d');
974
+
975
+ ctx.fillStyle = 'white';
976
+ ctx.fillRect(0, 0, 128, 128);
977
+ ctx.fillStyle = 'black';
978
+ ctx.font = 'bold 14px Arial';
979
+ ctx.textAlign = 'center';
980
+ ctx.fillText(symbol, 64, 40);
981
+ ctx.fillText(label, 64, 80);
982
+
983
+ const texture = new THREE.CanvasTexture(canvas);
984
+ const geometry = new THREE.PlaneGeometry(0.5, 0.5);
985
+ const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
986
+ const sprite = new THREE.Mesh(geometry, material);
987
+ sprite.position.copy(position);
988
+
989
+ getScene().add(sprite);
990
+ }
991
+
992
+ // ============================================================================
993
+ // PUBLIC API
994
+ // ============================================================================
995
+
996
+ return {
997
+ // Model loading
998
+ loadModel,
999
+ loadSTEP,
1000
+ addPart,
1001
+
1002
+ // Assembly tree
1003
+ buildAssemblyTree,
1004
+ togglePartVisibility,
1005
+ isolatePart,
1006
+ showAllParts,
1007
+
1008
+ // Explode/collapse
1009
+ explodeParts,
1010
+ collapseParts,
1011
+
1012
+ // Section cut
1013
+ setSectionCut,
1014
+
1015
+ // Measurements
1016
+ measureDistance,
1017
+ measureAngle,
1018
+ computeVolume,
1019
+ computeSurfaceArea,
1020
+
1021
+ // Analysis
1022
+ analyzeWallThickness,
1023
+ analyzeDraftAngle,
1024
+ checkInterference,
1025
+ computeCenterOfMass,
1026
+
1027
+ // Materials
1028
+ applyMaterial,
1029
+
1030
+ // Annotations
1031
+ addAnnotation,
1032
+ saveAnnotations,
1033
+ loadAnnotations,
1034
+
1035
+ // BOM
1036
+ generateBOM,
1037
+ exportBOMcsv,
1038
+ exportBOMhtml,
1039
+
1040
+ // AI tools
1041
+ aiAnalyzeModel,
1042
+ aiNarratePartFunction,
1043
+
1044
+ // Export
1045
+ captureScreenshot,
1046
+ exportSTL,
1047
+ exportOBJ,
1048
+
1049
+ // Display modes
1050
+ toggleWireframe,
1051
+ toggleTransparency,
1052
+ toggleXray,
1053
+
1054
+ // View controls
1055
+ fitAllParts,
1056
+ setViewDirection,
1057
+
1058
+ // Assembly animation
1059
+ createAssemblyAnimation,
1060
+ playAssemblyAnimation,
1061
+
1062
+ // Search
1063
+ searchParts,
1064
+ clearSearch,
1065
+
1066
+ // Language
1067
+ setLanguage,
1068
+ getTranslation,
1069
+
1070
+ // AR
1071
+ enterARMode,
1072
+ exitARMode,
1073
+
1074
+ // GD&T
1075
+ addGDTAnnotation,
1076
+
1077
+ // Convenience methods for menu integration
1078
+ computeVolumeOfSelected: () => {
1079
+ if (state.selectedPart) return computeVolume(state.selectedPart);
1080
+ if (state.parts.length > 0) return computeVolume(state.parts[0]);
1081
+ return null;
1082
+ },
1083
+ computeAreaOfSelected: () => {
1084
+ if (state.selectedPart) return computeSurfaceArea(state.selectedPart);
1085
+ if (state.parts.length > 0) return computeSurfaceArea(state.parts[0]);
1086
+ return null;
1087
+ },
1088
+ analyzeWallThicknessOfSelected: () => {
1089
+ const mesh = state.selectedPart || state.parts[0];
1090
+ if (mesh) analyzeWallThickness(mesh);
1091
+ },
1092
+ analyzeDraftAngleOfSelected: () => {
1093
+ const mesh = state.selectedPart || state.parts[0];
1094
+ if (mesh) analyzeDraftAngle(mesh);
1095
+ },
1096
+ exportSTLSelected: () => {
1097
+ const mesh = state.selectedPart || state.parts[0];
1098
+ if (mesh) exportSTL(mesh.uuid);
1099
+ },
1100
+ aiNarrateSelected: async () => {
1101
+ const mesh = state.selectedPart || state.parts[0];
1102
+ if (mesh) return await aiNarratePartFunction(mesh.uuid);
1103
+ return 'Select a part first';
1104
+ },
1105
+ getMassProperties: () => {
1106
+ computeCenterOfMass();
1107
+ let totalMass = 0;
1108
+ state.parts.forEach(part => {
1109
+ const props = state.partProperties.get(part.uuid);
1110
+ const vol = computeVolume(part);
1111
+ const mat = MATERIALS_DB[props?.material || 'steel'];
1112
+ totalMass += vol * (mat.density / 1000000);
1113
+ });
1114
+ return { totalMass, cog: state.centerOfMass, partCount: state.parts.length };
1115
+ },
1116
+ highlightPart: (uuid) => {
1117
+ const part = state.partsByUuid.get(uuid);
1118
+ if (part && part.material) {
1119
+ state.parts.forEach(p => { if (p.material) p.material.emissive?.setHex(0x000000); });
1120
+ part.material.emissive?.setHex(0x444444);
1121
+ state.selectedPart = part;
1122
+ }
1123
+ },
1124
+ startMeasureMode: (type) => {
1125
+ console.log('[ExplodeView] Measure mode:', type, '— click points in viewport');
1126
+ },
1127
+ startAnnotationMode: () => {
1128
+ console.log('[ExplodeView] Annotation mode — click a point on the model');
1129
+ },
1130
+ startGDTMode: () => {
1131
+ console.log('[ExplodeView] GD&T mode — click a surface');
1132
+ },
1133
+
1134
+ // State access
1135
+ getState: () => state,
1136
+ getParts: () => state.parts,
1137
+ getAssemblyTree: () => state.assemblyTree
1138
+ };
1139
+ }
1140
+
1141
+ export default initExplodeView;