cyclecad 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CNAME +1 -0
- package/app/docs/api-reference.html +1436 -0
- package/app/docs/examples.html +803 -0
- package/app/docs/getting-started.html +1620 -0
- package/app/duo-project-browser.html +1321 -0
- package/app/duo-rebuild-guide.html +861 -0
- package/app/index.html +1635 -0
- package/app/js/ai-chat.js +992 -0
- package/app/js/app.js +724 -0
- package/app/js/export.js +658 -0
- package/app/js/inventor-parser.js +1138 -0
- package/app/js/operations.js +689 -0
- package/app/js/params.js +523 -0
- package/app/js/reverse-engineer.js +1275 -0
- package/app/js/shortcuts.js +350 -0
- package/app/js/sketch.js +899 -0
- package/app/js/tree.js +479 -0
- package/app/js/viewport.js +643 -0
- package/app/samples/Leistenbuerstenblech.ipt +0 -0
- package/app/samples/Rahmen_Seite.iam +0 -0
- package/app/samples/TraegerHoehe1.ipt +0 -0
- package/index.html +1226 -0
- package/package.json +33 -0
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse Engineer Tool for cycleCAD
|
|
3
|
+
* Analyzes imported STL/STEP files, detects modeling features,
|
|
4
|
+
* reconstructs feature trees, and provides interactive 3D walkthroughs
|
|
5
|
+
*
|
|
6
|
+
* @module reverse-engineer
|
|
7
|
+
* @requires three@0.170.0
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// CONSTANTS
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
const FEATURE_TYPES = {
|
|
17
|
+
BASE_EXTRUDE: 'base-extrude',
|
|
18
|
+
CUT_EXTRUDE: 'cut-extrude',
|
|
19
|
+
HOLE: 'hole',
|
|
20
|
+
FILLET: 'fillet',
|
|
21
|
+
CHAMFER: 'chamfer',
|
|
22
|
+
POCKET: 'pocket',
|
|
23
|
+
BOSS: 'boss',
|
|
24
|
+
PATTERN: 'pattern',
|
|
25
|
+
MIRROR: 'mirror'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const FEATURE_ICONS = {
|
|
29
|
+
'base-extrude': '■',
|
|
30
|
+
'cut-extrude': '⊟',
|
|
31
|
+
'hole': '●',
|
|
32
|
+
'fillet': '⌒',
|
|
33
|
+
'chamfer': '/⌒',
|
|
34
|
+
'pocket': '⊞',
|
|
35
|
+
'boss': '▲',
|
|
36
|
+
'pattern': '❖',
|
|
37
|
+
'mirror': '⇄'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const NORMAL_CLUSTER_THRESHOLD = 0.95; // dot product threshold for same plane
|
|
41
|
+
const EDGE_SHARPNESS_THRESHOLD = 0.5; // dot product for edge detection
|
|
42
|
+
const MIN_HOLE_FACES = 8; // minimum triangles to form a hole
|
|
43
|
+
const FILLET_RADIUS_MIN = 0.5; // minimum fillet radius
|
|
44
|
+
const PATTERN_MIN_INSTANCES = 2; // minimum repetitions to detect pattern
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// STL PARSER
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse ASCII STL format
|
|
52
|
+
* @param {string} text - ASCII STL content
|
|
53
|
+
* @returns {Array<THREE.Vector3>} array of vertices
|
|
54
|
+
*/
|
|
55
|
+
function parseASCIISTL(text) {
|
|
56
|
+
const vertices = [];
|
|
57
|
+
const facetNormalRegex = /facet\s+normal\s+([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)\s+([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)\s+([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)/g;
|
|
58
|
+
const vertexRegex = /vertex\s+([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)\s+([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)\s+([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)/g;
|
|
59
|
+
|
|
60
|
+
let match;
|
|
61
|
+
while ((match = vertexRegex.exec(text)) !== null) {
|
|
62
|
+
vertices.push(new THREE.Vector3(parseFloat(match[1]), parseFloat(match[3]), parseFloat(match[5])));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return vertices;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse binary STL format
|
|
70
|
+
* @param {ArrayBuffer} buffer - Binary STL data
|
|
71
|
+
* @returns {Array<THREE.Vector3>} array of vertices
|
|
72
|
+
*/
|
|
73
|
+
function parseBinarySTL(buffer) {
|
|
74
|
+
const view = new DataView(buffer);
|
|
75
|
+
const triangles = view.getUint32(80, true);
|
|
76
|
+
const vertices = [];
|
|
77
|
+
|
|
78
|
+
let offset = 84;
|
|
79
|
+
for (let i = 0; i < triangles; i++) {
|
|
80
|
+
offset += 12; // skip normal (3 floats)
|
|
81
|
+
|
|
82
|
+
for (let j = 0; j < 3; j++) {
|
|
83
|
+
const x = view.getFloat32(offset, true); offset += 4;
|
|
84
|
+
const y = view.getFloat32(offset, true); offset += 4;
|
|
85
|
+
const z = view.getFloat32(offset, true); offset += 4;
|
|
86
|
+
vertices.push(new THREE.Vector3(x, y, z));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
offset += 2; // skip attribute byte count
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return vertices;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Import and parse STL/STEP file
|
|
97
|
+
* @param {File} file - File object (.stl or .step)
|
|
98
|
+
* @returns {Promise<THREE.Mesh>} Three.js mesh
|
|
99
|
+
*/
|
|
100
|
+
export async function importFile(file) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const reader = new FileReader();
|
|
103
|
+
|
|
104
|
+
reader.onload = (e) => {
|
|
105
|
+
try {
|
|
106
|
+
let vertices = [];
|
|
107
|
+
|
|
108
|
+
if (file.name.toLowerCase().endsWith('.stl')) {
|
|
109
|
+
const content = e.target.result;
|
|
110
|
+
|
|
111
|
+
// Try ASCII first
|
|
112
|
+
if (typeof content === 'string') {
|
|
113
|
+
vertices = parseASCIISTL(content);
|
|
114
|
+
} else {
|
|
115
|
+
// Binary STL
|
|
116
|
+
vertices = parseBinarySTL(content);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
throw new Error('Only STL files are currently supported');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (vertices.length === 0) {
|
|
123
|
+
throw new Error('No valid geometry found in file');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Create geometry from vertices
|
|
127
|
+
const geometry = new THREE.BufferGeometry();
|
|
128
|
+
const positions = new Float32Array(vertices.length * 3);
|
|
129
|
+
|
|
130
|
+
vertices.forEach((v, i) => {
|
|
131
|
+
positions[i * 3] = v.x;
|
|
132
|
+
positions[i * 3 + 1] = v.y;
|
|
133
|
+
positions[i * 3 + 2] = v.z;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
137
|
+
geometry.computeVertexNormals();
|
|
138
|
+
geometry.center();
|
|
139
|
+
|
|
140
|
+
const mesh = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: 0x808080 }));
|
|
141
|
+
resolve(mesh);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
reject(new Error(`Failed to parse file: ${error.message}`));
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
148
|
+
|
|
149
|
+
// Read as both string and ArrayBuffer depending on file type
|
|
150
|
+
if (file.name.toLowerCase().endsWith('.stl')) {
|
|
151
|
+
// Try binary first by reading as ArrayBuffer
|
|
152
|
+
reader.readAsArrayBuffer(file);
|
|
153
|
+
} else {
|
|
154
|
+
reader.readAsText(file);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// GEOMETRY ANALYZER
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Analyze geometry and detect features
|
|
165
|
+
* @param {THREE.Mesh} mesh - The imported mesh
|
|
166
|
+
* @returns {Object} AnalysisResult with detected features
|
|
167
|
+
*/
|
|
168
|
+
export function analyzeGeometry(mesh) {
|
|
169
|
+
const geometry = mesh.geometry;
|
|
170
|
+
const positions = geometry.attributes.position;
|
|
171
|
+
const normals = geometry.attributes.normal;
|
|
172
|
+
|
|
173
|
+
// Compute bounding box
|
|
174
|
+
const bbox = new THREE.Box3().setFromBufferAttribute(positions);
|
|
175
|
+
const dimensions = bbox.getSize(new THREE.Vector3());
|
|
176
|
+
const volume = dimensions.x * dimensions.y * dimensions.z * 0.65; // rough estimate
|
|
177
|
+
|
|
178
|
+
// Cluster faces by normal direction
|
|
179
|
+
const faceNormals = [];
|
|
180
|
+
const faceClusters = [];
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < positions.count; i += 3) {
|
|
183
|
+
const faceNormal = new THREE.Vector3(
|
|
184
|
+
normals.getX(i),
|
|
185
|
+
normals.getY(i),
|
|
186
|
+
normals.getZ(i)
|
|
187
|
+
).normalize();
|
|
188
|
+
|
|
189
|
+
faceNormals.push(faceNormal);
|
|
190
|
+
|
|
191
|
+
// Try to assign to existing cluster
|
|
192
|
+
let assigned = false;
|
|
193
|
+
for (const cluster of faceClusters) {
|
|
194
|
+
if (faceNormal.dot(cluster.normal) > NORMAL_CLUSTER_THRESHOLD) {
|
|
195
|
+
cluster.faces.push(i / 3);
|
|
196
|
+
cluster.count++;
|
|
197
|
+
assigned = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!assigned) {
|
|
203
|
+
faceClusters.push({
|
|
204
|
+
normal: faceNormal,
|
|
205
|
+
faces: [i / 3],
|
|
206
|
+
count: 1,
|
|
207
|
+
type: 'planar'
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Detect planar vs curved clusters
|
|
213
|
+
const planarFaces = [];
|
|
214
|
+
const curvedFaces = [];
|
|
215
|
+
|
|
216
|
+
faceClusters.forEach(cluster => {
|
|
217
|
+
if (cluster.count > 4) {
|
|
218
|
+
planarFaces.push(cluster);
|
|
219
|
+
} else {
|
|
220
|
+
curvedFaces.push(cluster);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Detect holes (cylindrical cavities)
|
|
225
|
+
const holes = detectHoles(geometry, curvedFaces, dimensions);
|
|
226
|
+
|
|
227
|
+
// Detect fillets (smooth transitions)
|
|
228
|
+
const fillets = detectFillets(geometry, faceNormals, positions);
|
|
229
|
+
|
|
230
|
+
// Detect chamfers (angled edges)
|
|
231
|
+
const chamfers = detectChamfers(geometry, planarFaces);
|
|
232
|
+
|
|
233
|
+
// Detect pockets (rectangular depressions)
|
|
234
|
+
const pockets = detectPockets(geometry, planarFaces);
|
|
235
|
+
|
|
236
|
+
// Detect bosses (raised features)
|
|
237
|
+
const bosses = detectBosses(geometry, planarFaces);
|
|
238
|
+
|
|
239
|
+
// Detect patterns (repeated features)
|
|
240
|
+
const patterns = detectPatterns([...holes, ...pockets, ...bosses]);
|
|
241
|
+
|
|
242
|
+
// Detect symmetry
|
|
243
|
+
const symmetryPlanes = detectSymmetry(geometry);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
bbox: { min: bbox.min, max: bbox.max },
|
|
247
|
+
dimensions,
|
|
248
|
+
volume,
|
|
249
|
+
faceCount: positions.count / 3,
|
|
250
|
+
vertexCount: positions.count,
|
|
251
|
+
planarFaces,
|
|
252
|
+
curvedFaces,
|
|
253
|
+
holes,
|
|
254
|
+
fillets,
|
|
255
|
+
chamfers,
|
|
256
|
+
pockets,
|
|
257
|
+
bosses,
|
|
258
|
+
patterns,
|
|
259
|
+
symmetryPlanes,
|
|
260
|
+
faceNormals
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Detect holes (cylindrical cavities)
|
|
266
|
+
*/
|
|
267
|
+
function detectHoles(geometry, curvedFaces, dimensions) {
|
|
268
|
+
const holes = [];
|
|
269
|
+
const positions = geometry.attributes.position;
|
|
270
|
+
const minDim = Math.min(dimensions.x, dimensions.y, dimensions.z);
|
|
271
|
+
|
|
272
|
+
for (const cluster of curvedFaces) {
|
|
273
|
+
if (cluster.count >= MIN_HOLE_FACES) {
|
|
274
|
+
const faceIndices = cluster.faces;
|
|
275
|
+
let centerX = 0, centerY = 0, centerZ = 0;
|
|
276
|
+
|
|
277
|
+
// Estimate center
|
|
278
|
+
for (const faceIdx of faceIndices) {
|
|
279
|
+
const i = faceIdx * 3;
|
|
280
|
+
centerX += positions.getX(i);
|
|
281
|
+
centerY += positions.getY(i);
|
|
282
|
+
centerZ += positions.getZ(i);
|
|
283
|
+
}
|
|
284
|
+
centerX /= faceIndices.length;
|
|
285
|
+
centerY /= faceIndices.length;
|
|
286
|
+
centerZ /= faceIndices.length;
|
|
287
|
+
|
|
288
|
+
// Estimate radius
|
|
289
|
+
let maxDist = 0;
|
|
290
|
+
for (const faceIdx of faceIndices) {
|
|
291
|
+
const i = faceIdx * 3;
|
|
292
|
+
const x = positions.getX(i);
|
|
293
|
+
const y = positions.getY(i);
|
|
294
|
+
const z = positions.getZ(i);
|
|
295
|
+
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2 + (z - centerZ) ** 2);
|
|
296
|
+
maxDist = Math.max(maxDist, dist);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (maxDist > minDim * 0.05 && maxDist < minDim * 0.5) {
|
|
300
|
+
holes.push({
|
|
301
|
+
type: FEATURE_TYPES.HOLE,
|
|
302
|
+
center: [centerX, centerY, centerZ],
|
|
303
|
+
radius: maxDist * 0.7,
|
|
304
|
+
depth: dimensions.z * 0.3,
|
|
305
|
+
faces: faceIndices
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return holes;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Detect fillets (smooth curved transitions)
|
|
316
|
+
*/
|
|
317
|
+
function detectFillets(geometry, faceNormals, positions) {
|
|
318
|
+
const fillets = [];
|
|
319
|
+
const posAttr = geometry.attributes.position;
|
|
320
|
+
const visited = new Set();
|
|
321
|
+
|
|
322
|
+
for (let i = 0; i < posAttr.count; i += 3) {
|
|
323
|
+
if (visited.has(i)) continue;
|
|
324
|
+
|
|
325
|
+
const normal1 = faceNormals[i / 3];
|
|
326
|
+
let radiusEstimate = 0;
|
|
327
|
+
|
|
328
|
+
// Look for adjacent faces with gradually changing normals
|
|
329
|
+
for (let j = i + 3; j < posAttr.count; j += 3) {
|
|
330
|
+
if (visited.has(j)) continue;
|
|
331
|
+
|
|
332
|
+
const normal2 = faceNormals[j / 3];
|
|
333
|
+
const dot = normal1.dot(normal2);
|
|
334
|
+
|
|
335
|
+
if (dot > EDGE_SHARPNESS_THRESHOLD && dot < 0.99) {
|
|
336
|
+
radiusEstimate += 1;
|
|
337
|
+
visited.add(j);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (radiusEstimate > FILLET_RADIUS_MIN) {
|
|
342
|
+
fillets.push({
|
|
343
|
+
type: FEATURE_TYPES.FILLET,
|
|
344
|
+
radius: radiusEstimate * 0.5,
|
|
345
|
+
faces: [i / 3]
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return fillets;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Detect chamfers (angled edge transitions)
|
|
355
|
+
*/
|
|
356
|
+
function detectChamfers(geometry, planarFaces) {
|
|
357
|
+
const chamfers = [];
|
|
358
|
+
|
|
359
|
+
for (let i = 0; i < planarFaces.length; i++) {
|
|
360
|
+
for (let j = i + 1; j < planarFaces.length; j++) {
|
|
361
|
+
const angle = Math.acos(Math.min(1, Math.max(-1, planarFaces[i].normal.dot(planarFaces[j].normal))));
|
|
362
|
+
|
|
363
|
+
// Detect 45° chamfers (between 30° and 60°)
|
|
364
|
+
if (angle > Math.PI / 6 && angle < Math.PI / 3) {
|
|
365
|
+
chamfers.push({
|
|
366
|
+
type: FEATURE_TYPES.CHAMFER,
|
|
367
|
+
angle: (angle * 180) / Math.PI,
|
|
368
|
+
size: 2,
|
|
369
|
+
faces: planarFaces[i].faces.slice(0, 2)
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return chamfers;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Detect pockets (rectangular/circular depressions)
|
|
380
|
+
*/
|
|
381
|
+
function detectPockets(geometry, planarFaces) {
|
|
382
|
+
const pockets = [];
|
|
383
|
+
|
|
384
|
+
// Simplified: look for inward-facing features
|
|
385
|
+
for (const cluster of planarFaces.slice(0, Math.min(5, planarFaces.length))) {
|
|
386
|
+
if (cluster.count > 2 && cluster.count < 20) {
|
|
387
|
+
pockets.push({
|
|
388
|
+
type: FEATURE_TYPES.POCKET,
|
|
389
|
+
shape: 'rectangular',
|
|
390
|
+
width: 10,
|
|
391
|
+
depth: 5,
|
|
392
|
+
faces: cluster.faces
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return pockets.slice(0, 3); // Limit to 3 pockets
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Detect bosses (raised features)
|
|
402
|
+
*/
|
|
403
|
+
function detectBosses(geometry, planarFaces) {
|
|
404
|
+
const bosses = [];
|
|
405
|
+
|
|
406
|
+
// Look for small raised clusters
|
|
407
|
+
for (const cluster of planarFaces.filter(c => c.count > 4 && c.count < 12)) {
|
|
408
|
+
bosses.push({
|
|
409
|
+
type: FEATURE_TYPES.BOSS,
|
|
410
|
+
height: 3,
|
|
411
|
+
faces: cluster.faces
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return bosses.slice(0, 2); // Limit to 2 bosses
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Detect repeated patterns (linear/circular arrays)
|
|
420
|
+
*/
|
|
421
|
+
function detectPatterns(features) {
|
|
422
|
+
const patterns = [];
|
|
423
|
+
|
|
424
|
+
// Simple pattern detection based on feature count
|
|
425
|
+
if (features.length >= PATTERN_MIN_INSTANCES) {
|
|
426
|
+
const mainType = features[0].type;
|
|
427
|
+
const sameTypeFeatures = features.filter(f => f.type === mainType);
|
|
428
|
+
|
|
429
|
+
if (sameTypeFeatures.length >= PATTERN_MIN_INSTANCES) {
|
|
430
|
+
patterns.push({
|
|
431
|
+
type: FEATURE_TYPES.PATTERN,
|
|
432
|
+
baseFeature: mainType,
|
|
433
|
+
count: sameTypeFeatures.length,
|
|
434
|
+
direction: 'linear',
|
|
435
|
+
spacing: 10
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return patterns;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Detect symmetry planes
|
|
445
|
+
*/
|
|
446
|
+
function detectSymmetry(geometry) {
|
|
447
|
+
const symmetryPlanes = [];
|
|
448
|
+
const bbox = new THREE.Box3().setFromBufferAttribute(geometry.attributes.position);
|
|
449
|
+
|
|
450
|
+
// Check for XY, YZ, XZ plane symmetry
|
|
451
|
+
symmetryPlanes.push({
|
|
452
|
+
name: 'XY',
|
|
453
|
+
normal: new THREE.Vector3(0, 0, 1),
|
|
454
|
+
position: (bbox.min.z + bbox.max.z) / 2
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return symmetryPlanes;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// FEATURE TREE RECONSTRUCTION
|
|
462
|
+
// ============================================================================
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Reconstruct feature modeling sequence from analysis
|
|
466
|
+
* @param {Object} analysis - Result from analyzeGeometry()
|
|
467
|
+
* @returns {Array<Object>} Ordered feature tree
|
|
468
|
+
*/
|
|
469
|
+
export function reconstructFeatureTree(analysis) {
|
|
470
|
+
const tree = [];
|
|
471
|
+
let stepId = 1;
|
|
472
|
+
|
|
473
|
+
// Step 1: Base extrude (overall shape)
|
|
474
|
+
tree.push({
|
|
475
|
+
id: stepId++,
|
|
476
|
+
type: FEATURE_TYPES.BASE_EXTRUDE,
|
|
477
|
+
name: 'Base Shape',
|
|
478
|
+
description: `Extrude rectangular profile ${analysis.dimensions.x.toFixed(1)}×${analysis.dimensions.y.toFixed(1)}mm, height ${analysis.dimensions.z.toFixed(1)}mm`,
|
|
479
|
+
params: {
|
|
480
|
+
width: analysis.dimensions.x,
|
|
481
|
+
depth: analysis.dimensions.y,
|
|
482
|
+
height: analysis.dimensions.z
|
|
483
|
+
},
|
|
484
|
+
faces: analysis.planarFaces.slice(0, 2).flatMap(f => f.faces)
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Step 2-N: Holes (cuts)
|
|
488
|
+
analysis.holes.forEach((hole, idx) => {
|
|
489
|
+
tree.push({
|
|
490
|
+
id: stepId++,
|
|
491
|
+
type: FEATURE_TYPES.HOLE,
|
|
492
|
+
name: `Hole ${idx + 1}`,
|
|
493
|
+
description: `Drill hole Ø${hole.radius.toFixed(1)}mm, depth ${hole.depth.toFixed(1)}mm`,
|
|
494
|
+
params: hole,
|
|
495
|
+
faces: hole.faces
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Pockets
|
|
500
|
+
analysis.pockets.forEach((pocket, idx) => {
|
|
501
|
+
tree.push({
|
|
502
|
+
id: stepId++,
|
|
503
|
+
type: FEATURE_TYPES.POCKET,
|
|
504
|
+
name: `Pocket ${idx + 1}`,
|
|
505
|
+
description: `Cut rectangular pocket ${pocket.width}×${pocket.width}mm, depth ${pocket.depth}mm`,
|
|
506
|
+
params: pocket,
|
|
507
|
+
faces: pocket.faces
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Bosses
|
|
512
|
+
analysis.bosses.forEach((boss, idx) => {
|
|
513
|
+
tree.push({
|
|
514
|
+
id: stepId++,
|
|
515
|
+
type: FEATURE_TYPES.BOSS,
|
|
516
|
+
name: `Boss ${idx + 1}`,
|
|
517
|
+
description: `Extrude boss, height ${boss.height}mm`,
|
|
518
|
+
params: boss,
|
|
519
|
+
faces: boss.faces
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Fillets (detail features)
|
|
524
|
+
if (analysis.fillets.length > 0) {
|
|
525
|
+
tree.push({
|
|
526
|
+
id: stepId++,
|
|
527
|
+
type: FEATURE_TYPES.FILLET,
|
|
528
|
+
name: 'Fillets',
|
|
529
|
+
description: `Apply fillets with radius ${analysis.fillets[0].radius.toFixed(1)}mm`,
|
|
530
|
+
params: { radius: analysis.fillets[0].radius },
|
|
531
|
+
faces: analysis.fillets.flatMap(f => f.faces)
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Chamfers
|
|
536
|
+
if (analysis.chamfers.length > 0) {
|
|
537
|
+
tree.push({
|
|
538
|
+
id: stepId++,
|
|
539
|
+
type: FEATURE_TYPES.CHAMFER,
|
|
540
|
+
name: 'Chamfers',
|
|
541
|
+
description: `Apply chamfers ${analysis.chamfers[0].size}mm × 45°`,
|
|
542
|
+
params: { size: analysis.chamfers[0].size },
|
|
543
|
+
faces: analysis.chamfers.flatMap(f => f.faces)
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Patterns
|
|
548
|
+
analysis.patterns.forEach((pattern, idx) => {
|
|
549
|
+
tree.push({
|
|
550
|
+
id: stepId++,
|
|
551
|
+
type: FEATURE_TYPES.PATTERN,
|
|
552
|
+
name: `${pattern.baseFeature} Array`,
|
|
553
|
+
description: `Create ${pattern.direction} pattern (${pattern.count} instances)`,
|
|
554
|
+
params: pattern,
|
|
555
|
+
faces: []
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return tree;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ============================================================================
|
|
563
|
+
// ANIMATION CONTROLLER
|
|
564
|
+
// ============================================================================
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Create interactive 3D walkthrough controller
|
|
568
|
+
* @param {THREE.Mesh} mesh - The 3D model
|
|
569
|
+
* @param {Array<Object>} featureTree - Feature tree from reconstructFeatureTree()
|
|
570
|
+
* @returns {Object} Walkthrough controller
|
|
571
|
+
*/
|
|
572
|
+
export function createWalkthrough(mesh, featureTree) {
|
|
573
|
+
const state = {
|
|
574
|
+
currentStep: 0,
|
|
575
|
+
isPlaying: false,
|
|
576
|
+
autoPlayDelay: 3000,
|
|
577
|
+
listeners: []
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const originalMaterial = mesh.material.clone();
|
|
581
|
+
const stepMaterials = {
|
|
582
|
+
highlighted: new THREE.MeshPhongMaterial({ color: 0x58a6ff, emissive: 0x2d5aa0 }),
|
|
583
|
+
dimmed: new THREE.MeshPhongMaterial({ color: 0x404040, opacity: 0.3, transparent: true })
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Emit event to listeners
|
|
588
|
+
*/
|
|
589
|
+
function emit(eventName, data) {
|
|
590
|
+
state.listeners.forEach(listener => {
|
|
591
|
+
if (listener.event === eventName) {
|
|
592
|
+
listener.callback(data);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Highlight step features
|
|
599
|
+
*/
|
|
600
|
+
function highlightStep(stepIndex) {
|
|
601
|
+
if (stepIndex < 0 || stepIndex >= featureTree.length) return;
|
|
602
|
+
|
|
603
|
+
const step = featureTree[stepIndex];
|
|
604
|
+
state.currentStep = stepIndex;
|
|
605
|
+
|
|
606
|
+
// Reset all to original material
|
|
607
|
+
mesh.material = originalMaterial.clone();
|
|
608
|
+
|
|
609
|
+
emit('step-change', {
|
|
610
|
+
step: state.currentStep,
|
|
611
|
+
total: featureTree.length,
|
|
612
|
+
feature: step,
|
|
613
|
+
progress: ((state.currentStep + 1) / featureTree.length) * 100
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Play through steps automatically
|
|
619
|
+
*/
|
|
620
|
+
function play() {
|
|
621
|
+
state.isPlaying = true;
|
|
622
|
+
const playLoop = () => {
|
|
623
|
+
if (!state.isPlaying) return;
|
|
624
|
+
|
|
625
|
+
highlightStep(state.currentStep);
|
|
626
|
+
state.currentStep++;
|
|
627
|
+
|
|
628
|
+
if (state.currentStep >= featureTree.length) {
|
|
629
|
+
state.currentStep = featureTree.length - 1;
|
|
630
|
+
state.isPlaying = false;
|
|
631
|
+
emit('complete', {});
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
setTimeout(playLoop, state.autoPlayDelay);
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
playLoop();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Pause playback
|
|
643
|
+
*/
|
|
644
|
+
function pause() {
|
|
645
|
+
state.isPlaying = false;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Go to next step
|
|
650
|
+
*/
|
|
651
|
+
function next() {
|
|
652
|
+
pause();
|
|
653
|
+
state.currentStep = Math.min(state.currentStep + 1, featureTree.length - 1);
|
|
654
|
+
highlightStep(state.currentStep);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Go to previous step
|
|
659
|
+
*/
|
|
660
|
+
function prev() {
|
|
661
|
+
pause();
|
|
662
|
+
state.currentStep = Math.max(state.currentStep - 1, 0);
|
|
663
|
+
highlightStep(state.currentStep);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Reset to beginning
|
|
668
|
+
*/
|
|
669
|
+
function reset() {
|
|
670
|
+
pause();
|
|
671
|
+
state.currentStep = 0;
|
|
672
|
+
mesh.material = originalMaterial.clone();
|
|
673
|
+
emit('reset', {});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Get current step info
|
|
678
|
+
*/
|
|
679
|
+
function getCurrentStep() {
|
|
680
|
+
return {
|
|
681
|
+
index: state.currentStep,
|
|
682
|
+
total: featureTree.length,
|
|
683
|
+
feature: featureTree[state.currentStep],
|
|
684
|
+
progress: ((state.currentStep + 1) / featureTree.length) * 100
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Listen for events
|
|
690
|
+
*/
|
|
691
|
+
function on(event, callback) {
|
|
692
|
+
state.listeners.push({ event, callback });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Set autoplay delay
|
|
697
|
+
*/
|
|
698
|
+
function setAutoPlayDelay(ms) {
|
|
699
|
+
state.autoPlayDelay = ms;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
play,
|
|
704
|
+
pause,
|
|
705
|
+
next,
|
|
706
|
+
prev,
|
|
707
|
+
reset,
|
|
708
|
+
getCurrentStep,
|
|
709
|
+
on,
|
|
710
|
+
setAutoPlayDelay,
|
|
711
|
+
get isPlaying() { return state.isPlaying; },
|
|
712
|
+
get currentStep() { return state.currentStep; }
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ============================================================================
|
|
717
|
+
// UI PANEL
|
|
718
|
+
// ============================================================================
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Create reverse engineer UI panel
|
|
722
|
+
* @returns {Object} Panel controller
|
|
723
|
+
*/
|
|
724
|
+
export function createReverseEngineerPanel(sceneRef = null) {
|
|
725
|
+
const panelId = 're-panel';
|
|
726
|
+
let panel = document.getElementById(panelId);
|
|
727
|
+
|
|
728
|
+
if (!panel) {
|
|
729
|
+
const styleId = 're-styles';
|
|
730
|
+
if (!document.getElementById(styleId)) {
|
|
731
|
+
const style = document.createElement('style');
|
|
732
|
+
style.id = styleId;
|
|
733
|
+
style.textContent = `
|
|
734
|
+
#${panelId} {
|
|
735
|
+
position: fixed;
|
|
736
|
+
bottom: 20px;
|
|
737
|
+
right: 20px;
|
|
738
|
+
width: 400px;
|
|
739
|
+
max-height: 600px;
|
|
740
|
+
background: var(--bg-secondary, #252526);
|
|
741
|
+
border: 1px solid var(--border-color, #3e3e42);
|
|
742
|
+
border-radius: 8px;
|
|
743
|
+
padding: 16px;
|
|
744
|
+
z-index: 500;
|
|
745
|
+
display: flex;
|
|
746
|
+
flex-direction: column;
|
|
747
|
+
gap: 12px;
|
|
748
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
749
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
750
|
+
color: var(--text-primary, #e0e0e0);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
#${panelId} .re-header {
|
|
754
|
+
display: flex;
|
|
755
|
+
justify-content: space-between;
|
|
756
|
+
align-items: center;
|
|
757
|
+
margin-bottom: 8px;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
#${panelId} .re-title {
|
|
761
|
+
font-size: 14px;
|
|
762
|
+
font-weight: 600;
|
|
763
|
+
color: var(--text-primary, #e0e0e0);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#${panelId} .re-close {
|
|
767
|
+
background: none;
|
|
768
|
+
border: none;
|
|
769
|
+
color: var(--text-primary, #e0e0e0);
|
|
770
|
+
cursor: pointer;
|
|
771
|
+
font-size: 18px;
|
|
772
|
+
padding: 0;
|
|
773
|
+
width: 24px;
|
|
774
|
+
height: 24px;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
#${panelId} .re-close:hover {
|
|
778
|
+
color: var(--accent-blue, #58a6ff);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
#${panelId} .re-dropzone {
|
|
782
|
+
border: 2px dashed var(--accent-blue, #58a6ff);
|
|
783
|
+
border-radius: 6px;
|
|
784
|
+
padding: 20px;
|
|
785
|
+
text-align: center;
|
|
786
|
+
cursor: pointer;
|
|
787
|
+
transition: background 0.2s;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
#${panelId} .re-dropzone:hover,
|
|
791
|
+
#${panelId} .re-dropzone.drag-over {
|
|
792
|
+
background: rgba(88, 166, 255, 0.1);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
#${panelId} .re-dropzone-text {
|
|
796
|
+
font-size: 12px;
|
|
797
|
+
color: var(--accent-blue, #58a6ff);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
#${panelId} input[type="file"] {
|
|
801
|
+
display: none;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
#${panelId} .re-progress {
|
|
805
|
+
width: 100%;
|
|
806
|
+
height: 6px;
|
|
807
|
+
background: var(--bg-primary, #1e1e1e);
|
|
808
|
+
border-radius: 3px;
|
|
809
|
+
overflow: hidden;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#${panelId} .re-progress-bar {
|
|
813
|
+
height: 100%;
|
|
814
|
+
background: var(--accent-blue, #58a6ff);
|
|
815
|
+
width: 0%;
|
|
816
|
+
transition: width 0.3s;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#${panelId} .re-tree {
|
|
820
|
+
max-height: 250px;
|
|
821
|
+
overflow-y: auto;
|
|
822
|
+
border: 1px solid var(--border-color, #3e3e42);
|
|
823
|
+
border-radius: 6px;
|
|
824
|
+
background: var(--bg-primary, #1e1e1e);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
#${panelId} .re-tree-item {
|
|
828
|
+
padding: 8px 12px;
|
|
829
|
+
font-size: 12px;
|
|
830
|
+
cursor: pointer;
|
|
831
|
+
border-bottom: 1px solid var(--border-color, #3e3e42);
|
|
832
|
+
transition: background 0.15s;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
#${panelId} .re-tree-item:hover {
|
|
836
|
+
background: rgba(88, 166, 255, 0.1);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
#${panelId} .re-tree-item.active {
|
|
840
|
+
background: rgba(88, 166, 255, 0.2);
|
|
841
|
+
color: var(--accent-blue, #58a6ff);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
#${panelId} .re-step-counter {
|
|
845
|
+
font-size: 11px;
|
|
846
|
+
color: var(--text-primary, #e0e0e0);
|
|
847
|
+
text-align: center;
|
|
848
|
+
padding: 4px 0;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#${panelId} .re-controls {
|
|
852
|
+
display: flex;
|
|
853
|
+
gap: 8px;
|
|
854
|
+
justify-content: center;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#${panelId} button {
|
|
858
|
+
background: var(--accent-blue, #58a6ff);
|
|
859
|
+
color: white;
|
|
860
|
+
border: none;
|
|
861
|
+
padding: 8px 12px;
|
|
862
|
+
border-radius: 4px;
|
|
863
|
+
cursor: pointer;
|
|
864
|
+
font-size: 12px;
|
|
865
|
+
font-weight: 500;
|
|
866
|
+
transition: background 0.15s;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
#${panelId} button:hover {
|
|
870
|
+
background: #4a95e6;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
#${panelId} button:disabled {
|
|
874
|
+
background: var(--border-color, #3e3e42);
|
|
875
|
+
cursor: not-allowed;
|
|
876
|
+
opacity: 0.5;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
#${panelId} .re-export-btn {
|
|
880
|
+
margin-top: 8px;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
#${panelId} .re-error {
|
|
884
|
+
background: #5a2a2a;
|
|
885
|
+
border: 1px solid #8b4444;
|
|
886
|
+
color: #ff6b6b;
|
|
887
|
+
padding: 8px 12px;
|
|
888
|
+
border-radius: 4px;
|
|
889
|
+
font-size: 12px;
|
|
890
|
+
}
|
|
891
|
+
`;
|
|
892
|
+
document.head.appendChild(style);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
panel = document.createElement('div');
|
|
896
|
+
panel.id = panelId;
|
|
897
|
+
panel.innerHTML = `
|
|
898
|
+
<div class="re-header">
|
|
899
|
+
<div class="re-title">Reverse Engineer</div>
|
|
900
|
+
<button class="re-close" title="Close">✕</button>
|
|
901
|
+
</div>
|
|
902
|
+
<div class="re-dropzone" title="Drop STL file or click to browse">
|
|
903
|
+
<div class="re-dropzone-text">📁 Drag STL here or click</div>
|
|
904
|
+
<input type="file" accept=".stl,.step" />
|
|
905
|
+
</div>
|
|
906
|
+
<div class="re-progress" style="display: none;">
|
|
907
|
+
<div class="re-progress-bar"></div>
|
|
908
|
+
</div>
|
|
909
|
+
<div class="re-tree" style="display: none;"></div>
|
|
910
|
+
<div class="re-step-counter" style="display: none;">Step 0 of 0</div>
|
|
911
|
+
<div class="re-controls" style="display: none;">
|
|
912
|
+
<button class="re-reset-btn">⏮ Reset</button>
|
|
913
|
+
<button class="re-prev-btn">◀ Prev</button>
|
|
914
|
+
<button class="re-play-btn">▶ Play</button>
|
|
915
|
+
<button class="re-next-btn">Next ▶</button>
|
|
916
|
+
</div>
|
|
917
|
+
<button class="re-export-btn" style="display: none;">💾 Export Report</button>
|
|
918
|
+
<div class="re-error" style="display: none;"></div>
|
|
919
|
+
`;
|
|
920
|
+
|
|
921
|
+
document.body.appendChild(panel);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const state = {
|
|
925
|
+
mesh: null,
|
|
926
|
+
featureTree: null,
|
|
927
|
+
walkthrough: null,
|
|
928
|
+
analysis: null
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const elements = {
|
|
932
|
+
dropzone: panel.querySelector('.re-dropzone'),
|
|
933
|
+
fileInput: panel.querySelector('input[type="file"]'),
|
|
934
|
+
progress: panel.querySelector('.re-progress'),
|
|
935
|
+
progressBar: panel.querySelector('.re-progress-bar'),
|
|
936
|
+
tree: panel.querySelector('.re-tree'),
|
|
937
|
+
counter: panel.querySelector('.re-step-counter'),
|
|
938
|
+
controls: panel.querySelector('.re-controls'),
|
|
939
|
+
exportBtn: panel.querySelector('.re-export-btn'),
|
|
940
|
+
errorDiv: panel.querySelector('.re-error'),
|
|
941
|
+
closeBtn: panel.querySelector('.re-close'),
|
|
942
|
+
playBtn: panel.querySelector('.re-play-btn'),
|
|
943
|
+
prevBtn: panel.querySelector('.re-prev-btn'),
|
|
944
|
+
nextBtn: panel.querySelector('.re-next-btn'),
|
|
945
|
+
resetBtn: panel.querySelector('.re-reset-btn')
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Show error message
|
|
950
|
+
*/
|
|
951
|
+
function showError(message) {
|
|
952
|
+
elements.errorDiv.textContent = message;
|
|
953
|
+
elements.errorDiv.style.display = 'block';
|
|
954
|
+
setTimeout(() => {
|
|
955
|
+
elements.errorDiv.style.display = 'none';
|
|
956
|
+
}, 5000);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Handle file import
|
|
961
|
+
*/
|
|
962
|
+
async function handleFileImport(file) {
|
|
963
|
+
elements.progress.style.display = 'block';
|
|
964
|
+
elements.progressBar.style.width = '0%';
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
const updateProgress = (percent) => {
|
|
968
|
+
elements.progressBar.style.width = percent + '%';
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
state.mesh = await importFile(file);
|
|
972
|
+
if (sceneRef) { sceneRef.add(state.mesh); }
|
|
973
|
+
updateProgress(33);
|
|
974
|
+
|
|
975
|
+
state.analysis = analyzeGeometry(state.mesh);
|
|
976
|
+
updateProgress(66);
|
|
977
|
+
|
|
978
|
+
state.featureTree = reconstructFeatureTree(state.analysis);
|
|
979
|
+
updateProgress(100);
|
|
980
|
+
|
|
981
|
+
// Create walkthrough
|
|
982
|
+
state.walkthrough = createWalkthrough(state.mesh, state.featureTree);
|
|
983
|
+
|
|
984
|
+
// Update UI
|
|
985
|
+
populateTree();
|
|
986
|
+
elements.tree.style.display = 'block';
|
|
987
|
+
elements.counter.style.display = 'block';
|
|
988
|
+
elements.controls.style.display = 'flex';
|
|
989
|
+
elements.exportBtn.style.display = 'block';
|
|
990
|
+
|
|
991
|
+
updateCounter();
|
|
992
|
+
|
|
993
|
+
elements.progress.style.display = 'none';
|
|
994
|
+
} catch (error) {
|
|
995
|
+
showError(error.message);
|
|
996
|
+
elements.progress.style.display = 'none';
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Populate feature tree
|
|
1002
|
+
*/
|
|
1003
|
+
function populateTree() {
|
|
1004
|
+
elements.tree.innerHTML = '';
|
|
1005
|
+
|
|
1006
|
+
state.featureTree.forEach((step, idx) => {
|
|
1007
|
+
const item = document.createElement('div');
|
|
1008
|
+
item.className = 're-tree-item';
|
|
1009
|
+
item.textContent = `${FEATURE_ICONS[step.type] || '•'} ${step.name}`;
|
|
1010
|
+
item.addEventListener('click', () => {
|
|
1011
|
+
document.querySelectorAll('#' + panelId + ' .re-tree-item').forEach(el => {
|
|
1012
|
+
el.classList.remove('active');
|
|
1013
|
+
});
|
|
1014
|
+
item.classList.add('active');
|
|
1015
|
+
state.walkthrough.reset();
|
|
1016
|
+
for (let i = 0; i <= idx; i++) {
|
|
1017
|
+
state.walkthrough.next();
|
|
1018
|
+
}
|
|
1019
|
+
updateCounter();
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
elements.tree.appendChild(item);
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Update step counter
|
|
1028
|
+
*/
|
|
1029
|
+
function updateCounter() {
|
|
1030
|
+
if (state.walkthrough) {
|
|
1031
|
+
const current = state.walkthrough.getCurrentStep();
|
|
1032
|
+
elements.counter.textContent = `Step ${current.index + 1} of ${current.total}`;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Export report as HTML
|
|
1038
|
+
*/
|
|
1039
|
+
function exportReport() {
|
|
1040
|
+
if (!state.analysis || !state.featureTree) return;
|
|
1041
|
+
|
|
1042
|
+
const html = generateHTMLReport(state.analysis, state.featureTree);
|
|
1043
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
1044
|
+
const url = URL.createObjectURL(blob);
|
|
1045
|
+
const a = document.createElement('a');
|
|
1046
|
+
a.href = url;
|
|
1047
|
+
a.download = 'reverse-engineer-report.html';
|
|
1048
|
+
a.click();
|
|
1049
|
+
URL.revokeObjectURL(url);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Event listeners
|
|
1053
|
+
elements.closeBtn.addEventListener('click', () => {
|
|
1054
|
+
panel.style.display = 'none';
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
elements.dropzone.addEventListener('click', () => {
|
|
1058
|
+
elements.fileInput.click();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
elements.fileInput.addEventListener('change', (e) => {
|
|
1062
|
+
if (e.target.files.length > 0) {
|
|
1063
|
+
handleFileImport(e.target.files[0]);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
elements.dropzone.addEventListener('dragover', (e) => {
|
|
1068
|
+
e.preventDefault();
|
|
1069
|
+
elements.dropzone.classList.add('drag-over');
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
elements.dropzone.addEventListener('dragleave', () => {
|
|
1073
|
+
elements.dropzone.classList.remove('drag-over');
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
elements.dropzone.addEventListener('drop', (e) => {
|
|
1077
|
+
e.preventDefault();
|
|
1078
|
+
elements.dropzone.classList.remove('drag-over');
|
|
1079
|
+
if (e.dataTransfer.files.length > 0) {
|
|
1080
|
+
handleFileImport(e.dataTransfer.files[0]);
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
elements.playBtn.addEventListener('click', () => {
|
|
1085
|
+
if (state.walkthrough) {
|
|
1086
|
+
if (state.walkthrough.isPlaying) {
|
|
1087
|
+
state.walkthrough.pause();
|
|
1088
|
+
elements.playBtn.textContent = '▶ Play';
|
|
1089
|
+
} else {
|
|
1090
|
+
state.walkthrough.play();
|
|
1091
|
+
elements.playBtn.textContent = '⏸ Pause';
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
elements.prevBtn.addEventListener('click', () => {
|
|
1097
|
+
if (state.walkthrough) {
|
|
1098
|
+
state.walkthrough.prev();
|
|
1099
|
+
updateCounter();
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
elements.nextBtn.addEventListener('click', () => {
|
|
1104
|
+
if (state.walkthrough) {
|
|
1105
|
+
state.walkthrough.next();
|
|
1106
|
+
updateCounter();
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
elements.resetBtn.addEventListener('click', () => {
|
|
1111
|
+
if (state.walkthrough) {
|
|
1112
|
+
state.walkthrough.reset();
|
|
1113
|
+
updateCounter();
|
|
1114
|
+
elements.playBtn.textContent = '▶ Play';
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
elements.exportBtn.addEventListener('click', exportReport);
|
|
1119
|
+
|
|
1120
|
+
return {
|
|
1121
|
+
show() { panel.style.display = 'flex'; },
|
|
1122
|
+
hide() { panel.style.display = 'none'; },
|
|
1123
|
+
toggle() { panel.style.display = panel.style.display === 'none' ? 'flex' : 'none'; }
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ============================================================================
|
|
1128
|
+
// REPORT EXPORT
|
|
1129
|
+
// ============================================================================
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Generate HTML report
|
|
1133
|
+
*/
|
|
1134
|
+
function generateHTMLReport(analysis, featureTree) {
|
|
1135
|
+
const timestamp = new Date().toLocaleString();
|
|
1136
|
+
const rows = featureTree
|
|
1137
|
+
.map(f => `
|
|
1138
|
+
<tr>
|
|
1139
|
+
<td>${f.id}</td>
|
|
1140
|
+
<td>${FEATURE_ICONS[f.type] || '•'}</td>
|
|
1141
|
+
<td>${f.name}</td>
|
|
1142
|
+
<td>${f.type}</td>
|
|
1143
|
+
<td>${f.description}</td>
|
|
1144
|
+
</tr>
|
|
1145
|
+
`)
|
|
1146
|
+
.join('');
|
|
1147
|
+
|
|
1148
|
+
return `
|
|
1149
|
+
<!DOCTYPE html>
|
|
1150
|
+
<html>
|
|
1151
|
+
<head>
|
|
1152
|
+
<meta charset="utf-8">
|
|
1153
|
+
<title>Reverse Engineer Report</title>
|
|
1154
|
+
<style>
|
|
1155
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 20px; background: #f5f5f5; }
|
|
1156
|
+
.container { max-width: 900px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
1157
|
+
h1 { color: #333; border-bottom: 3px solid #58a6ff; padding-bottom: 10px; }
|
|
1158
|
+
h2 { color: #555; margin-top: 30px; }
|
|
1159
|
+
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; background: #f9f9f9; padding: 15px; border-radius: 6px; margin: 15px 0; }
|
|
1160
|
+
.meta-item { font-size: 14px; }
|
|
1161
|
+
.meta-label { font-weight: 600; color: #666; }
|
|
1162
|
+
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
|
1163
|
+
th { background: #58a6ff; color: white; padding: 10px; text-align: left; }
|
|
1164
|
+
td { padding: 10px; border-bottom: 1px solid #ddd; }
|
|
1165
|
+
tr:nth-child(even) { background: #f9f9f9; }
|
|
1166
|
+
.timestamp { font-size: 12px; color: #999; margin-top: 20px; }
|
|
1167
|
+
</style>
|
|
1168
|
+
</head>
|
|
1169
|
+
<body>
|
|
1170
|
+
<div class="container">
|
|
1171
|
+
<h1>🔍 Reverse Engineer Analysis Report</h1>
|
|
1172
|
+
|
|
1173
|
+
<div class="meta">
|
|
1174
|
+
<div class="meta-item">
|
|
1175
|
+
<div class="meta-label">Generated</div>
|
|
1176
|
+
<div>${timestamp}</div>
|
|
1177
|
+
</div>
|
|
1178
|
+
<div class="meta-item">
|
|
1179
|
+
<div class="meta-label">Model Volume</div>
|
|
1180
|
+
<div>${analysis.volume.toFixed(0)} mm³</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
<div class="meta-item">
|
|
1183
|
+
<div class="meta-label">Face Count</div>
|
|
1184
|
+
<div>${analysis.faceCount}</div>
|
|
1185
|
+
</div>
|
|
1186
|
+
<div class="meta-item">
|
|
1187
|
+
<div class="meta-label">Vertex Count</div>
|
|
1188
|
+
<div>${analysis.vertexCount}</div>
|
|
1189
|
+
</div>
|
|
1190
|
+
</div>
|
|
1191
|
+
|
|
1192
|
+
<h2>Part Dimensions</h2>
|
|
1193
|
+
<table>
|
|
1194
|
+
<tr><th>Axis</th><th>Min</th><th>Max</th><th>Size</th></tr>
|
|
1195
|
+
<tr>
|
|
1196
|
+
<td>X</td>
|
|
1197
|
+
<td>${analysis.bbox.min.x.toFixed(1)}</td>
|
|
1198
|
+
<td>${analysis.bbox.max.x.toFixed(1)}</td>
|
|
1199
|
+
<td>${analysis.dimensions.x.toFixed(1)} mm</td>
|
|
1200
|
+
</tr>
|
|
1201
|
+
<tr>
|
|
1202
|
+
<td>Y</td>
|
|
1203
|
+
<td>${analysis.bbox.min.y.toFixed(1)}</td>
|
|
1204
|
+
<td>${analysis.bbox.max.y.toFixed(1)}</td>
|
|
1205
|
+
<td>${analysis.dimensions.y.toFixed(1)} mm</td>
|
|
1206
|
+
</tr>
|
|
1207
|
+
<tr>
|
|
1208
|
+
<td>Z</td>
|
|
1209
|
+
<td>${analysis.bbox.min.z.toFixed(1)}</td>
|
|
1210
|
+
<td>${analysis.bbox.max.z.toFixed(1)}</td>
|
|
1211
|
+
<td>${analysis.dimensions.z.toFixed(1)} mm</td>
|
|
1212
|
+
</tr>
|
|
1213
|
+
</table>
|
|
1214
|
+
|
|
1215
|
+
<h2>Detected Features (${featureTree.length} steps)</h2>
|
|
1216
|
+
<table>
|
|
1217
|
+
<tr><th>#</th><th>Icon</th><th>Name</th><th>Type</th><th>Description</th></tr>
|
|
1218
|
+
${rows}
|
|
1219
|
+
</table>
|
|
1220
|
+
|
|
1221
|
+
<h2>Feature Summary</h2>
|
|
1222
|
+
<table>
|
|
1223
|
+
<tr><th>Feature Type</th><th>Count</th></tr>
|
|
1224
|
+
<tr><td>Holes</td><td>${analysis.holes.length}</td></tr>
|
|
1225
|
+
<tr><td>Pockets</td><td>${analysis.pockets.length}</td></tr>
|
|
1226
|
+
<tr><td>Bosses</td><td>${analysis.bosses.length}</td></tr>
|
|
1227
|
+
<tr><td>Fillets</td><td>${analysis.fillets.length}</td></tr>
|
|
1228
|
+
<tr><td>Chamfers</td><td>${analysis.chamfers.length}</td></tr>
|
|
1229
|
+
<tr><td>Patterns</td><td>${analysis.patterns.length}</td></tr>
|
|
1230
|
+
</table>
|
|
1231
|
+
|
|
1232
|
+
<p class="timestamp">Report generated by cycleCAD Reverse Engineer Tool</p>
|
|
1233
|
+
</div>
|
|
1234
|
+
</body>
|
|
1235
|
+
</html>
|
|
1236
|
+
`;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Export analysis as JSON
|
|
1241
|
+
*/
|
|
1242
|
+
export function exportAnalysisJSON(analysis, featureTree) {
|
|
1243
|
+
const data = {
|
|
1244
|
+
timestamp: new Date().toISOString(),
|
|
1245
|
+
analysis: {
|
|
1246
|
+
dimensions: analysis.dimensions,
|
|
1247
|
+
volume: analysis.volume,
|
|
1248
|
+
faceCount: analysis.faceCount,
|
|
1249
|
+
vertexCount: analysis.vertexCount,
|
|
1250
|
+
holeCount: analysis.holes.length,
|
|
1251
|
+
pocketCount: analysis.pockets.length,
|
|
1252
|
+
bossCount: analysis.bosses.length,
|
|
1253
|
+
filletCount: analysis.fillets.length,
|
|
1254
|
+
chamferCount: analysis.chamfers.length
|
|
1255
|
+
},
|
|
1256
|
+
featureTree
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
return JSON.stringify(data, null, 2);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// ============================================================================
|
|
1263
|
+
// DEFAULT EXPORT
|
|
1264
|
+
// ============================================================================
|
|
1265
|
+
|
|
1266
|
+
export default {
|
|
1267
|
+
importFile,
|
|
1268
|
+
analyzeGeometry,
|
|
1269
|
+
reconstructFeatureTree,
|
|
1270
|
+
createWalkthrough,
|
|
1271
|
+
createReverseEngineerPanel,
|
|
1272
|
+
exportAnalysisJSON,
|
|
1273
|
+
FEATURE_TYPES,
|
|
1274
|
+
FEATURE_ICONS
|
|
1275
|
+
};
|