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.
- package/app/index.html +213 -8
- package/app/js/cad-vr.js +917 -0
- package/app/js/cam-operations.js +638 -0
- package/app/js/gcode-generator.js +485 -0
- package/app/js/gdt-training.js +1144 -0
- package/app/js/machine-profiles.js +534 -0
- package/app/js/marketplace-v2.js +766 -0
- package/app/js/misumi-catalog.js +904 -0
- package/app/js/section-view.js +666 -0
- package/app/js/sketch-enhance.js +779 -0
- package/app/js/stock-manager.js +482 -0
- package/app/js/text-to-cad.js +806 -0
- package/app/js/tool-library.js +593 -0
- package/app/tutorials/advanced.html +1924 -0
- package/app/tutorials/basic.html +1160 -0
- package/app/tutorials/intermediate.html +1456 -0
- package/cycleCAD-Architecture.pptx +0 -0
- package/cycleCAD-Investor-Deck.pptx +0 -0
- package/linkedin-post-combined.md +31 -0
- package/package.json +1 -1
package/app/js/cad-vr.js
ADDED
|
@@ -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
|
+
})();
|