senangwebs-tour 1.0.3 → 1.0.5
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/dist/swt-editor.css +36 -2
- package/dist/swt-editor.css.map +1 -1
- package/dist/swt-editor.js +3102 -209
- package/dist/swt-editor.js.map +1 -1
- package/dist/swt-editor.min.css +1 -1
- package/dist/swt-editor.min.js +1 -1
- package/dist/swt.js +292 -26
- package/dist/swt.js.map +1 -1
- package/dist/swt.min.js +1 -1
- package/package.json +2 -1
- package/src/AssetManager.js +13 -2
- package/src/HotspotManager.js +93 -20
- package/src/IconRenderer.js +123 -0
- package/src/SceneManager.js +7 -1
- package/src/editor/css/main.css +41 -2
- package/src/editor/js/editor.js +108 -23
- package/src/editor/js/export-manager.js +56 -9
- package/src/editor/js/hotspot-editor.js +10 -57
- package/src/editor/js/preview-controller.js +132 -105
- package/src/editor/js/ui-controller.js +91 -8
- package/src/editor/js/utils.js +1 -0
- package/src/index.js +56 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Export Manager - Handles JSON generation for SWT library
|
|
2
|
-
import { downloadTextAsFile, showModal } from "./utils.js";
|
|
2
|
+
import { downloadTextAsFile, showModal, copyToClipboard } from "./utils.js";
|
|
3
|
+
import { IconRenderer } from "../../IconRenderer.js";
|
|
3
4
|
|
|
4
5
|
class ExportManager {
|
|
5
6
|
constructor(editor) {
|
|
6
7
|
this.editor = editor;
|
|
8
|
+
this.iconRenderer = new IconRenderer();
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -40,8 +42,13 @@ class ExportManager {
|
|
|
40
42
|
// Add appearance
|
|
41
43
|
hotspotData.appearance = {
|
|
42
44
|
color: hotspot.color || "#FF6B6B",
|
|
43
|
-
scale: "2 2 2",
|
|
45
|
+
scale: hotspot.scale || "2 2 2",
|
|
44
46
|
};
|
|
47
|
+
|
|
48
|
+
// Add icon if set
|
|
49
|
+
if (hotspot.icon) {
|
|
50
|
+
hotspotData.appearance.icon = hotspot.icon;
|
|
51
|
+
}
|
|
45
52
|
|
|
46
53
|
// Add tooltip if title exists
|
|
47
54
|
if (hotspot.title) {
|
|
@@ -53,6 +60,11 @@ class ExportManager {
|
|
|
53
60
|
return hotspotData;
|
|
54
61
|
}),
|
|
55
62
|
};
|
|
63
|
+
|
|
64
|
+
// Add starting position if set
|
|
65
|
+
if (scene.startingPosition) {
|
|
66
|
+
scenesData[scene.id].startingPosition = scene.startingPosition;
|
|
67
|
+
}
|
|
56
68
|
});
|
|
57
69
|
|
|
58
70
|
// Determine initial scene
|
|
@@ -70,6 +82,41 @@ class ExportManager {
|
|
|
70
82
|
return jsonData;
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Generate JSON with icons baked in as SVG data URLs
|
|
87
|
+
* This ensures the exported HTML doesn't need the SenangStart icons library
|
|
88
|
+
*/
|
|
89
|
+
async generateJSONWithBakedIcons() {
|
|
90
|
+
const jsonData = this.generateJSON();
|
|
91
|
+
|
|
92
|
+
// Process all scenes and convert icon names to data URLs
|
|
93
|
+
for (const sceneId of Object.keys(jsonData.scenes)) {
|
|
94
|
+
const scene = jsonData.scenes[sceneId];
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < scene.hotspots.length; i++) {
|
|
97
|
+
const hotspot = scene.hotspots[i];
|
|
98
|
+
const icon = hotspot.appearance?.icon;
|
|
99
|
+
|
|
100
|
+
// Skip if no icon or if it's already a data URL or URL
|
|
101
|
+
if (!icon) continue;
|
|
102
|
+
if (icon.startsWith('data:') || icon.startsWith('http') || icon.startsWith('/')) continue;
|
|
103
|
+
|
|
104
|
+
// Generate SVG data URL from icon name
|
|
105
|
+
try {
|
|
106
|
+
const color = hotspot.appearance?.color || '#ffffff';
|
|
107
|
+
const dataUrl = await this.iconRenderer.generateIconDataUrl(icon, color, 128);
|
|
108
|
+
if (dataUrl) {
|
|
109
|
+
hotspot.appearance.icon = dataUrl;
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.warn(`Failed to bake icon "${icon}" for export:`, err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return jsonData;
|
|
118
|
+
}
|
|
119
|
+
|
|
73
120
|
/**
|
|
74
121
|
* Export as JSON file
|
|
75
122
|
*/
|
|
@@ -114,10 +161,10 @@ class ExportManager {
|
|
|
114
161
|
}
|
|
115
162
|
|
|
116
163
|
/**
|
|
117
|
-
* Generate HTML viewer code
|
|
164
|
+
* Generate HTML viewer code with icons baked in
|
|
118
165
|
*/
|
|
119
|
-
generateViewerHTML() {
|
|
120
|
-
const jsonData = this.
|
|
166
|
+
async generateViewerHTML() {
|
|
167
|
+
const jsonData = await this.generateJSONWithBakedIcons();
|
|
121
168
|
const title = this.editor.config.title || "Virtual Tour";
|
|
122
169
|
|
|
123
170
|
return `<!DOCTYPE html>
|
|
@@ -130,7 +177,7 @@ class ExportManager {
|
|
|
130
177
|
</head>
|
|
131
178
|
<body>
|
|
132
179
|
<a-scene id="tour-container">
|
|
133
|
-
<a-camera>
|
|
180
|
+
<a-camera look-controls>
|
|
134
181
|
<a-cursor></a-cursor>
|
|
135
182
|
</a-camera>
|
|
136
183
|
</a-scene>
|
|
@@ -154,11 +201,11 @@ class ExportManager {
|
|
|
154
201
|
}
|
|
155
202
|
|
|
156
203
|
/**
|
|
157
|
-
* Export as standalone HTML viewer
|
|
204
|
+
* Export as standalone HTML viewer with icons baked in
|
|
158
205
|
*/
|
|
159
|
-
exportViewerHTML() {
|
|
206
|
+
async exportViewerHTML() {
|
|
160
207
|
try {
|
|
161
|
-
const html = this.generateViewerHTML();
|
|
208
|
+
const html = await this.generateViewerHTML();
|
|
162
209
|
const title = this.editor.config.title || "tour";
|
|
163
210
|
const filename = sanitizeId(title) + "-viewer.html";
|
|
164
211
|
|
|
@@ -5,66 +5,15 @@ class HotspotEditor {
|
|
|
5
5
|
constructor(editor) {
|
|
6
6
|
this.editor = editor;
|
|
7
7
|
this.currentHotspotIndex = -1;
|
|
8
|
-
this.placementMode = false;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
* Enable hotspot placement mode
|
|
13
|
-
*/
|
|
14
|
-
enablePlacementMode() {
|
|
15
|
-
const scene = this.editor.sceneManager.getCurrentScene();
|
|
16
|
-
if (!scene) {
|
|
17
|
-
showToast('Please select a scene first', 'error');
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
this.placementMode = true;
|
|
22
|
-
|
|
23
|
-
// Visual feedback
|
|
24
|
-
document.body.style.cursor = 'crosshair';
|
|
25
|
-
const preview = document.getElementById('preview');
|
|
26
|
-
if (preview) {
|
|
27
|
-
preview.style.border = '3px solid #4CC3D9';
|
|
28
|
-
preview.style.boxShadow = '0 0 20px rgba(76, 195, 217, 0.5)';
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Update button state
|
|
32
|
-
const btn = document.getElementById('addHotspotBtn');
|
|
33
|
-
if (btn) {
|
|
34
|
-
btn.textContent = 'Click on Preview...';
|
|
35
|
-
btn.classList.add('btn-active');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
showToast('Click on the 360° preview to place hotspot', 'info', 5000);
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Disable hotspot placement mode
|
|
44
|
-
*/
|
|
45
|
-
disablePlacementMode() {
|
|
46
|
-
this.placementMode = false;
|
|
47
|
-
document.body.style.cursor = 'default';
|
|
48
|
-
|
|
49
|
-
// Remove visual feedback
|
|
50
|
-
const preview = document.getElementById('preview');
|
|
51
|
-
if (preview) {
|
|
52
|
-
preview.style.border = '';
|
|
53
|
-
preview.style.boxShadow = '';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Reset button state
|
|
57
|
-
const btn = document.getElementById('addHotspotBtn');
|
|
58
|
-
if (btn) {
|
|
59
|
-
btn.textContent = '+ Add Hotspot';
|
|
60
|
-
btn.classList.remove('btn-active');
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
10
|
/**
|
|
65
11
|
* Add hotspot at position
|
|
12
|
+
* @param {Object} position - The 3D position {x, y, z}
|
|
13
|
+
* @param {string} targetSceneId - Target scene ID for navigation
|
|
14
|
+
* @param {Object} cameraOrientation - Camera orientation at creation {pitch, yaw} in radians
|
|
66
15
|
*/
|
|
67
|
-
addHotspot(position, targetSceneId = '') {
|
|
16
|
+
addHotspot(position, targetSceneId = '', cameraOrientation = null) {
|
|
68
17
|
const scene = this.editor.sceneManager.getCurrentScene();
|
|
69
18
|
if (!scene) {
|
|
70
19
|
showToast('No scene selected', 'error');
|
|
@@ -75,17 +24,18 @@ class HotspotEditor {
|
|
|
75
24
|
id: generateId('hotspot'),
|
|
76
25
|
type: 'navigation',
|
|
77
26
|
position: position,
|
|
27
|
+
cameraOrientation: cameraOrientation, // Store camera pitch/yaw for reliable pointing
|
|
78
28
|
targetSceneId: targetSceneId,
|
|
79
29
|
title: 'New Hotspot',
|
|
80
30
|
description: '',
|
|
81
31
|
color: '#00ff00',
|
|
82
|
-
icon: ''
|
|
32
|
+
icon: '',
|
|
33
|
+
scale: '1 1 1'
|
|
83
34
|
};
|
|
84
35
|
|
|
85
36
|
scene.hotspots.push(hotspot);
|
|
86
37
|
this.currentHotspotIndex = scene.hotspots.length - 1;
|
|
87
38
|
|
|
88
|
-
this.disablePlacementMode();
|
|
89
39
|
showToast('Hotspot added', 'success');
|
|
90
40
|
|
|
91
41
|
return hotspot;
|
|
@@ -201,6 +151,9 @@ class HotspotEditor {
|
|
|
201
151
|
y: original.position.y,
|
|
202
152
|
z: original.position.z
|
|
203
153
|
};
|
|
154
|
+
|
|
155
|
+
// Clear camera orientation since position changed - will fallback to position-based calculation
|
|
156
|
+
duplicate.cameraOrientation = null;
|
|
204
157
|
|
|
205
158
|
scene.hotspots.push(duplicate);
|
|
206
159
|
this.currentHotspotIndex = scene.hotspots.length - 1;
|
|
@@ -174,6 +174,7 @@ class PreviewController {
|
|
|
174
174
|
name: s.name,
|
|
175
175
|
panorama: s.imageUrl,
|
|
176
176
|
hotspots: sceneHotspots,
|
|
177
|
+
startingPosition: s.startingPosition || null,
|
|
177
178
|
};
|
|
178
179
|
});
|
|
179
180
|
|
|
@@ -181,10 +182,6 @@ class PreviewController {
|
|
|
181
182
|
title: scene.name,
|
|
182
183
|
initialScene: scene.id,
|
|
183
184
|
scenes: allScenes,
|
|
184
|
-
settings: {
|
|
185
|
-
autoRotate: false,
|
|
186
|
-
showCompass: false,
|
|
187
|
-
},
|
|
188
185
|
};
|
|
189
186
|
|
|
190
187
|
try {
|
|
@@ -221,15 +218,18 @@ class PreviewController {
|
|
|
221
218
|
// Hide loading animation after scene loads
|
|
222
219
|
this.hideLoading();
|
|
223
220
|
|
|
224
|
-
//
|
|
221
|
+
// Handle camera rotation after scene loads
|
|
225
222
|
if (savedRotation && preserveCameraRotation) {
|
|
223
|
+
// Restore previous camera rotation
|
|
226
224
|
this.setCameraRotation(savedRotation);
|
|
225
|
+
} else if (scene.startingPosition) {
|
|
226
|
+
// Set camera to scene's starting position immediately
|
|
227
|
+
this.setCameraRotation({
|
|
228
|
+
x: scene.startingPosition.pitch,
|
|
229
|
+
y: scene.startingPosition.yaw,
|
|
230
|
+
z: 0
|
|
231
|
+
});
|
|
227
232
|
}
|
|
228
|
-
|
|
229
|
-
// Setup click handler after a short delay to ensure A-Frame is ready
|
|
230
|
-
setTimeout(() => {
|
|
231
|
-
this.setupClickHandler();
|
|
232
|
-
}, 500);
|
|
233
233
|
} catch (error) {
|
|
234
234
|
console.error("Failed to load preview:", error);
|
|
235
235
|
showToast("Failed to load preview: " + error.message, "error");
|
|
@@ -239,76 +239,73 @@ class PreviewController {
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
/**
|
|
242
|
-
*
|
|
242
|
+
* Get the current cursor intersection point with the sky sphere.
|
|
243
|
+
* Raycasts from the camera center (where the A-Cursor points) to find the intersection.
|
|
244
|
+
* @returns {Object|null} The 3D position {x, y, z} or null if no intersection
|
|
243
245
|
*/
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const aframeScene = this.previewContainer.querySelector("a-scene");
|
|
246
|
+
getCursorIntersection() {
|
|
247
|
+
const aframeScene = this.previewContainer?.querySelector("a-scene");
|
|
250
248
|
if (!aframeScene) {
|
|
251
|
-
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Remove any existing click handler to avoid duplicates
|
|
256
|
-
if (this.clickHandler) {
|
|
257
|
-
aframeScene.removeEventListener("click", this.clickHandler);
|
|
249
|
+
return null;
|
|
258
250
|
}
|
|
259
251
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (!this.editor.hotspotEditor.placementMode) {
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Try to get intersection from event detail first
|
|
267
|
-
let intersection = evt.detail?.intersection;
|
|
252
|
+
const camera = aframeScene.querySelector("[camera]");
|
|
253
|
+
const sky = aframeScene.querySelector("a-sky");
|
|
268
254
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const sky = aframeScene.querySelector("a-sky");
|
|
255
|
+
if (!camera || !sky) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
273
258
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
259
|
+
// Get pitch and yaw from look-controls (the authoritative source in A-Frame)
|
|
260
|
+
const lookControls = camera.components?.["look-controls"];
|
|
261
|
+
let pitch = 0;
|
|
262
|
+
let yaw = 0;
|
|
278
263
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
// Perform raycasting
|
|
288
|
-
const raycaster = new THREE.Raycaster();
|
|
289
|
-
const cameraEl = camera.object3D;
|
|
290
|
-
raycaster.setFromCamera(mouse, cameraEl.children[0]); // Get the actual camera
|
|
291
|
-
|
|
292
|
-
// Raycast against the sky sphere
|
|
293
|
-
const intersects = raycaster.intersectObject(sky.object3D, true);
|
|
294
|
-
|
|
295
|
-
if (intersects.length > 0) {
|
|
296
|
-
intersection = intersects[0];
|
|
297
|
-
} else {
|
|
298
|
-
showToast("Click on the panorama image", "warning");
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
264
|
+
if (lookControls && lookControls.pitchObject && lookControls.yawObject) {
|
|
265
|
+
pitch = lookControls.pitchObject.rotation.x;
|
|
266
|
+
yaw = lookControls.yawObject.rotation.y;
|
|
267
|
+
} else {
|
|
268
|
+
// Fallback to object3D rotation
|
|
269
|
+
pitch = camera.object3D.rotation.x;
|
|
270
|
+
yaw = camera.object3D.rotation.y;
|
|
271
|
+
}
|
|
302
272
|
|
|
303
|
-
|
|
304
|
-
|
|
273
|
+
// Calculate direction vector from pitch/yaw
|
|
274
|
+
// In A-Frame/Three.js coordinate system:
|
|
275
|
+
// - Looking forward is -Z
|
|
276
|
+
// - Yaw rotates around Y axis
|
|
277
|
+
// - Pitch rotates around X axis
|
|
278
|
+
const direction = new THREE.Vector3();
|
|
279
|
+
direction.x = -Math.sin(yaw) * Math.cos(pitch);
|
|
280
|
+
direction.y = Math.sin(pitch);
|
|
281
|
+
direction.z = -Math.cos(yaw) * Math.cos(pitch);
|
|
282
|
+
direction.normalize();
|
|
283
|
+
|
|
284
|
+
// Create raycaster from camera position in the direction we're looking
|
|
285
|
+
const raycaster = new THREE.Raycaster();
|
|
286
|
+
const origin = new THREE.Vector3(0, 0, 0); // Camera is typically at origin in 360 viewer
|
|
287
|
+
raycaster.set(origin, direction);
|
|
288
|
+
|
|
289
|
+
// Raycast against the sky sphere
|
|
290
|
+
const intersects = raycaster.intersectObject(sky.object3D, true);
|
|
291
|
+
|
|
292
|
+
if (intersects.length > 0) {
|
|
293
|
+
const point = intersects[0].point;
|
|
294
|
+
|
|
295
|
+
// Calculate an upward offset to center the hotspot visual on the cursor
|
|
296
|
+
// The offset is applied along the "up" direction relative to the sphere surface
|
|
297
|
+
// For a sphere, we shift the point slightly in the Y direction (vertical up in world space)
|
|
298
|
+
// The offset compensates for the hotspot visual being centered, so the top aligns with cursor
|
|
299
|
+
const offsetAmount = 200; // Adjust this value to fine-tune alignment
|
|
300
|
+
|
|
301
|
+
return {
|
|
305
302
|
x: parseFloat(point.x.toFixed(2)),
|
|
306
|
-
y: parseFloat(point.y.toFixed(2)),
|
|
303
|
+
y: parseFloat((point.y + offsetAmount).toFixed(2)),
|
|
307
304
|
z: parseFloat(point.z.toFixed(2)),
|
|
308
305
|
};
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return null;
|
|
312
309
|
}
|
|
313
310
|
|
|
314
311
|
/**
|
|
@@ -414,10 +411,12 @@ class PreviewController {
|
|
|
414
411
|
}
|
|
415
412
|
|
|
416
413
|
/**
|
|
417
|
-
* Point camera to hotspot
|
|
414
|
+
* Point camera to hotspot
|
|
415
|
+
* Uses stored camera orientation if available, otherwise calculates from position
|
|
416
|
+
* @param {Object} hotspot - The hotspot object with position and optional cameraOrientation
|
|
418
417
|
*/
|
|
419
|
-
pointCameraToHotspot(
|
|
420
|
-
if (!
|
|
418
|
+
pointCameraToHotspot(hotspot) {
|
|
419
|
+
if (!hotspot) {
|
|
421
420
|
return;
|
|
422
421
|
}
|
|
423
422
|
|
|
@@ -431,25 +430,36 @@ class PreviewController {
|
|
|
431
430
|
return;
|
|
432
431
|
}
|
|
433
432
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
433
|
+
let pitch, yaw;
|
|
434
|
+
|
|
435
|
+
// Use stored camera orientation if available (more reliable)
|
|
436
|
+
if (hotspot.cameraOrientation) {
|
|
437
|
+
// Stored values are in radians, convert to degrees for animateCameraRotation
|
|
438
|
+
pitch = hotspot.cameraOrientation.pitch * (180 / Math.PI);
|
|
439
|
+
yaw = hotspot.cameraOrientation.yaw * (180 / Math.PI);
|
|
440
|
+
} else if (hotspot.position) {
|
|
441
|
+
// Fallback: calculate from position (for legacy hotspots without cameraOrientation)
|
|
442
|
+
const hotspotPosition = hotspot.position;
|
|
443
|
+
const cameraPos = camera.object3D.position;
|
|
444
|
+
|
|
445
|
+
// Calculate direction vector from camera to hotspot
|
|
446
|
+
const direction = new THREE.Vector3(
|
|
447
|
+
hotspotPosition.x - cameraPos.x,
|
|
448
|
+
hotspotPosition.y - cameraPos.y,
|
|
449
|
+
hotspotPosition.z - cameraPos.z
|
|
450
|
+
);
|
|
443
451
|
|
|
444
|
-
|
|
445
|
-
|
|
452
|
+
// Calculate spherical coordinates (yaw and pitch)
|
|
453
|
+
const distance = direction.length();
|
|
446
454
|
|
|
447
|
-
|
|
448
|
-
|
|
455
|
+
// Pitch (up/down rotation around X-axis) - in degrees
|
|
456
|
+
pitch = Math.asin(direction.y / distance) * (180 / Math.PI);
|
|
449
457
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
458
|
+
// Yaw (left/right rotation around Y-axis) - in degrees
|
|
459
|
+
yaw = Math.atan2(direction.x, direction.z) * (180 / Math.PI);
|
|
460
|
+
} else {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
453
463
|
|
|
454
464
|
// Apply smooth rotation with animation
|
|
455
465
|
this.animateCameraRotation(camera, { x: pitch, y: yaw, z: 0 });
|
|
@@ -457,15 +467,29 @@ class PreviewController {
|
|
|
457
467
|
|
|
458
468
|
/**
|
|
459
469
|
* Animate camera rotation smoothly
|
|
470
|
+
* Uses look-controls internal pitchObject/yawObject to avoid being overwritten
|
|
460
471
|
*/
|
|
461
472
|
animateCameraRotation(camera, targetRotation, duration = 800) {
|
|
462
473
|
if (!camera || !camera.object3D) return;
|
|
463
474
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
475
|
+
// Get look-controls component
|
|
476
|
+
const lookControls = camera.components?.["look-controls"];
|
|
477
|
+
|
|
478
|
+
// Get current rotation - prefer look-controls internal state
|
|
479
|
+
let startRotation;
|
|
480
|
+
if (lookControls && lookControls.pitchObject && lookControls.yawObject) {
|
|
481
|
+
startRotation = {
|
|
482
|
+
x: lookControls.pitchObject.rotation.x * (180 / Math.PI),
|
|
483
|
+
y: lookControls.yawObject.rotation.y * (180 / Math.PI),
|
|
484
|
+
z: 0,
|
|
485
|
+
};
|
|
486
|
+
} else {
|
|
487
|
+
startRotation = {
|
|
488
|
+
x: camera.object3D.rotation.x * (180 / Math.PI),
|
|
489
|
+
y: camera.object3D.rotation.y * (180 / Math.PI),
|
|
490
|
+
z: 0,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
469
493
|
|
|
470
494
|
// Handle angle wrapping for smooth rotation
|
|
471
495
|
let deltaY = targetRotation.y - startRotation.y;
|
|
@@ -488,19 +512,22 @@ class PreviewController {
|
|
|
488
512
|
? 2 * progress * progress
|
|
489
513
|
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
490
514
|
|
|
491
|
-
// Interpolate rotation
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
y: startRotation.y + (endRotationY - startRotation.y) * eased,
|
|
495
|
-
z: startRotation.z + (targetRotation.z - startRotation.z) * eased,
|
|
496
|
-
};
|
|
515
|
+
// Interpolate rotation (in degrees)
|
|
516
|
+
const currentX = startRotation.x + (targetRotation.x - startRotation.x) * eased;
|
|
517
|
+
const currentY = startRotation.y + (endRotationY - startRotation.y) * eased;
|
|
497
518
|
|
|
498
|
-
// Apply rotation
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
519
|
+
// Apply rotation using look-controls internal objects (in radians)
|
|
520
|
+
if (lookControls && lookControls.pitchObject && lookControls.yawObject) {
|
|
521
|
+
lookControls.pitchObject.rotation.x = currentX * (Math.PI / 180);
|
|
522
|
+
lookControls.yawObject.rotation.y = currentY * (Math.PI / 180);
|
|
523
|
+
} else {
|
|
524
|
+
// Fallback to direct rotation
|
|
525
|
+
camera.object3D.rotation.set(
|
|
526
|
+
currentX * (Math.PI / 180),
|
|
527
|
+
currentY * (Math.PI / 180),
|
|
528
|
+
0
|
|
529
|
+
);
|
|
530
|
+
}
|
|
504
531
|
|
|
505
532
|
if (progress < 1) {
|
|
506
533
|
requestAnimationFrame(animate);
|