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.
@@ -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.generateJSON();
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
- // Restore camera rotation if preserved
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
- * Setup click handler for hotspot placement
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
- setupClickHandler() {
245
- if (!this.tour) {
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
- setTimeout(() => this.setupClickHandler(), 200); // Retry
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
- // Create and store the click handler
261
- this.clickHandler = (evt) => {
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
- // If no intersection, perform manual raycasting
270
- if (!intersection) {
271
- const camera = aframeScene.querySelector("[camera]");
272
- const sky = aframeScene.querySelector("a-sky");
255
+ if (!camera || !sky) {
256
+ return null;
257
+ }
273
258
 
274
- if (!camera || !sky) {
275
- showToast("Scene not ready, please try again", "warning");
276
- return;
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
- // Get mouse position relative to canvas
280
- const canvas = aframeScene.canvas;
281
- const rect = canvas.getBoundingClientRect();
282
- const mouse = {
283
- x: ((evt.clientX - rect.left) / rect.width) * 2 - 1,
284
- y: -((evt.clientY - rect.top) / rect.height) * 2 + 1,
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
- const point = intersection.point;
304
- const position = {
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
- this.editor.addHotspotAtPosition(position);
310
- };
311
- aframeScene.addEventListener("click", this.clickHandler);
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 position
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(hotspotPosition) {
420
- if (!hotspotPosition) {
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
- // Get camera position (usually at origin 0,0,0)
435
- const cameraPos = camera.object3D.position;
436
-
437
- // Calculate direction vector from camera to hotspot
438
- const direction = new THREE.Vector3(
439
- hotspotPosition.x - cameraPos.x,
440
- hotspotPosition.y - cameraPos.y,
441
- hotspotPosition.z - cameraPos.z
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
- // Calculate spherical coordinates (yaw and pitch)
445
- const distance = direction.length();
452
+ // Calculate spherical coordinates (yaw and pitch)
453
+ const distance = direction.length();
446
454
 
447
- // Pitch (up/down rotation around X-axis) - in degrees
448
- const pitch = Math.asin(direction.y / distance) * (180 / Math.PI);
455
+ // Pitch (up/down rotation around X-axis) - in degrees
456
+ pitch = Math.asin(direction.y / distance) * (180 / Math.PI);
449
457
 
450
- // Yaw (left/right rotation around Y-axis) - in degrees
451
- // Using atan2 to get correct quadrant
452
- const yaw = Math.atan2(direction.x, direction.z) * (180 / Math.PI);
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
- const startRotation = {
465
- x: camera.object3D.rotation.x * (180 / Math.PI),
466
- y: camera.object3D.rotation.y * (180 / Math.PI),
467
- z: camera.object3D.rotation.z * (180 / Math.PI),
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 currentRotation = {
493
- x: startRotation.x + (targetRotation.x - startRotation.x) * eased,
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 (convert degrees to radians)
499
- camera.object3D.rotation.set(
500
- currentRotation.x * (Math.PI / 180),
501
- currentRotation.y * (Math.PI / 180),
502
- currentRotation.z * (Math.PI / 180)
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);