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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "senangwebs-tour",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "description": "VR 360° virtual tour system with visual editor and viewer library",
6
6
  "main": "dist/swt.js",
@@ -24,6 +24,7 @@
24
24
  "author": "a-hakim",
25
25
  "license": "MIT",
26
26
  "devDependencies": {
27
+ "@rollup/plugin-json": "^6.1.0",
27
28
  "@rollup/plugin-node-resolve": "^15.2.3",
28
29
  "@rollup/plugin-terser": "^0.4.4",
29
30
  "@rollup/plugin-virtual": "^3.0.2",
@@ -56,8 +56,19 @@ export class AssetManager {
56
56
  * @returns {Promise} - Resolves when the asset is loaded
57
57
  */
58
58
  async preloadImage(url, id) {
59
- if (this.loadedAssets.has(id)) {
60
- return Promise.resolve(this.loadedAssets.get(id));
59
+ // Check if asset exists and if the URL is the same
60
+ const existingAsset = this.loadedAssets.get(id);
61
+ if (existingAsset) {
62
+ const existingSrc = existingAsset.getAttribute('src');
63
+ if (existingSrc === url) {
64
+ // Same URL, return cached asset
65
+ return Promise.resolve(existingAsset);
66
+ }
67
+ // URL changed - remove old asset and create new one
68
+ if (existingAsset.parentNode) {
69
+ existingAsset.parentNode.removeChild(existingAsset);
70
+ }
71
+ this.loadedAssets.delete(id);
61
72
  }
62
73
 
63
74
  // Ensure assets element is ready
@@ -2,13 +2,17 @@
2
2
  * HotspotManager - Creates, manages, and removes hotspot entities in the A-Frame scene
3
3
  */
4
4
  export class HotspotManager {
5
- constructor(sceneEl, assetManager, defaultHotspotSettings = {}) {
5
+ constructor(sceneEl, assetManager, defaultHotspotSettings = {}, iconRenderer = null) {
6
6
  this.sceneEl = sceneEl;
7
7
  this.assetManager = assetManager;
8
8
  this.defaultSettings = defaultHotspotSettings;
9
+ this.iconRenderer = iconRenderer;
9
10
  this.activeHotspots = [];
10
11
  this.tooltipEl = null;
11
12
  this.tooltipCreated = false;
13
+ this.iconDataUrls = new Map(); // Cache for generated icon data URLs
14
+ this.sceneLoadCounter = 0; // Unique counter to prevent asset ID collisions between scene loads
15
+ this.currentAssetPrefix = ''; // Current asset ID prefix for this scene load
12
16
 
13
17
  // Listen for hover events
14
18
  this.sceneEl.addEventListener('swt-hotspot-hover', (evt) => {
@@ -87,20 +91,50 @@ export class HotspotManager {
87
91
  this.createTooltip();
88
92
  }
89
93
 
90
- // First, preload all hotspot icons (with error handling)
91
- const iconPromises = hotspots.map((hotspot, index) => {
94
+ // Increment scene load counter to ensure unique asset IDs for this scene load
95
+ // This prevents A-Frame/THREE.js texture caching from reusing old icons
96
+ this.sceneLoadCounter++;
97
+ this.currentAssetPrefix = `hotspot-icon-${this.sceneLoadCounter}`;
98
+
99
+ // Clear previous icon data URLs cache
100
+ this.iconDataUrls.clear();
101
+
102
+ // Process all hotspot icons SEQUENTIALLY to avoid race condition
103
+ // (IconRenderer uses a shared renderContainer that would be corrupted by parallel generation)
104
+ for (let index = 0; index < hotspots.length; index++) {
105
+ const hotspot = hotspots[index];
92
106
  const icon = hotspot.appearance?.icon || this.defaultSettings.icon;
93
- if (icon) {
94
- const assetId = `hotspot-icon-${index}`;
95
- return this.assetManager.preloadImage(icon, assetId).catch(err => {
107
+ const color = hotspot.appearance?.color || '#ffffff';
108
+
109
+ if (!icon) continue;
110
+
111
+ // Check if it's an image URL
112
+ const isImageUrl = icon.startsWith('http') || icon.startsWith('data:') || icon.startsWith('/');
113
+
114
+ // Use unique asset ID that includes scene load counter
115
+ const assetId = `${this.currentAssetPrefix}-${index}`;
116
+
117
+ if (isImageUrl) {
118
+ // Preload as image asset
119
+ try {
120
+ await this.assetManager.preloadImage(icon, assetId);
121
+ } catch (err) {
96
122
  console.warn(`Failed to load icon for hotspot ${index}, will use color instead`);
97
- return null; // Continue even if icon fails to load
98
- });
123
+ }
124
+ } else if (this.iconRenderer) {
125
+ // Generate icon data URL from SenangStart icon name
126
+ try {
127
+ const dataUrl = await this.iconRenderer.generateIconDataUrl(icon, color, 128);
128
+ if (dataUrl) {
129
+ this.iconDataUrls.set(index, dataUrl);
130
+ // Preload the generated data URL as an asset
131
+ await this.assetManager.preloadImage(dataUrl, assetId);
132
+ }
133
+ } catch (err) {
134
+ console.warn(`Failed to generate icon for hotspot ${index}:`, err);
135
+ }
99
136
  }
100
- return Promise.resolve();
101
- });
102
-
103
- await Promise.all(iconPromises);
137
+ }
104
138
 
105
139
  // Then create the hotspot entities
106
140
  hotspots.forEach((hotspot, index) => {
@@ -121,26 +155,31 @@ export class HotspotManager {
121
155
  const pos = hotspot.position;
122
156
  hotspotEl.setAttribute('position', `${pos.x} ${pos.y} ${pos.z}`);
123
157
 
124
- // Set icon or fallback to plane with color
158
+ // Get icon and color
125
159
  const icon = hotspot.appearance?.icon || this.defaultSettings.icon;
126
- const assetId = `hotspot-icon-${index}`;
127
- const assetEl = icon ? document.getElementById(assetId) : null;
160
+ const color = hotspot.appearance?.color || '#4CC3D9';
161
+
162
+ // Check if we have a preloaded icon asset (either from URL or generated from icon name)
163
+ // Use the unique asset prefix that includes scene load counter
164
+ const assetId = `${this.currentAssetPrefix}-${index}`;
165
+ const assetsEl = this.sceneEl.querySelector('a-assets');
166
+ const assetEl = assetsEl ? assetsEl.querySelector(`#${assetId}`) : null;
128
167
 
129
168
  let visualEl;
130
169
 
131
- // Check if icon was successfully loaded
170
+ // Check if icon asset was successfully loaded/generated
132
171
  if (icon && assetEl) {
133
172
  visualEl = document.createElement('a-image');
134
173
  visualEl.setAttribute('src', `#${assetId}`);
135
174
  // Make images double-sided
136
- visualEl.setAttribute('material', 'side', 'double');
175
+ visualEl.setAttribute('material', 'side: double; transparent: true; alphaTest: 0.1');
137
176
  } else {
138
- // Fallback to a plane with color
177
+ // Fallback to a colored plane
139
178
  visualEl = document.createElement('a-plane');
140
- visualEl.setAttribute('color', hotspot.appearance?.color || '#4CC3D9');
179
+ visualEl.setAttribute('color', color);
141
180
  visualEl.setAttribute('width', '1');
142
181
  visualEl.setAttribute('height', '1');
143
- // Make plane double-sided and always face camera
182
+ // Make plane double-sided
144
183
  visualEl.setAttribute('material', 'side', 'double');
145
184
  }
146
185
 
@@ -168,6 +207,9 @@ export class HotspotManager {
168
207
  * Remove all active hotspots from the scene
169
208
  */
170
209
  removeAllHotspots() {
210
+ // Remove hotspot icon assets from a-assets to prevent icon mixup on scene navigation
211
+ this.removeHotspotIconAssets();
212
+
171
213
  this.activeHotspots.forEach(hotspot => {
172
214
  if (hotspot.parentNode) {
173
215
  hotspot.parentNode.removeChild(hotspot);
@@ -175,12 +217,43 @@ export class HotspotManager {
175
217
  });
176
218
  this.activeHotspots = [];
177
219
 
220
+ // Clear icon data URLs cache
221
+ this.iconDataUrls.clear();
222
+
178
223
  // Hide tooltip
179
224
  if (this.tooltipEl) {
180
225
  this.tooltipEl.setAttribute('visible', 'false');
181
226
  }
182
227
  }
183
228
 
229
+ /**
230
+ * Remove all hotspot icon assets from a-assets element
231
+ * This prevents icon mixup when navigating between scenes
232
+ */
233
+ removeHotspotIconAssets() {
234
+ const assetsEl = this.sceneEl.querySelector('a-assets');
235
+ if (!assetsEl) return;
236
+
237
+ // Find and remove all assets with IDs matching hotspot-icon-* pattern
238
+ const iconAssets = assetsEl.querySelectorAll('[id^="hotspot-icon-"]');
239
+ iconAssets.forEach(asset => {
240
+ if (asset.parentNode) {
241
+ asset.parentNode.removeChild(asset);
242
+ }
243
+ });
244
+
245
+ // Also clear the asset manager's loaded assets for hotspot icons
246
+ if (this.assetManager && this.assetManager.loadedAssets) {
247
+ const keysToDelete = [];
248
+ this.assetManager.loadedAssets.forEach((value, key) => {
249
+ if (key.startsWith('hotspot-icon-')) {
250
+ keysToDelete.push(key);
251
+ }
252
+ });
253
+ keysToDelete.forEach(key => this.assetManager.loadedAssets.delete(key));
254
+ }
255
+ }
256
+
184
257
  /**
185
258
  * Clean up the hotspot manager
186
259
  */
@@ -0,0 +1,123 @@
1
+ /**
2
+ * IconRenderer - Converts SenangStart icon names to image data URLs for A-Frame
3
+ */
4
+ export class IconRenderer {
5
+ constructor() {
6
+ this.iconCache = new Map();
7
+ this.renderContainer = null;
8
+ }
9
+
10
+ /**
11
+ * Initialize the hidden render container
12
+ */
13
+ init() {
14
+ if (this.renderContainer) return;
15
+
16
+ this.renderContainer = document.createElement('div');
17
+ this.renderContainer.id = 'swt-icon-renderer';
18
+ this.renderContainer.style.cssText = `
19
+ position: absolute;
20
+ left: -9999px;
21
+ top: -9999px;
22
+ width: 128px;
23
+ height: 128px;
24
+ pointer-events: none;
25
+ visibility: hidden;
26
+ `;
27
+ document.body.appendChild(this.renderContainer);
28
+ }
29
+
30
+ /**
31
+ * Generate an image data URL from a SenangStart icon
32
+ * @param {string} iconName - SenangStart icon name (e.g., 'arrow-right')
33
+ * @param {string} color - Hex color for the icon
34
+ * @param {number} size - Size in pixels (default 128)
35
+ * @returns {Promise<string>} - Data URL of the icon image
36
+ */
37
+ async generateIconDataUrl(iconName, color = '#ffffff', size = 128) {
38
+ const cacheKey = `${iconName}-${color}-${size}`;
39
+
40
+ if (this.iconCache.has(cacheKey)) {
41
+ return this.iconCache.get(cacheKey);
42
+ }
43
+
44
+ this.init();
45
+
46
+ return new Promise((resolve, reject) => {
47
+ // Create the ss-icon element
48
+ this.renderContainer.innerHTML = `
49
+ <ss-icon
50
+ icon="${iconName}"
51
+ thickness="2.5"
52
+ style="color: ${color}; width: ${size}px; height: ${size}px; display: block;"
53
+ ></ss-icon>
54
+ `;
55
+
56
+ // Wait for the custom element to render
57
+ setTimeout(() => {
58
+ try {
59
+ const ssIcon = this.renderContainer.querySelector('ss-icon');
60
+ if (!ssIcon || !ssIcon.shadowRoot) {
61
+ console.warn(`Icon ${iconName} not rendered properly`);
62
+ resolve(null);
63
+ return;
64
+ }
65
+
66
+ // Get the SVG from shadow root
67
+ const svg = ssIcon.shadowRoot.querySelector('svg');
68
+ if (!svg) {
69
+ console.warn(`SVG not found for icon ${iconName}`);
70
+ resolve(null);
71
+ return;
72
+ }
73
+
74
+ // Clone and prepare SVG
75
+ const svgClone = svg.cloneNode(true);
76
+ svgClone.setAttribute('width', size);
77
+ svgClone.setAttribute('height', size);
78
+ svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
79
+
80
+ // Apply the color to all paths/elements
81
+ svgClone.querySelectorAll('path, circle, rect, line, polyline, polygon').forEach(el => {
82
+ el.setAttribute('stroke', color);
83
+ // Keep fill as currentColor if it's set
84
+ const fill = el.getAttribute('fill');
85
+ if (fill && fill !== 'none') {
86
+ el.setAttribute('fill', color);
87
+ }
88
+ });
89
+
90
+ // Convert SVG to data URL
91
+ const svgString = new XMLSerializer().serializeToString(svgClone);
92
+ const dataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgString)));
93
+
94
+ // Cache the result
95
+ this.iconCache.set(cacheKey, dataUrl);
96
+
97
+ resolve(dataUrl);
98
+ } catch (error) {
99
+ console.error('Error generating icon data URL:', error);
100
+ resolve(null);
101
+ }
102
+ }, 100); // Wait for custom element to render
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Clear the icon cache
108
+ */
109
+ clearCache() {
110
+ this.iconCache.clear();
111
+ }
112
+
113
+ /**
114
+ * Destroy the renderer
115
+ */
116
+ destroy() {
117
+ if (this.renderContainer && this.renderContainer.parentNode) {
118
+ this.renderContainer.parentNode.removeChild(this.renderContainer);
119
+ }
120
+ this.renderContainer = null;
121
+ this.iconCache.clear();
122
+ }
123
+ }
@@ -62,15 +62,21 @@ export class SceneManager {
62
62
  * Transition to a new scene with fade effect
63
63
  * @param {string} sceneId - The target scene ID
64
64
  * @param {Object} sceneData - The scene configuration object
65
+ * @param {Function} onSceneLoaded - Optional callback to run after scene loads but before fade-in
65
66
  * @returns {Promise} - Resolves when the transition is complete
66
67
  */
67
- async transitionTo(sceneId, sceneData) {
68
+ async transitionTo(sceneId, sceneData, onSceneLoaded = null) {
68
69
  // Fade out
69
70
  await this.fadeOut();
70
71
 
71
72
  // Load new scene
72
73
  await this.loadScene(sceneId, sceneData);
73
74
 
75
+ // Call onSceneLoaded callback (e.g., to set camera position while screen is black)
76
+ if (onSceneLoaded && typeof onSceneLoaded === 'function') {
77
+ onSceneLoaded();
78
+ }
79
+
74
80
  // Fade in
75
81
  await this.fadeIn();
76
82
 
@@ -223,7 +223,6 @@ body {
223
223
 
224
224
  .scene-card.active {
225
225
  border-color: var(--accent-primary);
226
- background: rgba(59, 130, 246, 0.1);
227
226
  }
228
227
 
229
228
  .scene-thumbnail {
@@ -677,7 +676,6 @@ body {
677
676
 
678
677
  .hotspot-item.active {
679
678
  border-color: var(--accent-primary);
680
- background: rgba(59, 130, 246, 0.1);
681
679
  }
682
680
 
683
681
  .hotspot-color {
@@ -1020,6 +1018,47 @@ body {
1020
1018
  }
1021
1019
  }
1022
1020
 
1021
+ /* Icon Grid */
1022
+ .icon-grid {
1023
+ display: grid;
1024
+ grid-template-columns: repeat(5, 1fr);
1025
+ gap: 8px;
1026
+ margin-top: 8px;
1027
+ height: 280px;
1028
+ overflow-y: auto;
1029
+ }
1030
+
1031
+ /* Icon Grid Buttons */
1032
+ .icon-btn {
1033
+ width: 100%;
1034
+ aspect-ratio: 1;
1035
+ border: 2px solid var(--border-color);
1036
+ border-radius: 6px;
1037
+ background: var(--bg-secondary);
1038
+ cursor: pointer;
1039
+ display: flex;
1040
+ align-items: center;
1041
+ justify-content: center;
1042
+ transition: all 0.2s ease;
1043
+ color: var(--text-secondary);
1044
+ }
1045
+
1046
+ .icon-btn ss-icon {
1047
+ width: 20px;
1048
+ height: 20px;
1049
+ }
1050
+
1051
+ .icon-btn:hover {
1052
+ background: var(--bg-hover);
1053
+ border-color: var(--text-muted);
1054
+ }
1055
+
1056
+ .icon-btn.active {
1057
+ border-color: var(--accent-primary);
1058
+ background: rgba(0, 255, 153, 0.1);
1059
+ color: var(--accent-primary);
1060
+ }
1061
+
1023
1062
  /* Responsive */
1024
1063
  @media (max-width: 1024px) {
1025
1064
  .sidebar {
@@ -1,14 +1,12 @@
1
1
  // Main Editor Controller
2
- import { debounce, showModal } from './utils.js';
2
+ import { debounce, showModal, showToast } from './utils.js';
3
3
 
4
4
  class TourEditor {
5
5
  constructor(options = {}) {
6
6
  this.config = {
7
7
  title: options.projectName || 'My Virtual Tour',
8
8
  description: '',
9
- initialSceneId: '',
10
- autoRotate: false,
11
- showCompass: false
9
+ initialSceneId: ''
12
10
  };
13
11
 
14
12
  // Store initialization options
@@ -57,6 +55,9 @@ class TourEditor {
57
55
  // Setup event listeners
58
56
  this.setupEventListeners();
59
57
 
58
+ // Populate icon grid
59
+ this.uiController.populateIconGrid();
60
+
60
61
  // Load saved project if exists (but only if it has valid data)
61
62
  if (this.storageManager.hasProject()) {
62
63
  try {
@@ -131,7 +132,7 @@ class TourEditor {
131
132
  });
132
133
 
133
134
  document.getElementById('addHotspotBtn')?.addEventListener('click', () => {
134
- this.hotspotEditor.enablePlacementMode();
135
+ this.addHotspotAtCursor();
135
136
  });
136
137
 
137
138
  document.getElementById('clearHotspotsBtn')?.addEventListener('click', () => {
@@ -163,6 +164,19 @@ class TourEditor {
163
164
  this.updateCurrentHotspot('color', e.target.value);
164
165
  });
165
166
 
167
+ // Icon grid button clicks
168
+ document.getElementById('hotspotIconGrid')?.addEventListener('click', (e) => {
169
+ const btn = e.target.closest('.icon-btn');
170
+ if (btn) {
171
+ const iconValue = btn.dataset.icon;
172
+ // Update active state
173
+ document.querySelectorAll('#hotspotIconGrid .icon-btn').forEach(b => b.classList.remove('active'));
174
+ btn.classList.add('active');
175
+ // Update hotspot
176
+ this.updateCurrentHotspot('icon', iconValue);
177
+ }
178
+ });
179
+
166
180
  document.getElementById('hotspotPosX')?.addEventListener('input', debounce((e) => {
167
181
  this.updateCurrentHotspotPosition('x', parseFloat(e.target.value) || 0);
168
182
  }, 300));
@@ -187,6 +201,14 @@ class TourEditor {
187
201
  this.updateCurrentSceneImage(e.target.value);
188
202
  }, 300));
189
203
 
204
+ document.getElementById('setStartingPosBtn')?.addEventListener('click', () => {
205
+ this.setSceneStartingPosition();
206
+ });
207
+
208
+ document.getElementById('clearStartingPosBtn')?.addEventListener('click', () => {
209
+ this.clearSceneStartingPosition();
210
+ });
211
+
190
212
  document.getElementById('tourTitle')?.addEventListener('input', debounce((e) => {
191
213
  this.config.title = e.target.value;
192
214
  this.markUnsavedChanges();
@@ -214,16 +236,6 @@ class TourEditor {
214
236
  this.config.initialSceneId = e.target.value;
215
237
  this.markUnsavedChanges();
216
238
  });
217
-
218
- document.getElementById('tourAutoRotate')?.addEventListener('change', (e) => {
219
- this.config.autoRotate = e.target.checked;
220
- this.markUnsavedChanges();
221
- });
222
-
223
- document.getElementById('tourShowCompass')?.addEventListener('change', (e) => {
224
- this.config.showCompass = e.target.checked;
225
- this.markUnsavedChanges();
226
- });
227
239
 
228
240
  document.getElementById('exportJsonBtn')?.addEventListener('click', () => {
229
241
  this.exportManager.exportJSON();
@@ -233,8 +245,8 @@ class TourEditor {
233
245
  this.exportManager.copyJSON();
234
246
  });
235
247
 
236
- document.getElementById('exportViewerBtn')?.addEventListener('click', () => {
237
- this.exportManager.exportViewerHTML();
248
+ document.getElementById('exportViewerBtn')?.addEventListener('click', async () => {
249
+ await this.exportManager.exportViewerHTML();
238
250
  });
239
251
 
240
252
  document.querySelectorAll('.modal-close').forEach(btn => {
@@ -302,7 +314,15 @@ class TourEditor {
302
314
  position.y = parseFloat(position.y.toFixed(2));
303
315
  position.z = parseFloat(position.z.toFixed(2));
304
316
  }
305
- const hotspot = this.hotspotEditor.addHotspot(position);
317
+
318
+ // Capture current camera orientation for reliable pointing later
319
+ const cameraRotation = this.previewController.getCameraRotation();
320
+ const cameraOrientation = cameraRotation ? {
321
+ pitch: cameraRotation.x,
322
+ yaw: cameraRotation.y
323
+ } : null;
324
+
325
+ const hotspot = this.hotspotEditor.addHotspot(position, '', cameraOrientation);
306
326
  if (hotspot) {
307
327
  this.lastRenderedSceneIndex = -1;
308
328
  this.render();
@@ -312,6 +332,26 @@ class TourEditor {
312
332
  }
313
333
  }
314
334
 
335
+ /**
336
+ * Add hotspot at current cursor position (center of view)
337
+ * This uses the A-Cursor's raycaster intersection with the sky sphere
338
+ */
339
+ addHotspotAtCursor() {
340
+ const scene = this.sceneManager.getCurrentScene();
341
+ if (!scene) {
342
+ showToast('Please select a scene first', 'error');
343
+ return;
344
+ }
345
+
346
+ const position = this.previewController.getCursorIntersection();
347
+ if (!position) {
348
+ showToast('Could not get cursor position. Please ensure the preview is loaded.', 'error');
349
+ return;
350
+ }
351
+
352
+ this.addHotspotAtPosition(position);
353
+ }
354
+
315
355
  /**
316
356
  * Select scene by index
317
357
  */
@@ -347,8 +387,8 @@ class TourEditor {
347
387
  this.uiController.updateTargetSceneOptions();
348
388
  this.uiController.switchTab('hotspot');
349
389
 
350
- if (hotspot && hotspot.position) {
351
- this.previewController.pointCameraToHotspot(hotspot.position);
390
+ if (hotspot) {
391
+ this.previewController.pointCameraToHotspot(hotspot);
352
392
  }
353
393
  }
354
394
  }
@@ -422,6 +462,10 @@ class TourEditor {
422
462
 
423
463
  hotspot.position[axis] = value;
424
464
 
465
+ // Clear camera orientation since position changed manually
466
+ // Will fallback to position-based calculation when pointing camera
467
+ hotspot.cameraOrientation = null;
468
+
425
469
  const pos = hotspot.position;
426
470
  const distance = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z);
427
471
  if (distance > 10) {
@@ -476,6 +520,49 @@ class TourEditor {
476
520
  }
477
521
  }
478
522
 
523
+ /**
524
+ * Set scene starting position to current camera rotation
525
+ */
526
+ setSceneStartingPosition() {
527
+ const scene = this.sceneManager.getCurrentScene();
528
+ if (!scene) {
529
+ showToast('No scene selected', 'error');
530
+ return;
531
+ }
532
+
533
+ const rotation = this.previewController.getCameraRotation();
534
+ if (!rotation) {
535
+ showToast('Could not get camera rotation', 'error');
536
+ return;
537
+ }
538
+
539
+ scene.startingPosition = {
540
+ pitch: rotation.x,
541
+ yaw: rotation.y
542
+ };
543
+
544
+ this.uiController.updateSceneProperties(scene);
545
+ this.markUnsavedChanges();
546
+ showToast('Starting position set', 'success');
547
+ }
548
+
549
+ /**
550
+ * Clear scene starting position
551
+ */
552
+ clearSceneStartingPosition() {
553
+ const scene = this.sceneManager.getCurrentScene();
554
+ if (!scene) {
555
+ showToast('No scene selected', 'error');
556
+ return;
557
+ }
558
+
559
+ scene.startingPosition = null;
560
+
561
+ this.uiController.updateSceneProperties(scene);
562
+ this.markUnsavedChanges();
563
+ showToast('Starting position cleared', 'success');
564
+ }
565
+
479
566
  /**
480
567
  * Render all UI
481
568
  */
@@ -562,9 +649,7 @@ class TourEditor {
562
649
  this.config = {
563
650
  title: 'My Virtual Tour',
564
651
  description: '',
565
- initialSceneId: '',
566
- autoRotate: false,
567
- showCompass: false
652
+ initialSceneId: ''
568
653
  };
569
654
 
570
655
  this.sceneManager.clearScenes();