cyclecad 0.1.9 → 0.2.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,899 @@
1
+ /**
2
+ * viewer-mode.js - ExplodeView integration for cycleCAD
3
+ *
4
+ * This module enables Viewer Mode: a presentation/inspection mode for loaded assemblies.
5
+ * Shares the Three.js scene with Edit Mode and provides:
6
+ *
7
+ * - Mode switching (Edit ↔ Viewer)
8
+ * - File loading (STL, OBJ, manifest-based assemblies)
9
+ * - Assembly tree navigation
10
+ * - Part selection and highlighting
11
+ * - Explode/collapse animation
12
+ * - Section cut (clipping planes)
13
+ * - Context menu (select, hide, isolate, export)
14
+ * - Part info panel
15
+ * - State management for viewer-specific data
16
+ *
17
+ * Part of the ExplodeView→cycleCAD merge strategy.
18
+ * Imports THREE.js loaders and viewport scene from existing modules.
19
+ */
20
+
21
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
22
+ import { STLLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js';
23
+ import { OBJLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/OBJLoader.js';
24
+ import { GLTFLoader } from 'https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/GLTFLoader.js';
25
+
26
+ /**
27
+ * ============================================================================
28
+ * MODULE STATE
29
+ * ============================================================================
30
+ */
31
+
32
+ let isViewerMode = false;
33
+ let scene = null;
34
+ let camera = null;
35
+ let renderer = null;
36
+ let controls = null;
37
+
38
+ // Root group for all viewer objects (toggled when switching modes)
39
+ let viewerGroup = null;
40
+
41
+ // Viewer state
42
+ const viewerState = {
43
+ isLoading: false,
44
+ allParts: [], // Array of { mesh, name, index, bbox, center }
45
+ assemblies: [], // Array of assembly definitions { name, indices, color }
46
+ manifest: [], // Array of part metadata
47
+ selectedPartIndex: null,
48
+ selectedMesh: null,
49
+ explodeAmount: 0, // 0-1, how far apart to move parts
50
+ explodedPositions: {}, // Cache of original positions
51
+ hoveredMesh: null,
52
+ sectionCutActive: false,
53
+ sectionCutPlane: null,
54
+ clippingPlanes: [],
55
+ annotationPins: [],
56
+ };
57
+
58
+ // Part highlight state
59
+ const partHighlightState = {
60
+ originalColor: {},
61
+ originalOpacity: {},
62
+ highlighted: null,
63
+ };
64
+
65
+ // Configuration
66
+ const config = {
67
+ highlightColor: 0x00ff00,
68
+ selectionColor: 0xffaa00,
69
+ assemblySeparation: 80, // mm to move parts when exploded
70
+ sectionCutThickness: 2, // mm clipping plane thickness
71
+ };
72
+
73
+ /**
74
+ * ============================================================================
75
+ * INITIALIZATION
76
+ * ============================================================================
77
+ */
78
+
79
+ /**
80
+ * Initialize the Viewer Mode system
81
+ * @param {Object} viewportExports - { getScene, getCamera, getRenderer, getControls }
82
+ * @returns {Object} Public API
83
+ */
84
+ export function initViewerMode(viewportExports) {
85
+ scene = viewportExports.getScene();
86
+ camera = viewportExports.getCamera();
87
+ renderer = viewportExports.getRenderer();
88
+ controls = viewportExports.getControls();
89
+
90
+ if (!scene || !camera || !renderer || !controls) {
91
+ throw new Error('initViewerMode: Missing viewport exports');
92
+ }
93
+
94
+ // Create the viewer group to toggle visibility
95
+ viewerGroup = new THREE.Group();
96
+ viewerGroup.name = 'ViewerGroup';
97
+ scene.add(viewerGroup);
98
+
99
+ setupEventListeners();
100
+ setupContextMenu();
101
+
102
+ return {
103
+ toggleViewerMode,
104
+ loadFile,
105
+ getViewerState,
106
+ selectPart,
107
+ explodeParts,
108
+ setSectionCut,
109
+ exportBOM,
110
+ addAnnotationPin,
111
+ isInViewerMode,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * ============================================================================
117
+ * MODE SWITCHING
118
+ * ============================================================================
119
+ */
120
+
121
+ function toggleViewerMode(enable) {
122
+ isViewerMode = enable;
123
+ viewerGroup.visible = enable;
124
+
125
+ const modeBtn = document.getElementById('btn-viewer-mode-toggle');
126
+ if (modeBtn) {
127
+ modeBtn.textContent = isViewerMode ? 'Edit Mode' : 'Viewer Mode';
128
+ modeBtn.style.backgroundColor = isViewerMode ? '#ff6600' : '#333';
129
+ }
130
+
131
+ const rightPanel = document.getElementById('right-panel');
132
+ if (rightPanel) {
133
+ // In viewer mode, show viewer tabs; hide edit tabs
134
+ const viewerTabs = rightPanel.querySelectorAll('[data-viewer-only]');
135
+ const editTabs = rightPanel.querySelectorAll('[data-edit-only]');
136
+
137
+ viewerTabs.forEach(t => t.style.display = isViewerMode ? 'block' : 'none');
138
+ editTabs.forEach(t => t.style.display = !isViewerMode ? 'block' : 'none');
139
+ }
140
+
141
+ if (isViewerMode) {
142
+ // Auto-fit to loaded model
143
+ if (viewerState.allParts.length > 0) {
144
+ fitAllParts();
145
+ }
146
+ }
147
+ }
148
+
149
+ function isInViewerMode() {
150
+ return isViewerMode;
151
+ }
152
+
153
+ /**
154
+ * ============================================================================
155
+ * FILE LOADING
156
+ * ============================================================================
157
+ */
158
+
159
+ /**
160
+ * Load a file (STL, OBJ, glTF, or manifest-based assembly)
161
+ * @param {File|Blob} file - The file to load
162
+ * @param {Object} options - { manifest, assemblies }
163
+ */
164
+ export async function loadFile(file, options = {}) {
165
+ viewerState.isLoading = true;
166
+ clearViewer();
167
+
168
+ try {
169
+ const fileName = file.name.toLowerCase();
170
+ let mesh = null;
171
+
172
+ if (fileName.endsWith('.stl')) {
173
+ mesh = await loadSTL(file);
174
+ } else if (fileName.endsWith('.obj')) {
175
+ mesh = await loadOBJ(file);
176
+ } else if (fileName.endsWith('.gltf') || fileName.endsWith('.glb')) {
177
+ mesh = await loadGLTF(file);
178
+ } else {
179
+ throw new Error(`Unsupported file type: ${fileName}`);
180
+ }
181
+
182
+ if (!mesh) throw new Error('Failed to load model');
183
+
184
+ // If manifest provided, use it to split mesh into parts
185
+ if (options.manifest) {
186
+ processManifestFile(mesh, options.manifest, options.assemblies);
187
+ } else {
188
+ // Single mesh as single part
189
+ addPartToScene(mesh, 'Loaded Model', 0);
190
+ }
191
+
192
+ // Enable viewer mode
193
+ toggleViewerMode(true);
194
+
195
+ updateStatus(`Loaded ${file.name} — ${viewerState.allParts.length} parts`);
196
+ } catch (err) {
197
+ updateStatus(`Error loading file: ${err.message}`, 'error');
198
+ console.error(err);
199
+ } finally {
200
+ viewerState.isLoading = false;
201
+ }
202
+ }
203
+
204
+ async function loadSTL(file) {
205
+ return new Promise((resolve, reject) => {
206
+ const reader = new FileReader();
207
+ reader.onload = (e) => {
208
+ try {
209
+ const loader = new STLLoader();
210
+ const geometry = loader.parse(e.target.result);
211
+ const material = new THREE.MeshStandardMaterial({
212
+ color: 0xcc9955,
213
+ roughness: 0.7,
214
+ metalness: 0.2,
215
+ });
216
+ const mesh = new THREE.Mesh(geometry, material);
217
+ centerGeometry(mesh);
218
+ resolve(mesh);
219
+ } catch (err) {
220
+ reject(err);
221
+ }
222
+ };
223
+ reader.onerror = () => reject(new Error('Failed to read file'));
224
+ reader.readAsArrayBuffer(file);
225
+ });
226
+ }
227
+
228
+ async function loadOBJ(file) {
229
+ return new Promise((resolve, reject) => {
230
+ const reader = new FileReader();
231
+ reader.onload = (e) => {
232
+ try {
233
+ const loader = new OBJLoader();
234
+ const object = loader.parse(e.target.result);
235
+ const material = new THREE.MeshStandardMaterial({
236
+ color: 0xcc9955,
237
+ roughness: 0.7,
238
+ metalness: 0.2,
239
+ });
240
+
241
+ // Convert all geometries in object
242
+ object.traverse((child) => {
243
+ if (child.isGeometry || child.geometry) {
244
+ if (!(child.material instanceof THREE.Material)) {
245
+ child.material = material;
246
+ }
247
+ }
248
+ });
249
+
250
+ centerGeometry(object);
251
+ resolve(object);
252
+ } catch (err) {
253
+ reject(err);
254
+ }
255
+ };
256
+ reader.onerror = () => reject(new Error('Failed to read file'));
257
+ reader.readAsText(file);
258
+ });
259
+ }
260
+
261
+ async function loadGLTF(file) {
262
+ return new Promise((resolve, reject) => {
263
+ const reader = new FileReader();
264
+ reader.onload = (e) => {
265
+ try {
266
+ const loader = new GLTFLoader();
267
+ loader.parse(e.target.result, '', (gltf) => {
268
+ const scene = gltf.scene;
269
+ centerGeometry(scene);
270
+ resolve(scene);
271
+ }, reject);
272
+ } catch (err) {
273
+ reject(err);
274
+ }
275
+ };
276
+ reader.onerror = () => reject(new Error('Failed to read file'));
277
+
278
+ if (file.name.endsWith('.glb')) {
279
+ reader.readAsArrayBuffer(file);
280
+ } else {
281
+ reader.readAsText(file);
282
+ }
283
+ });
284
+ }
285
+
286
+ /**
287
+ * If manifest is provided, build parts array from metadata
288
+ * This matches ExplodeView's structure: array of parts with metadata
289
+ */
290
+ function processManifestFile(mesh, manifest, assemblies) {
291
+ // TODO: Parse manifest.json to extract:
292
+ // - Part names, centers, bounding boxes
293
+ // - Assembly groupings
294
+ // - Load individual part files if URLs provided
295
+ //
296
+ // For now, treat entire mesh as single part
297
+ addPartToScene(mesh, manifest[0]?.name || 'Part', 0);
298
+ }
299
+
300
+ /**
301
+ * ============================================================================
302
+ * SCENE MANAGEMENT
303
+ * ============================================================================
304
+ */
305
+
306
+ function addPartToScene(object, partName, index) {
307
+ const bbox = new THREE.Box3().setFromObject(object);
308
+ const center = bbox.getCenter(new THREE.Vector3());
309
+
310
+ const partData = {
311
+ mesh: object,
312
+ name: partName || `Part ${index}`,
313
+ index: index || viewerState.allParts.length,
314
+ bbox: bbox,
315
+ center: center,
316
+ originalCenter: center.clone(),
317
+ };
318
+
319
+ viewerState.allParts.push(partData);
320
+ viewerGroup.add(object);
321
+
322
+ // Cache original position for explode animation
323
+ if (object.isGroup) {
324
+ object.children.forEach((child) => {
325
+ if (child.position) {
326
+ viewerState.explodedPositions[child.uuid] = child.position.clone();
327
+ }
328
+ });
329
+ } else {
330
+ viewerState.explodedPositions[object.uuid] = object.position.clone();
331
+ }
332
+ }
333
+
334
+ function clearViewer() {
335
+ // Remove all meshes from viewer group
336
+ while (viewerGroup.children.length > 0) {
337
+ viewerGroup.remove(viewerGroup.children[0]);
338
+ }
339
+
340
+ viewerState.allParts = [];
341
+ viewerState.assemblies = [];
342
+ viewerState.selectedPartIndex = null;
343
+ viewerState.selectedMesh = null;
344
+ viewerState.explodeAmount = 0;
345
+ partHighlightState.highlighted = null;
346
+ viewerState.annotationPins = [];
347
+ }
348
+
349
+ function centerGeometry(object) {
350
+ const bbox = new THREE.Box3().setFromObject(object);
351
+ const center = bbox.getCenter(new THREE.Vector3());
352
+
353
+ object.traverse((child) => {
354
+ if (child.position) {
355
+ child.position.sub(center);
356
+ }
357
+ });
358
+ }
359
+
360
+ /**
361
+ * ============================================================================
362
+ * PART SELECTION & HIGHLIGHTING
363
+ * ============================================================================
364
+ */
365
+
366
+ /**
367
+ * Select a part by index
368
+ */
369
+ export function selectPart(partIndex) {
370
+ if (partIndex < 0 || partIndex >= viewerState.allParts.length) {
371
+ return;
372
+ }
373
+
374
+ // Clear previous selection
375
+ if (viewerState.selectedMesh) {
376
+ restorePartColor(viewerState.selectedMesh);
377
+ }
378
+
379
+ const part = viewerState.allParts[partIndex];
380
+ viewerState.selectedPartIndex = partIndex;
381
+ viewerState.selectedMesh = part.mesh;
382
+
383
+ // Highlight selected mesh
384
+ highlightMesh(part.mesh, config.selectionColor, 0.8);
385
+
386
+ // Show part info panel
387
+ showPartInfo(part);
388
+
389
+ updateStatus(`Selected: ${part.name}`);
390
+ }
391
+
392
+ function highlightMesh(mesh, color, opacity) {
393
+ if (!mesh) return;
394
+
395
+ if (mesh.isGroup) {
396
+ mesh.children.forEach((child) => {
397
+ if (child.material) {
398
+ partHighlightState.originalColor[child.uuid] = child.material.color.clone();
399
+ partHighlightState.originalOpacity[child.uuid] = child.material.opacity;
400
+
401
+ child.material.color.setHex(color);
402
+ child.material.opacity = opacity;
403
+ child.material.transparent = true;
404
+ }
405
+ });
406
+ } else if (mesh.material) {
407
+ partHighlightState.originalColor[mesh.uuid] = mesh.material.color.clone();
408
+ partHighlightState.originalOpacity[mesh.uuid] = mesh.material.opacity;
409
+
410
+ mesh.material.color.setHex(color);
411
+ mesh.material.opacity = opacity;
412
+ mesh.material.transparent = true;
413
+ }
414
+ }
415
+
416
+ function restorePartColor(mesh) {
417
+ if (!mesh) return;
418
+
419
+ if (mesh.isGroup) {
420
+ mesh.children.forEach((child) => {
421
+ if (child.material && partHighlightState.originalColor[child.uuid]) {
422
+ child.material.color.copy(partHighlightState.originalColor[child.uuid]);
423
+ child.material.opacity = partHighlightState.originalOpacity[child.uuid];
424
+ }
425
+ });
426
+ } else if (mesh.material && partHighlightState.originalColor[mesh.uuid]) {
427
+ mesh.material.color.copy(partHighlightState.originalColor[mesh.uuid]);
428
+ mesh.material.opacity = partHighlightState.originalOpacity[mesh.uuid];
429
+ }
430
+ }
431
+
432
+ /**
433
+ * ============================================================================
434
+ * EXPLODE ANIMATION
435
+ * ============================================================================
436
+ */
437
+
438
+ /**
439
+ * Animate explode/collapse
440
+ * @param {number} amount - 0 = collapsed, 1 = fully exploded
441
+ */
442
+ export function explodeParts(amount) {
443
+ viewerState.explodeAmount = Math.max(0, Math.min(1, amount));
444
+
445
+ viewerState.allParts.forEach((partData, index) => {
446
+ const mesh = partData.mesh;
447
+ const originalPos = viewerState.explodedPositions[mesh.uuid];
448
+
449
+ if (!originalPos) return;
450
+
451
+ // Calculate displacement direction from center
452
+ const direction = partData.center.clone().normalize();
453
+
454
+ // Interpolate position
455
+ const displacementDistance = config.assemblySeparation * viewerState.explodeAmount;
456
+ const newPos = originalPos.clone().add(direction.multiplyScalar(displacementDistance));
457
+
458
+ mesh.position.copy(newPos);
459
+ });
460
+
461
+ updateStatus(`Explode: ${Math.round(viewerState.explodeAmount * 100)}%`);
462
+ }
463
+
464
+ /**
465
+ * ============================================================================
466
+ * SECTION CUT (CLIPPING PLANE)
467
+ * ============================================================================
468
+ */
469
+
470
+ /**
471
+ * Enable/disable section cut with clipping plane
472
+ * @param {boolean} enabled
473
+ * @param {string} axis - 'x', 'y', 'z'
474
+ * @param {number} position - position of clipping plane
475
+ */
476
+ export function setSectionCut(enabled, axis = 'z', position = 0) {
477
+ viewerState.sectionCutActive = enabled;
478
+
479
+ if (!enabled) {
480
+ // Disable clipping on all materials
481
+ viewerState.allParts.forEach((partData) => {
482
+ const traverse = (obj) => {
483
+ if (obj.material) {
484
+ obj.material.clippingPlanes = [];
485
+ obj.material.clipIntersection = false;
486
+ }
487
+ if (obj.children) {
488
+ obj.children.forEach(traverse);
489
+ }
490
+ };
491
+ traverse(partData.mesh);
492
+ });
493
+ return;
494
+ }
495
+
496
+ // Create clipping plane
497
+ const normal = new THREE.Vector3();
498
+ normal[axis] = 1;
499
+
500
+ const clippingPlane = new THREE.Plane(normal, position);
501
+ renderer.localClippingEnabled = true;
502
+
503
+ // Apply to all materials
504
+ viewerState.allParts.forEach((partData) => {
505
+ const traverse = (obj) => {
506
+ if (obj.material) {
507
+ obj.material.clippingPlanes = [clippingPlane];
508
+ obj.material.clipIntersection = false;
509
+ obj.material.side = THREE.DoubleSide;
510
+ }
511
+ if (obj.children) {
512
+ obj.children.forEach(traverse);
513
+ }
514
+ };
515
+ traverse(partData.mesh);
516
+ });
517
+
518
+ updateStatus(`Section cut active — ${axis.toUpperCase()} axis`);
519
+ }
520
+
521
+ /**
522
+ * ============================================================================
523
+ * PART INFO PANEL
524
+ * ============================================================================
525
+ */
526
+
527
+ function showPartInfo(part) {
528
+ // Calculate bounding box dimensions
529
+ const size = part.bbox.getSize(new THREE.Vector3());
530
+ const volume = size.x * size.y * size.z;
531
+
532
+ // Create info panel if doesn't exist
533
+ let infoPanel = document.getElementById('viewer-part-info-panel');
534
+ if (!infoPanel) {
535
+ infoPanel = document.createElement('div');
536
+ infoPanel.id = 'viewer-part-info-panel';
537
+ infoPanel.style.cssText = `
538
+ position: fixed;
539
+ bottom: 20px;
540
+ right: 20px;
541
+ background: #252526;
542
+ border: 1px solid #3e3e42;
543
+ border-radius: 4px;
544
+ padding: 16px;
545
+ width: 250px;
546
+ max-height: 300px;
547
+ overflow-y: auto;
548
+ color: #e0e0e0;
549
+ font-family: monospace;
550
+ font-size: 12px;
551
+ z-index: 50;
552
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
553
+ `;
554
+ document.body.appendChild(infoPanel);
555
+ }
556
+
557
+ infoPanel.innerHTML = `
558
+ <div style="margin-bottom: 8px; font-weight: bold; color: #58a6ff;">${part.name}</div>
559
+ <div style="margin-bottom: 4px;">Index: ${part.index}</div>
560
+ <div style="margin-bottom: 4px;">Dimensions (mm):</div>
561
+ <div style="margin-bottom: 4px; margin-left: 8px;">X: ${size.x.toFixed(2)}</div>
562
+ <div style="margin-bottom: 4px; margin-left: 8px;">Y: ${size.y.toFixed(2)}</div>
563
+ <div style="margin-bottom: 4px; margin-left: 8px;">Z: ${size.z.toFixed(2)}</div>
564
+ <div style="margin-bottom: 4px;">Volume: ${volume.toFixed(0)} mm³</div>
565
+ <button id="info-close-btn" style="
566
+ margin-top: 8px;
567
+ padding: 4px 8px;
568
+ background: #3e3e42;
569
+ border: 1px solid #58a6ff;
570
+ color: #58a6ff;
571
+ cursor: pointer;
572
+ border-radius: 2px;
573
+ ">Close</button>
574
+ `;
575
+
576
+ document.getElementById('info-close-btn').addEventListener('click', () => {
577
+ infoPanel.style.display = 'none';
578
+ });
579
+ }
580
+
581
+ /**
582
+ * ============================================================================
583
+ * BOM EXPORT
584
+ * ============================================================================
585
+ */
586
+
587
+ /**
588
+ * Export BOM as CSV
589
+ */
590
+ export function exportBOM() {
591
+ if (viewerState.allParts.length === 0) {
592
+ updateStatus('No parts loaded', 'warning');
593
+ return;
594
+ }
595
+
596
+ // Build CSV
597
+ let csv = 'Index,Name,Dimensions (X,Y,Z),Volume (mm³)\n';
598
+
599
+ viewerState.allParts.forEach((part) => {
600
+ const size = part.bbox.getSize(new THREE.Vector3());
601
+ const volume = size.x * size.y * size.z;
602
+ csv += `${part.index},"${part.name}","${size.x.toFixed(2)}, ${size.y.toFixed(2)}, ${size.z.toFixed(2)}",${volume.toFixed(0)}\n`;
603
+ });
604
+
605
+ // Download
606
+ const blob = new Blob([csv], { type: 'text/csv' });
607
+ const url = URL.createObjectURL(blob);
608
+ const a = document.createElement('a');
609
+ a.href = url;
610
+ a.download = 'bom.csv';
611
+ a.click();
612
+ URL.revokeObjectURL(url);
613
+
614
+ updateStatus('BOM exported to bom.csv');
615
+ }
616
+
617
+ /**
618
+ * ============================================================================
619
+ * ANNOTATIONS
620
+ * ============================================================================
621
+ */
622
+
623
+ /**
624
+ * Add an annotation pin at world position
625
+ * @param {THREE.Vector3} position - World position for pin
626
+ * @param {string} text - Annotation text
627
+ */
628
+ export function addAnnotationPin(position, text) {
629
+ // Create simple sphere pin
630
+ const geometry = new THREE.SphereGeometry(5, 8, 8);
631
+ const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
632
+ const pin = new THREE.Mesh(geometry, material);
633
+
634
+ pin.position.copy(position);
635
+ pin.userData = { text, isAnnotationPin: true };
636
+
637
+ viewerGroup.add(pin);
638
+ viewerState.annotationPins.push(pin);
639
+
640
+ updateStatus(`Added annotation: ${text}`);
641
+ }
642
+
643
+ /**
644
+ * ============================================================================
645
+ * CONTEXT MENU
646
+ * ============================================================================
647
+ */
648
+
649
+ function setupContextMenu() {
650
+ // Create context menu HTML
651
+ let contextMenu = document.getElementById('viewer-context-menu');
652
+ if (!contextMenu) {
653
+ contextMenu = document.createElement('div');
654
+ contextMenu.id = 'viewer-context-menu';
655
+ contextMenu.style.cssText = `
656
+ position: fixed;
657
+ background: #2d2d30;
658
+ border: 1px solid #3e3e42;
659
+ border-radius: 4px;
660
+ min-width: 150px;
661
+ z-index: 1000;
662
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
663
+ display: none;
664
+ `;
665
+ document.body.appendChild(contextMenu);
666
+ }
667
+
668
+ // Right-click handler on renderer
669
+ renderer.domElement.addEventListener('contextmenu', (e) => {
670
+ if (!isViewerMode) return;
671
+
672
+ e.preventDefault();
673
+
674
+ // Raycast to find part under cursor
675
+ const raycaster = new THREE.Raycaster();
676
+ const mouse = new THREE.Vector2();
677
+ mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
678
+ mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
679
+
680
+ raycaster.setFromCamera(mouse, camera);
681
+ const intersects = raycaster.intersectObjects(viewerGroup.children, true);
682
+
683
+ if (intersects.length === 0) return;
684
+
685
+ const clickedObject = intersects[0].object;
686
+ const partIndex = findPartIndex(clickedObject);
687
+
688
+ if (partIndex === -1) return;
689
+
690
+ // Show context menu
691
+ contextMenu.innerHTML = `
692
+ <div style="padding: 0;">
693
+ <button class="context-menu-item" data-action="select">Select</button>
694
+ <button class="context-menu-item" data-action="hide">Hide</button>
695
+ <button class="context-menu-item" data-action="isolate">Isolate</button>
696
+ <button class="context-menu-item" data-action="export">Export STL</button>
697
+ <button class="context-menu-item" data-action="info">Part Info</button>
698
+ </div>
699
+ `;
700
+
701
+ // Style items
702
+ contextMenu.querySelectorAll('.context-menu-item').forEach((btn) => {
703
+ btn.style.cssText = `
704
+ display: block;
705
+ width: 100%;
706
+ padding: 8px 12px;
707
+ border: none;
708
+ background: transparent;
709
+ color: #e0e0e0;
710
+ cursor: pointer;
711
+ text-align: left;
712
+ font-size: 12px;
713
+ `;
714
+ btn.addEventListener('mouseover', () => {
715
+ btn.style.background = '#3e3e42';
716
+ });
717
+ btn.addEventListener('mouseout', () => {
718
+ btn.style.background = 'transparent';
719
+ });
720
+ btn.addEventListener('click', () => {
721
+ handleContextMenuAction(btn.dataset.action, partIndex);
722
+ contextMenu.style.display = 'none';
723
+ });
724
+ });
725
+
726
+ contextMenu.style.display = 'block';
727
+ contextMenu.style.left = e.clientX + 'px';
728
+ contextMenu.style.top = e.clientY + 'px';
729
+ });
730
+
731
+ // Hide menu on click elsewhere
732
+ document.addEventListener('click', () => {
733
+ contextMenu.style.display = 'none';
734
+ });
735
+ }
736
+
737
+ function handleContextMenuAction(action, partIndex) {
738
+ const part = viewerState.allParts[partIndex];
739
+ if (!part) return;
740
+
741
+ switch (action) {
742
+ case 'select':
743
+ selectPart(partIndex);
744
+ break;
745
+ case 'hide':
746
+ part.mesh.visible = false;
747
+ updateStatus(`Hidden: ${part.name}`);
748
+ break;
749
+ case 'isolate':
750
+ viewerState.allParts.forEach((p, i) => {
751
+ p.mesh.visible = (i === partIndex);
752
+ });
753
+ updateStatus(`Isolated: ${part.name}`);
754
+ break;
755
+ case 'export':
756
+ exportPartSTL(part);
757
+ break;
758
+ case 'info':
759
+ showPartInfo(part);
760
+ break;
761
+ }
762
+ }
763
+
764
+ function findPartIndex(object) {
765
+ for (let i = 0; i < viewerState.allParts.length; i++) {
766
+ const part = viewerState.allParts[i];
767
+ if (part.mesh === object || part.mesh.children.includes(object)) {
768
+ return i;
769
+ }
770
+ }
771
+ return -1;
772
+ }
773
+
774
+ function exportPartSTL(part) {
775
+ // Stub: Would export mesh to STL using Three.js STL exporter
776
+ updateStatus(`Exporting ${part.name} to STL... (stub)`);
777
+ }
778
+
779
+ /**
780
+ * ============================================================================
781
+ * EVENT LISTENERS
782
+ * ============================================================================
783
+ */
784
+
785
+ function setupEventListeners() {
786
+ // Explode slider
787
+ const explodeSlider = document.getElementById('viewer-explode-slider');
788
+ if (explodeSlider) {
789
+ explodeSlider.addEventListener('input', (e) => {
790
+ const amount = parseFloat(e.target.value);
791
+ explodeParts(amount);
792
+ });
793
+ }
794
+
795
+ // Section cut toggle
796
+ const sectionToggle = document.getElementById('viewer-section-cut-toggle');
797
+ if (sectionToggle) {
798
+ sectionToggle.addEventListener('change', (e) => {
799
+ setSectionCut(e.target.checked);
800
+ });
801
+ }
802
+
803
+ // BOM export button
804
+ const bomBtn = document.getElementById('viewer-bom-export-btn');
805
+ if (bomBtn) {
806
+ bomBtn.addEventListener('click', () => {
807
+ exportBOM();
808
+ });
809
+ }
810
+
811
+ // Mode toggle button
812
+ const modeBtn = document.getElementById('btn-viewer-mode-toggle');
813
+ if (modeBtn) {
814
+ modeBtn.addEventListener('click', () => {
815
+ toggleViewerMode(!isViewerMode);
816
+ });
817
+ }
818
+ }
819
+
820
+ /**
821
+ * ============================================================================
822
+ * UTILITIES
823
+ * ============================================================================
824
+ */
825
+
826
+ function fitAllParts() {
827
+ if (viewerState.allParts.length === 0) return;
828
+
829
+ // Calculate overall bounding box
830
+ const bbox = new THREE.Box3();
831
+ viewerState.allParts.forEach((part) => {
832
+ bbox.expandByObject(part.mesh);
833
+ });
834
+
835
+ const center = bbox.getCenter(new THREE.Vector3());
836
+ const size = bbox.getSize(new THREE.Vector3());
837
+ const maxDim = Math.max(size.x, size.y, size.z);
838
+
839
+ // Set camera to frame all parts
840
+ const distance = maxDim / (2 * Math.tan((camera.fov * Math.PI / 180) / 2));
841
+
842
+ camera.position.set(
843
+ center.x + distance * 0.5,
844
+ center.y + distance * 0.3,
845
+ center.z + distance * 0.7,
846
+ );
847
+ camera.lookAt(center);
848
+ controls.target.copy(center);
849
+ controls.update();
850
+ }
851
+
852
+ function updateStatus(message, type = 'info') {
853
+ let statusBar = document.getElementById('status-bar');
854
+ if (!statusBar) {
855
+ statusBar = document.createElement('div');
856
+ statusBar.id = 'status-bar';
857
+ statusBar.style.cssText = `
858
+ position: fixed;
859
+ bottom: 0;
860
+ left: 0;
861
+ right: 0;
862
+ height: 36px;
863
+ background: #1e1e1e;
864
+ border-top: 1px solid #3e3e42;
865
+ display: flex;
866
+ align-items: center;
867
+ padding: 0 16px;
868
+ color: #a0a0a0;
869
+ font-size: 12px;
870
+ z-index: 40;
871
+ `;
872
+ document.body.appendChild(statusBar);
873
+ }
874
+
875
+ statusBar.textContent = message;
876
+ statusBar.style.color = type === 'error' ? '#f85149' : type === 'warning' ? '#d29922' : '#a0a0a0';
877
+ }
878
+
879
+ /**
880
+ * ============================================================================
881
+ * PUBLIC API GETTERS
882
+ * ============================================================================
883
+ */
884
+
885
+ export function getViewerState() {
886
+ return viewerState;
887
+ }
888
+
889
+ // Expose key functions globally for debugging
890
+ window.ViewerMode = {
891
+ loadFile,
892
+ selectPart,
893
+ explodeParts,
894
+ setSectionCut,
895
+ exportBOM,
896
+ addAnnotationPin,
897
+ toggleViewerMode,
898
+ getViewerState,
899
+ };