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.
- package/app/index.html +558 -25
- package/app/js/explodeview-full.js +1141 -0
- package/app/js/modules/image-to-cad.js +1184 -0
- package/app/js/modules/openscad-engine.js +817 -0
- package/app/js/modules/parametric-sliders.js +1322 -0
- package/app/js/modules/scad-export.js +643 -0
- package/app/js/test-compat-shim.js +121 -0
- package/app/tests/killer-features-visual-test.html +71 -49
- package/package.json +1 -1
|
@@ -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;
|