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.
@@ -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
+ };