cyclecad 0.2.3 → 0.3.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,917 @@
1
+ // CAD-to-VR WebXR Module for cycleCAD
2
+ // IIFE — registers as window.cycleCAD.cadVR
3
+ // Inspired by CAD2VR, CADDY on Meta Quest, SimLab VR
4
+ // Requires Three.js r170 + navigator.xr support
5
+
6
+ (function cadVRModule() {
7
+ 'use strict';
8
+
9
+ // ============================================================================
10
+ // VR Session State
11
+ // ============================================================================
12
+ const VRState = {
13
+ isActive: false,
14
+ session: null,
15
+ supported: false,
16
+ mode: 'inspect', // inspect|explode|cross-section|scale|measure|annotate
17
+ controllers: { left: null, right: null },
18
+ inputSources: [],
19
+ referenceSpace: null,
20
+ vrScene: null,
21
+ vrCamera: null,
22
+ explosionFactor: 0,
23
+ measurePoints: [],
24
+ annotations: [],
25
+ multiUserRoom: null,
26
+ avatars: new Map(),
27
+ sessionStartTime: null,
28
+ isRecording: false,
29
+ };
30
+
31
+ // ============================================================================
32
+ // Comfort & Accessibility Settings
33
+ // ============================================================================
34
+ const ComfortSettings = {
35
+ locomotionType: 'teleport', // teleport|smooth
36
+ snapTurnAngle: 30, // degrees
37
+ enableVignette: true,
38
+ vignetteAlpha: 0.7,
39
+ worldScale: 1.0,
40
+ seatedMode: false,
41
+ highlightColor: 0xa855f7, // purple
42
+ controllerRayColor: 0x58a6ff, // blue
43
+ };
44
+
45
+ // ============================================================================
46
+ // WebXR Support Detection
47
+ // ============================================================================
48
+ function isVRSupported() {
49
+ return !!(navigator?.xr?.isSessionSupported?.('immersive-vr'));
50
+ }
51
+
52
+ async function detectHeadsets() {
53
+ const headsets = [];
54
+ const profiles = [
55
+ { name: 'Meta Quest 3', id: 'oculus-touch-v3' },
56
+ { name: 'Meta Quest Pro', id: 'oculus-touch-v4' },
57
+ { name: 'Meta Quest 2', id: 'oculus-touch' },
58
+ { name: 'Pico 4', id: 'pico-touch' },
59
+ { name: 'Valve Index', id: 'valve-index' },
60
+ { name: 'HTC Vive', id: 'vive' },
61
+ { name: 'Windows Mixed Reality', id: 'windows-mixed-reality' },
62
+ ];
63
+
64
+ if (navigator?.xr?.inputProfiles) {
65
+ const supported = [];
66
+ for (const profile of profiles) {
67
+ try {
68
+ const isSupported = await navigator.xr.isSessionSupported('immersive-vr');
69
+ if (isSupported) supported.push(profile.name);
70
+ } catch (e) {
71
+ // silent
72
+ }
73
+ }
74
+ return supported.length > 0 ? supported : ['Generic WebXR Headset'];
75
+ }
76
+ return [];
77
+ }
78
+
79
+ function getVRStatus() {
80
+ return {
81
+ supported: VRState.supported,
82
+ active: VRState.isActive,
83
+ mode: VRState.mode,
84
+ headset: VRState.session?.inputSources?.[0]?.handedness ? 'Detected' : 'Not Detected',
85
+ controllers: {
86
+ left: !!VRState.controllers.left,
87
+ right: !!VRState.controllers.right,
88
+ },
89
+ };
90
+ }
91
+
92
+ // ============================================================================
93
+ // VR Scene Initialization
94
+ // ============================================================================
95
+ function initVRScene() {
96
+ const mainScene = window._scene;
97
+ const mainCamera = window._camera;
98
+ const renderer = window._renderer;
99
+
100
+ // Clone scene for VR
101
+ VRState.vrScene = mainScene.clone();
102
+
103
+ // Ensure camera is in VR scene
104
+ VRState.vrCamera = mainCamera;
105
+ if (!VRState.vrScene.children.includes(VRState.vrCamera)) {
106
+ VRState.vrScene.add(VRState.vrCamera);
107
+ }
108
+
109
+ // Add VR-specific lighting
110
+ const ambientLight = new THREE.AmbientLight(0xffffff, 1.2);
111
+ VRState.vrScene.add(ambientLight);
112
+
113
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
114
+ directionalLight.position.set(10, 20, 10);
115
+ VRState.vrScene.add(directionalLight);
116
+
117
+ // Add ground plane
118
+ const groundGeometry = new THREE.PlaneGeometry(50, 50);
119
+ const groundMaterial = new THREE.MeshStandardMaterial({
120
+ color: 0x2a2a2a,
121
+ metalness: 0.3,
122
+ roughness: 0.7,
123
+ });
124
+ const ground = new THREE.Mesh(groundGeometry, groundMaterial);
125
+ ground.rotation.x = -Math.PI / 2;
126
+ ground.position.y = -5;
127
+ ground.receiveShadow = true;
128
+ VRState.vrScene.add(ground);
129
+
130
+ // Add grid visualization
131
+ const gridHelper = new THREE.GridHelper(50, 50, 0x444444, 0x222222);
132
+ gridHelper.position.y = -4.9;
133
+ VRState.vrScene.add(gridHelper);
134
+
135
+ // Add sky/environment
136
+ const skyGeometry = new THREE.SphereGeometry(500, 32, 32);
137
+ const skyMaterial = new THREE.MeshBasicMaterial({
138
+ color: 0x87ceeb,
139
+ side: THREE.BackSide,
140
+ });
141
+ const sky = new THREE.Mesh(skyGeometry, skyMaterial);
142
+ VRState.vrScene.add(sky);
143
+
144
+ console.log('[VR] Scene initialized');
145
+ }
146
+
147
+ // ============================================================================
148
+ // Controller Setup & Input Handling
149
+ // ============================================================================
150
+ function initControllers(session) {
151
+ const renderer = window._renderer;
152
+
153
+ // Controller 1 (Right hand)
154
+ const controller1 = renderer.xr.getController(0);
155
+ const geometry = new THREE.BufferGeometry().setFromPoints([
156
+ new THREE.Vector3(0, 0, 0),
157
+ new THREE.Vector3(0, 0, -10),
158
+ ]);
159
+ const line = new THREE.Line(
160
+ geometry,
161
+ new THREE.LineBasicMaterial({ color: ComfortSettings.controllerRayColor, linewidth: 2 })
162
+ );
163
+ line.name = 'ray';
164
+ controller1.add(line);
165
+
166
+ const pointer = new THREE.Mesh(
167
+ new THREE.SphereGeometry(0.1, 8, 8),
168
+ new THREE.MeshBasicMaterial({ color: ComfortSettings.controllerRayColor })
169
+ );
170
+ pointer.position.z = -10;
171
+ controller1.add(pointer);
172
+
173
+ controller1.addEventListener('select', onControllerSelect);
174
+ controller1.addEventListener('selectstart', onControllerSelectStart);
175
+ controller1.addEventListener('selectend', onControllerSelectEnd);
176
+ controller1.addEventListener('squeezestart', onControllerSqueezeStart);
177
+ controller1.addEventListener('squeeze', onControllerSqueeze);
178
+ controller1.addEventListener('squeezeend', onControllerSqueezeEnd);
179
+
180
+ VRState.controllers.right = controller1;
181
+ VRState.vrScene.add(controller1);
182
+
183
+ // Controller 2 (Left hand)
184
+ const controller2 = renderer.xr.getController(1);
185
+ const geometry2 = new THREE.BufferGeometry().setFromPoints([
186
+ new THREE.Vector3(0, 0, 0),
187
+ new THREE.Vector3(0, 0, -10),
188
+ ]);
189
+ const line2 = new THREE.Line(
190
+ geometry2,
191
+ new THREE.LineBasicMaterial({ color: ComfortSettings.controllerRayColor, linewidth: 2 })
192
+ );
193
+ line2.name = 'ray';
194
+ controller2.add(line2);
195
+
196
+ const pointer2 = new THREE.Mesh(
197
+ new THREE.SphereGeometry(0.1, 8, 8),
198
+ new THREE.MeshBasicMaterial({ color: ComfortSettings.controllerRayColor })
199
+ );
200
+ pointer2.position.z = -10;
201
+ controller2.add(pointer2);
202
+
203
+ controller2.addEventListener('select', onControllerSelect);
204
+ controller2.addEventListener('squeezestart', onControllerSqueezeStart);
205
+ controller2.addEventListener('squeeze', onControllerSqueeze);
206
+ controller2.addEventListener('squeezeend', onControllerSqueezeEnd);
207
+
208
+ VRState.controllers.left = controller2;
209
+ VRState.vrScene.add(controller2);
210
+
211
+ console.log('[VR] Controllers initialized');
212
+ }
213
+
214
+ function onControllerSelect(event) {
215
+ const controller = event.target;
216
+ handleModeInteraction('select', controller);
217
+ }
218
+
219
+ function onControllerSelectStart(event) {
220
+ const controller = event.target;
221
+ if (VRState.mode === 'measure') {
222
+ handleMeasurePoint(controller);
223
+ }
224
+ }
225
+
226
+ function onControllerSelectEnd(event) {
227
+ // End of selection
228
+ }
229
+
230
+ function onControllerSqueezeStart(event) {
231
+ const controller = event.target;
232
+ if (VRState.mode === 'scale') {
233
+ // Start pinch gesture for scaling
234
+ }
235
+ }
236
+
237
+ function onControllerSqueeze(event) {
238
+ const controller = event.target;
239
+ if (VRState.mode === 'cross-section') {
240
+ handleCrossSection(controller);
241
+ }
242
+ }
243
+
244
+ function onControllerSqueezeEnd(event) {
245
+ // End squeeze
246
+ }
247
+
248
+ function handleModeInteraction(interaction, controller) {
249
+ const mode = VRState.mode;
250
+
251
+ if (mode === 'inspect') {
252
+ // Highlight and show part info
253
+ console.log('[VR] Inspect mode: selecting part at', controller.position);
254
+ } else if (mode === 'explode') {
255
+ // Adjust explosion with thumbstick
256
+ console.log('[VR] Explode mode: adjusting explosion');
257
+ } else if (mode === 'annotate') {
258
+ // Place annotation at controller position
259
+ placeAnnotation(controller.position, controller.quaternion);
260
+ } else if (mode === 'measure') {
261
+ // Place measurement point
262
+ console.log('[VR] Measure mode: recording point');
263
+ }
264
+ }
265
+
266
+ function handleMeasurePoint(controller) {
267
+ const point = new THREE.Vector3();
268
+ controller.getWorldPosition(point);
269
+ VRState.measurePoints.push(point.clone());
270
+
271
+ if (VRState.measurePoints.length === 2) {
272
+ const distance = VRState.measurePoints[0].distanceTo(VRState.measurePoints[1]);
273
+ console.log(`[VR] Measurement: ${distance.toFixed(3)} units`);
274
+ showMeasurementLabel(
275
+ VRState.measurePoints[0],
276
+ VRState.measurePoints[1],
277
+ distance
278
+ );
279
+ }
280
+
281
+ if (VRState.measurePoints.length >= 3) {
282
+ // Angle measurement
283
+ const p1 = VRState.measurePoints[0];
284
+ const p2 = VRState.measurePoints[1];
285
+ const p3 = VRState.measurePoints[2];
286
+ const v1 = new THREE.Vector3().subVectors(p1, p2);
287
+ const v2 = new THREE.Vector3().subVectors(p3, p2);
288
+ const angle = Math.acos(v1.normalize().dot(v2.normalize()));
289
+ console.log(`[VR] Angle: ${THREE.MathUtils.radToDeg(angle).toFixed(1)}°`);
290
+ VRState.measurePoints = [];
291
+ }
292
+ }
293
+
294
+ function handleCrossSection(controller) {
295
+ const position = new THREE.Vector3();
296
+ controller.getWorldPosition(position);
297
+ console.log('[VR] Cross-section plane at:', position);
298
+ // Would implement clipping plane update here
299
+ }
300
+
301
+ function showMeasurementLabel(p1, p2, distance) {
302
+ const midpoint = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
303
+ const canvas = document.createElement('canvas');
304
+ canvas.width = 256;
305
+ canvas.height = 128;
306
+ const ctx = canvas.getContext('2d');
307
+ ctx.fillStyle = '#1e1e1e';
308
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
309
+ ctx.fillStyle = '#a855f7';
310
+ ctx.font = 'bold 32px Arial';
311
+ ctx.textAlign = 'center';
312
+ ctx.fillText(`${distance.toFixed(2)}`, canvas.width / 2, canvas.height / 2);
313
+
314
+ const texture = new THREE.CanvasTexture(canvas);
315
+ const material = new THREE.MeshBasicMaterial({ map: texture });
316
+ const geometry = new THREE.PlaneGeometry(2, 1);
317
+ const mesh = new THREE.Mesh(geometry, material);
318
+ mesh.position.copy(midpoint);
319
+ mesh.userData.temporary = true;
320
+ mesh.userData.createdAt = Date.now();
321
+ VRState.vrScene.add(mesh);
322
+
323
+ // Auto-remove after 3 seconds
324
+ setTimeout(() => VRState.vrScene.remove(mesh), 3000);
325
+ }
326
+
327
+ function placeAnnotation(position, quaternion) {
328
+ console.log('[VR] Annotation placed at', position);
329
+ const annotation = {
330
+ id: 'ann_' + Date.now(),
331
+ position: position.clone(),
332
+ quaternion: quaternion.clone(),
333
+ text: 'New annotation',
334
+ timestamp: Date.now(),
335
+ };
336
+ VRState.annotations.push(annotation);
337
+ }
338
+
339
+ // ============================================================================
340
+ // VR Mode Management
341
+ // ============================================================================
342
+ function setMode(newMode) {
343
+ const validModes = ['inspect', 'explode', 'cross-section', 'scale', 'measure', 'annotate'];
344
+ if (!validModes.includes(newMode)) {
345
+ console.error('[VR] Invalid mode:', newMode);
346
+ return;
347
+ }
348
+
349
+ VRState.mode = newMode;
350
+ VRState.measurePoints = [];
351
+
352
+ console.log(`[VR] Mode changed to: ${newMode}`);
353
+
354
+ // Broadcast to listeners
355
+ window.dispatchEvent(new CustomEvent('vr-mode-changed', { detail: { mode: newMode } }));
356
+ }
357
+
358
+ // ============================================================================
359
+ // Multi-User VR (Collaboration)
360
+ // ============================================================================
361
+ async function createRoom(roomId) {
362
+ console.log(`[VR] Creating room: ${roomId}`);
363
+ VRState.multiUserRoom = {
364
+ id: roomId,
365
+ created: Date.now(),
366
+ users: new Map(),
367
+ };
368
+ // Placeholder: in production, would establish WebRTC connection
369
+ return roomId;
370
+ }
371
+
372
+ async function joinRoom(roomId) {
373
+ console.log(`[VR] Joining room: ${roomId}`);
374
+ VRState.multiUserRoom = {
375
+ id: roomId,
376
+ joined: Date.now(),
377
+ users: new Map(),
378
+ };
379
+ // Placeholder: in production, would connect to existing session
380
+ return true;
381
+ }
382
+
383
+ function leaveRoom() {
384
+ if (!VRState.multiUserRoom) return;
385
+ console.log(`[VR] Leaving room: ${VRState.multiUserRoom.id}`);
386
+ VRState.avatars.clear();
387
+ VRState.multiUserRoom = null;
388
+ }
389
+
390
+ // ============================================================================
391
+ // VR Session Management
392
+ // ============================================================================
393
+ async function enterVR() {
394
+ if (!VRState.supported) {
395
+ console.error('[VR] WebXR VR not supported');
396
+ alert('WebXR immersive-vr is not supported on this device.');
397
+ return false;
398
+ }
399
+
400
+ if (VRState.isActive) {
401
+ console.warn('[VR] Already in VR session');
402
+ return false;
403
+ }
404
+
405
+ try {
406
+ initVRScene();
407
+
408
+ const session = await navigator.xr.requestSession('immersive-vr', {
409
+ requiredFeatures: ['local-floor', 'hand-tracking'],
410
+ optionalFeatures: ['layers'],
411
+ });
412
+
413
+ VRState.session = session;
414
+ VRState.isActive = true;
415
+ VRState.sessionStartTime = Date.now();
416
+
417
+ const renderer = window._renderer;
418
+ renderer.xr.setSession(session);
419
+
420
+ // Set reference space
421
+ const referenceSpace = await session.requestReferenceSpace('local-floor');
422
+ VRState.referenceSpace = referenceSpace;
423
+
424
+ initControllers(session);
425
+
426
+ // Listen for input sources
427
+ session.addEventListener('inputsourceschange', onInputSourcesChange);
428
+ session.addEventListener('end', exitVR);
429
+
430
+ console.log('[VR] VR session started');
431
+ window.dispatchEvent(new CustomEvent('vr-session-started', { detail: VRState }));
432
+
433
+ return true;
434
+ } catch (err) {
435
+ console.error('[VR] Failed to enter VR:', err);
436
+ return false;
437
+ }
438
+ }
439
+
440
+ async function exitVR() {
441
+ if (!VRState.session) return;
442
+
443
+ try {
444
+ VRState.isActive = false;
445
+
446
+ if (VRState.session.end) {
447
+ await VRState.session.end();
448
+ }
449
+
450
+ const renderer = window._renderer;
451
+ renderer.xr.setSession(null);
452
+
453
+ // Restore main scene
454
+ if (window._scene) {
455
+ renderer.setRenderTarget(null);
456
+ window._scene.traverseVisible((obj) => {
457
+ if (obj.material) obj.material.needsUpdate = true;
458
+ });
459
+ }
460
+
461
+ VRState.session = null;
462
+ VRState.controllers = { left: null, right: null };
463
+
464
+ console.log('[VR] VR session ended');
465
+ window.dispatchEvent(new CustomEvent('vr-session-ended', { detail: VRState }));
466
+
467
+ return true;
468
+ } catch (err) {
469
+ console.error('[VR] Error exiting VR:', err);
470
+ return false;
471
+ }
472
+ }
473
+
474
+ function onInputSourcesChange(event) {
475
+ console.log('[VR] Input sources changed', event.added.length, 'added,', event.removed.length, 'removed');
476
+ }
477
+
478
+ // ============================================================================
479
+ // Explosion/Collapse (Assembly Mode)
480
+ // ============================================================================
481
+ function setExplosionFactor(factor) {
482
+ factor = Math.max(0, Math.min(1, factor)); // Clamp 0-1
483
+ VRState.explosionFactor = factor;
484
+
485
+ if (!window.ASSEMBLIES || !window.allParts) return;
486
+
487
+ window.ASSEMBLIES.forEach((asm, asmIdx) => {
488
+ const [startIdx, count] = asm.indices;
489
+ for (let i = 0; i < count; i++) {
490
+ const part = window.allParts[startIdx + i];
491
+ if (part && part.mesh) {
492
+ const direction = new THREE.Vector3(
493
+ Math.sin(asmIdx) * 2,
494
+ 1,
495
+ Math.cos(asmIdx) * 2
496
+ ).normalize();
497
+ part.mesh.position.copy(part._originalPosition || part.mesh.position);
498
+ part.mesh.position.addScaledVector(direction, factor * 10);
499
+ }
500
+ }
501
+ });
502
+
503
+ window.dispatchEvent(new CustomEvent('vr-explosion-changed', { detail: { factor } }));
504
+ }
505
+
506
+ // ============================================================================
507
+ // Export & Recording
508
+ // ============================================================================
509
+ function captureVRScreenshot() {
510
+ const renderer = window._renderer;
511
+ const canvas = renderer.domElement;
512
+ const dataURL = canvas.toDataURL('image/png');
513
+ const link = document.createElement('a');
514
+ link.href = dataURL;
515
+ link.download = `vr-screenshot-${Date.now()}.png`;
516
+ link.click();
517
+ console.log('[VR] Screenshot captured');
518
+ }
519
+
520
+ async function recordVRSession(duration = 30) {
521
+ if (VRState.isRecording) {
522
+ console.warn('[VR] Already recording');
523
+ return;
524
+ }
525
+
526
+ VRState.isRecording = true;
527
+ console.log(`[VR] Recording for ${duration} seconds...`);
528
+
529
+ // Placeholder: in production would use MediaRecorder on canvas stream
530
+ setTimeout(() => {
531
+ VRState.isRecording = false;
532
+ console.log('[VR] Recording completed');
533
+ }, duration * 1000);
534
+ }
535
+
536
+ function shareVRLink(modelId) {
537
+ const url = new URL(window.location);
538
+ url.hash = `vr=1&model=${modelId}`;
539
+ const link = url.toString();
540
+ console.log('[VR] Share link:', link);
541
+ return link;
542
+ }
543
+
544
+ // ============================================================================
545
+ // UI Panel HTML
546
+ // ============================================================================
547
+ function getUI() {
548
+ const status = getVRStatus();
549
+ const supportedHeadsets = status.supported ? 'Meta Quest 3, Meta Quest Pro, Pico 4, Valve Index' : 'None';
550
+
551
+ return `
552
+ <div id="vr-panel" style="
553
+ background: #1e1e1e;
554
+ border: 1px solid #a855f7;
555
+ border-radius: 8px;
556
+ padding: 16px;
557
+ font-family: 'Segoe UI', Tahoma, sans-serif;
558
+ color: #e0e0e0;
559
+ max-height: 600px;
560
+ overflow-y: auto;
561
+ ">
562
+ <h3 style="margin-top: 0; color: #a855f7; border-bottom: 2px solid #a855f7; padding-bottom: 8px;">
563
+ 🥽 VR Settings
564
+ </h3>
565
+
566
+ <!-- Status Section -->
567
+ <div style="margin: 12px 0; padding: 8px; background: #2a2a2a; border-radius: 4px;">
568
+ <div style="font-weight: bold; margin-bottom: 4px;">Status</div>
569
+ <div style="font-size: 12px; color: #a855f7;">
570
+ ${status.supported ? '✓ Supported' : '✗ Not Supported'}
571
+ </div>
572
+ <div style="font-size: 12px; color: #58a6ff;">
573
+ ${status.active ? '● Active' : '○ Inactive'}
574
+ </div>
575
+ <div style="font-size: 12px; color: #ccc;">
576
+ Headset: ${status.headset}
577
+ </div>
578
+ <div style="font-size: 12px; color: #ccc;">
579
+ Controllers: L=${status.controllers.left ? '✓' : '✗'} R=${status.controllers.right ? '✓' : '✗'}
580
+ </div>
581
+ </div>
582
+
583
+ <!-- VR Entry Button -->
584
+ <button id="vr-enter-btn" style="
585
+ width: 100%;
586
+ padding: 12px;
587
+ margin: 8px 0;
588
+ background: #a855f7;
589
+ color: #fff;
590
+ border: none;
591
+ border-radius: 4px;
592
+ font-weight: bold;
593
+ cursor: pointer;
594
+ transition: background 0.2s;
595
+ " ${!status.supported ? 'disabled' : ''}>
596
+ ${status.active ? 'Exit VR' : 'Enter VR'}
597
+ </button>
598
+
599
+ <!-- Mode Selector -->
600
+ <div style="margin: 12px 0; padding: 8px; background: #2a2a2a; border-radius: 4px;">
601
+ <div style="font-weight: bold; margin-bottom: 6px;">Mode</div>
602
+ <select id="vr-mode-select" style="
603
+ width: 100%;
604
+ padding: 6px;
605
+ background: #1e1e1e;
606
+ color: #58a6ff;
607
+ border: 1px solid #a855f7;
608
+ border-radius: 4px;
609
+ cursor: pointer;
610
+ ">
611
+ <option value="inspect">🔍 Inspect</option>
612
+ <option value="explode">💥 Explode</option>
613
+ <option value="cross-section">✂️ Cross-Section</option>
614
+ <option value="scale">↔️ Scale</option>
615
+ <option value="measure">📏 Measure</option>
616
+ <option value="annotate">📝 Annotate</option>
617
+ </select>
618
+ </div>
619
+
620
+ <!-- Comfort Settings -->
621
+ <div style="margin: 12px 0; padding: 8px; background: #2a2a2a; border-radius: 4px;">
622
+ <div style="font-weight: bold; margin-bottom: 6px;">Comfort</div>
623
+ <label style="display: block; margin: 6px 0; font-size: 12px;">
624
+ <input type="checkbox" id="vr-vignette-toggle" checked>
625
+ Comfort Vignette
626
+ </label>
627
+ <label style="display: block; margin: 6px 0; font-size: 12px;">
628
+ <input type="checkbox" id="vr-snap-turn-toggle" checked>
629
+ Snap Turning (30°)
630
+ </label>
631
+ <label style="display: block; margin: 6px 0; font-size: 12px;">
632
+ <input type="checkbox" id="vr-seated-toggle">
633
+ Seated Mode
634
+ </label>
635
+ <div style="margin-top: 8px;">
636
+ <label style="font-size: 12px;">World Scale:</label>
637
+ <input type="range" id="vr-scale-slider" min="0.1" max="10" step="0.1" value="1"
638
+ style="width: 100%; margin-top: 4px;">
639
+ <span id="vr-scale-value" style="font-size: 11px; color: #a855f7;">1.0x</span>
640
+ </div>
641
+ </div>
642
+
643
+ <!-- Multi-User -->
644
+ <div style="margin: 12px 0; padding: 8px; background: #2a2a2a; border-radius: 4px;">
645
+ <div style="font-weight: bold; margin-bottom: 6px;">Collaboration</div>
646
+ <div style="display: flex; gap: 8px; margin-bottom: 8px;">
647
+ <input type="text" id="vr-room-input" placeholder="Room ID" style="
648
+ flex: 1;
649
+ padding: 6px;
650
+ background: #1e1e1e;
651
+ color: #e0e0e0;
652
+ border: 1px solid #a855f7;
653
+ border-radius: 4px;
654
+ font-size: 12px;
655
+ ">
656
+ <button id="vr-create-room-btn" style="
657
+ padding: 6px 12px;
658
+ background: #58a6ff;
659
+ color: #000;
660
+ border: none;
661
+ border-radius: 4px;
662
+ font-weight: bold;
663
+ cursor: pointer;
664
+ font-size: 12px;
665
+ ">Create</button>
666
+ <button id="vr-join-room-btn" style="
667
+ padding: 6px 12px;
668
+ background: #58a6ff;
669
+ color: #000;
670
+ border: none;
671
+ border-radius: 4px;
672
+ font-weight: bold;
673
+ cursor: pointer;
674
+ font-size: 12px;
675
+ ">Join</button>
676
+ </div>
677
+ </div>
678
+
679
+ <!-- Recording & Export -->
680
+ <div style="margin: 12px 0; padding: 8px; background: #2a2a2a; border-radius: 4px;">
681
+ <div style="font-weight: bold; margin-bottom: 6px;">Capture</div>
682
+ <button id="vr-screenshot-btn" style="
683
+ width: 100%;
684
+ padding: 8px;
685
+ background: #58a6ff;
686
+ color: #000;
687
+ border: none;
688
+ border-radius: 4px;
689
+ font-weight: bold;
690
+ cursor: pointer;
691
+ margin-bottom: 4px;
692
+ font-size: 12px;
693
+ ">📷 Screenshot</button>
694
+ <button id="vr-record-btn" style="
695
+ width: 100%;
696
+ padding: 8px;
697
+ background: #ff6b6b;
698
+ color: #fff;
699
+ border: none;
700
+ border-radius: 4px;
701
+ font-weight: bold;
702
+ cursor: pointer;
703
+ font-size: 12px;
704
+ ">⏺️ Record 30s</button>
705
+ </div>
706
+
707
+ <!-- Headset Detection -->
708
+ <div style="margin: 12px 0; padding: 8px; background: #2a2a2a; border-radius: 4px;">
709
+ <div style="font-weight: bold; margin-bottom: 4px; font-size: 12px;">
710
+ Compatible Headsets
711
+ </div>
712
+ <div style="font-size: 11px; color: #a0a0a0;">
713
+ ${supportedHeadsets}
714
+ </div>
715
+ </div>
716
+
717
+ <!-- WebXR Features -->
718
+ <div style="margin: 12px 0; padding: 8px; background: #2a2a2a; border-radius: 4px;">
719
+ <div style="font-weight: bold; margin-bottom: 4px; font-size: 12px;">
720
+ WebXR Features
721
+ </div>
722
+ <div style="font-size: 11px; color: #ccc; line-height: 1.6;">
723
+ ✓ Immersive VR<br>
724
+ ${status.supported ? '✓' : '✗'} Hand Tracking<br>
725
+ ${status.supported ? '✓' : '✗'} Controller Input<br>
726
+ ${status.supported ? '✓' : '✗'} Gesture Recognition
727
+ </div>
728
+ </div>
729
+ </div>
730
+ `;
731
+ }
732
+
733
+ // ============================================================================
734
+ // Panel Integration
735
+ // ============================================================================
736
+ function attachUIToPanel() {
737
+ const panel = document.getElementById('vr-panel');
738
+ if (!panel) {
739
+ const container = document.body;
740
+ const div = document.createElement('div');
741
+ div.innerHTML = getUI();
742
+ container.appendChild(div);
743
+ attachEventListeners();
744
+ }
745
+ }
746
+
747
+ function attachEventListeners() {
748
+ const enterBtn = document.getElementById('vr-enter-btn');
749
+ if (enterBtn) {
750
+ enterBtn.addEventListener('click', () => {
751
+ if (VRState.isActive) {
752
+ exitVR();
753
+ } else {
754
+ enterVR();
755
+ }
756
+ });
757
+ }
758
+
759
+ const modeSelect = document.getElementById('vr-mode-select');
760
+ if (modeSelect) {
761
+ modeSelect.addEventListener('change', (e) => setMode(e.target.value));
762
+ }
763
+
764
+ const scaleSlider = document.getElementById('vr-scale-slider');
765
+ if (scaleSlider) {
766
+ scaleSlider.addEventListener('input', (e) => {
767
+ ComfortSettings.worldScale = parseFloat(e.target.value);
768
+ document.getElementById('vr-scale-value').textContent = e.target.value + 'x';
769
+ });
770
+ }
771
+
772
+ const createRoomBtn = document.getElementById('vr-create-room-btn');
773
+ if (createRoomBtn) {
774
+ createRoomBtn.addEventListener('click', () => {
775
+ const roomInput = document.getElementById('vr-room-input');
776
+ const roomId = roomInput?.value || 'room_' + Date.now();
777
+ createRoom(roomId);
778
+ console.log('[VR] Room created:', roomId);
779
+ });
780
+ }
781
+
782
+ const joinRoomBtn = document.getElementById('vr-join-room-btn');
783
+ if (joinRoomBtn) {
784
+ joinRoomBtn.addEventListener('click', () => {
785
+ const roomInput = document.getElementById('vr-room-input');
786
+ const roomId = roomInput?.value;
787
+ if (roomId) {
788
+ joinRoom(roomId);
789
+ console.log('[VR] Joined room:', roomId);
790
+ }
791
+ });
792
+ }
793
+
794
+ const screenshotBtn = document.getElementById('vr-screenshot-btn');
795
+ if (screenshotBtn) {
796
+ screenshotBtn.addEventListener('click', captureVRScreenshot);
797
+ }
798
+
799
+ const recordBtn = document.getElementById('vr-record-btn');
800
+ if (recordBtn) {
801
+ recordBtn.addEventListener('click', () => recordVRSession(30));
802
+ }
803
+
804
+ const vignetteToggle = document.getElementById('vr-vignette-toggle');
805
+ if (vignetteToggle) {
806
+ vignetteToggle.addEventListener('change', (e) => {
807
+ ComfortSettings.enableVignette = e.target.checked;
808
+ });
809
+ }
810
+
811
+ const seatedToggle = document.getElementById('vr-seated-toggle');
812
+ if (seatedToggle) {
813
+ seatedToggle.addEventListener('change', (e) => {
814
+ ComfortSettings.seatedMode = e.target.checked;
815
+ });
816
+ }
817
+ }
818
+
819
+ // ============================================================================
820
+ // Initialization & Public API
821
+ // ============================================================================
822
+ async function init() {
823
+ // Check WebXR support
824
+ if (navigator?.xr?.isSessionSupported) {
825
+ try {
826
+ const vrSupported = await navigator.xr.isSessionSupported('immersive-vr');
827
+ VRState.supported = vrSupported;
828
+ } catch (e) {
829
+ VRState.supported = false;
830
+ }
831
+ }
832
+
833
+ console.log('[VR] Module initialized. Support:', VRState.supported);
834
+ attachUIToPanel();
835
+ }
836
+
837
+ // ============================================================================
838
+ // Public API
839
+ // ============================================================================
840
+ const publicAPI = {
841
+ // Session management
842
+ enterVR,
843
+ exitVR,
844
+ isVRSupported,
845
+ getVRStatus,
846
+
847
+ // Scene & controllers
848
+ initVRScene,
849
+ initControllers,
850
+
851
+ // Mode management
852
+ setMode,
853
+ getMode: () => VRState.mode,
854
+
855
+ // Assembly operations
856
+ setExplosionFactor,
857
+ getExplosionFactor: () => VRState.explosionFactor,
858
+
859
+ // Measurement
860
+ getMeasurePoints: () => [...VRState.measurePoints],
861
+ clearMeasurePoints: () => {
862
+ VRState.measurePoints = [];
863
+ },
864
+
865
+ // Annotations
866
+ getAnnotations: () => [...VRState.annotations],
867
+ clearAnnotations: () => {
868
+ VRState.annotations = [];
869
+ },
870
+
871
+ // Multi-user
872
+ createRoom,
873
+ joinRoom,
874
+ leaveRoom,
875
+ getRoom: () => VRState.multiUserRoom,
876
+
877
+ // Export & recording
878
+ captureVRScreenshot,
879
+ recordVRSession,
880
+ shareVRLink,
881
+ getSessionDuration: () =>
882
+ VRState.sessionStartTime ? Date.now() - VRState.sessionStartTime : 0,
883
+
884
+ // Comfort settings
885
+ getComfortSettings: () => ({ ...ComfortSettings }),
886
+ setComfortSettings: (settings) => {
887
+ Object.assign(ComfortSettings, settings);
888
+ },
889
+
890
+ // UI
891
+ getUI,
892
+ attachUIToPanel,
893
+ refreshUI: () => {
894
+ const panel = document.getElementById('vr-panel');
895
+ if (panel) {
896
+ panel.innerHTML = getUI();
897
+ attachEventListeners();
898
+ }
899
+ },
900
+
901
+ // State
902
+ getState: () => ({ ...VRState }),
903
+ };
904
+
905
+ // Register module
906
+ if (!window.cycleCAD) window.cycleCAD = {};
907
+ window.cycleCAD.cadVR = publicAPI;
908
+
909
+ // Auto-initialize when DOM is ready
910
+ if (document.readyState === 'loading') {
911
+ document.addEventListener('DOMContentLoaded', init);
912
+ } else {
913
+ init();
914
+ }
915
+
916
+ console.log('[VR] CAD-to-VR module loaded. Access via window.cycleCAD.cadVR');
917
+ })();