storysplat-viewer 2.9.13 → 2.9.15

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # StorySplat Viewer
2
2
 
3
- A powerful npm package for embedding and interacting with 3D Gaussian Splatting scenes in web applications. StorySplat Viewer provides a complete solution for displaying, navigating, and interacting with .splat, .ply, and .sog files with built-in support for waypoints, hotspots, and multiple camera modes.
3
+ A powerful npm package for embedding and interacting with 3D Gaussian Splatting scenes in web applications. StorySplat Viewer provides a complete solution for displaying, navigating, and interacting with .splat, .ply, and .sog files with built-in support for waypoints, hotspots, portals, audio, particles, LOD streaming, 4DGS frame sequences, and multiple camera modes.
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -13,13 +13,25 @@ A powerful npm package for embedding and interacting with 3D Gaussian Splatting
13
13
  - [API Reference](#api-reference)
14
14
  - [Controlling the Viewer](#controlling-the-viewer)
15
15
  - [Configuration Options](#configuration-options)
16
+ - [Scene Data Format](#scene-data-format)
17
+ - [LOD Streaming](#lod-streaming)
18
+ - [4DGS Frame Sequences](#4dgs-frame-sequences)
16
19
  - [Viewer Theme Customization](#viewer-theme-customization)
17
20
  - [Splat Relighting](#splat-relighting)
18
21
  - [Internationalization (i18n)](#internationalization-i18n)
19
22
  - [Events](#events)
23
+ - [Portals (Scene-to-Scene Navigation)](#portals-scene-to-scene-navigation)
24
+ - [Audio Emitters](#audio-emitters)
25
+ - [HTML Meshes](#html-meshes)
26
+ - [Voxel Collision](#voxel-collision)
20
27
  - [React Integration](#react-integration)
28
+ - [Native App Integration](#native-app-integration)
21
29
  - [Analytics & Tracking](#analytics--tracking)
30
+ - [Standalone HTML Generation](#standalone-html-generation)
31
+ - [Error Handling](#error-handling)
32
+ - [Exports](#exports)
22
33
  - [Troubleshooting](#troubleshooting)
34
+ - [Support](#support)
23
35
 
24
36
  ## Installation
25
37
 
@@ -114,31 +126,6 @@ const viewer = createViewer(
114
126
 
115
127
  > **Note:** When using `createViewer` with self-hosted scenes, views and bandwidth are not tracked. Use `createViewerFromSceneId` for StorySplat-hosted scenes to get analytics.
116
128
 
117
- ### Scene Data Format
118
-
119
- When using `createViewer()` with self-hosted JSON, the scene data should match the format exported by the StorySplat editor. Key fields:
120
-
121
- | Field | Type | Description |
122
- |-------|------|-------------|
123
- | `loadedModelUrl` | `string` | URL to the .splat, .ply, or .ksplat file |
124
- | `waypoints` | `array` | Camera path waypoints |
125
- | `hotspots` | `array` | Interactive hotspot markers |
126
- | `portals` | `array` | Scene-to-scene navigation portals |
127
- | `activeSkyboxUrl` | `string` | URL to the skybox HDR/image file |
128
- | `skyboxRotation` | `number` | Skybox rotation offset |
129
- | `lights` | `array` | Scene lighting configuration |
130
- | `particleSystems` | `array` | Particle effect systems |
131
- | `customMeshes` | `array` | Imported 3D models |
132
- | `uiColor` | `string` | Accent color for UI controls |
133
- | `uiOptions` | `object` | UI visibility toggles and button labels |
134
- | `uiOptions.viewerTheme` | `ViewerTheme` | Custom viewer theme overrides (colors, fonts, radii) |
135
- | `defaultCameraMode` | `string` | Initial camera mode: `'tour'`, `'explore'`, or `'walk'` |
136
- | `revealStyle` | `string` | Reveal animation style: `'bloom'` or `'radial'` |
137
- | `doubleTapMoveSpeed` | `number` | Auto-forward speed on double-tap in explore mode (default: 1.0) |
138
- | `splatRelighting` | `object` | Splat relighting config (enabled, ambientColor, ambientIntensity, allowViewerToggle, viewerDefaultOn) |
139
-
140
- The viewer's transform layer normalizes all field variations automatically, so JSON exported from any version of the editor will work.
141
-
142
129
  ## API Reference
143
130
 
144
131
  ### createViewerFromSceneId
@@ -230,7 +217,7 @@ interface ViewerInstance {
230
217
  getWaypointCount: () => number;
231
218
 
232
219
  // Camera
233
- setCameraMode: (mode: 'tour' | 'explore') => void;
220
+ setCameraMode: (mode: 'tour' | 'explore' | 'walk') => void;
234
221
  getCameraMode: () => string;
235
222
  setExploreMode: (mode: 'orbit' | 'fly') => void;
236
223
  setPosition: (x: number, y: number, z: number) => void;
@@ -253,6 +240,16 @@ interface ViewerInstance {
253
240
  isShowingOriginalSplat: () => boolean;
254
241
  getAdditionalSplats: () => Array<{ url: string; name?: string; waypointIndex: number; percentage: number }>;
255
242
 
243
+ // 4DGS Frame Sequence (only when frameSequence is configured)
244
+ isFrameSequencePlaying: () => boolean;
245
+ setFrame: (index: number) => void;
246
+ getCurrentFrame: () => number;
247
+ getTotalFrames: () => number;
248
+ setFps: (fps: number) => void;
249
+ getFps: () => number;
250
+ getFrameProgress: () => number;
251
+ setFrameProgress: (progress: number) => void;
252
+
256
253
  // Audio
257
254
  muteAll: () => void;
258
255
  unmuteAll: () => void;
@@ -279,6 +276,112 @@ interface ViewerInstance {
279
276
  }
280
277
  ```
281
278
 
279
+ ## Controlling the Viewer
280
+
281
+ The viewer instance provides methods to control playback, navigation, and camera programmatically. This is useful for building custom UI controls outside the viewer.
282
+
283
+ ### External Control Example
284
+
285
+ ```html
286
+ <div id="viewer" style="width: 100%; height: 500px;"></div>
287
+
288
+ <div id="controls">
289
+ <button id="prev">Previous</button>
290
+ <button id="play">Play</button>
291
+ <button id="pause">Pause</button>
292
+ <button id="next">Next</button>
293
+ <span id="waypoint-info"></span>
294
+ </div>
295
+
296
+ <script type="module">
297
+ import { createViewerFromSceneId } from 'storysplat-viewer';
298
+
299
+ const viewer = await createViewerFromSceneId(
300
+ document.getElementById('viewer'),
301
+ 'YOUR_SCENE_ID'
302
+ );
303
+
304
+ // Navigation
305
+ document.getElementById('prev').onclick = () => viewer.prevWaypoint();
306
+ document.getElementById('next').onclick = () => viewer.nextWaypoint();
307
+
308
+ // Playback
309
+ document.getElementById('play').onclick = () => viewer.play();
310
+ document.getElementById('pause').onclick = () => viewer.pause();
311
+
312
+ // Update waypoint display
313
+ viewer.on('waypointChange', ({ index }) => {
314
+ const total = viewer.getWaypointCount();
315
+ document.getElementById('waypoint-info').textContent =
316
+ `Waypoint ${index + 1} of ${total}`;
317
+ });
318
+
319
+ // Jump to specific waypoint
320
+ function goToWaypoint(index) {
321
+ viewer.goToWaypoint(index);
322
+ }
323
+ </script>
324
+ ```
325
+
326
+ ### Available Control Methods
327
+
328
+ | Method | Description |
329
+ |--------|-------------|
330
+ | **Navigation** | |
331
+ | `viewer.goToWaypoint(index)` | Jump to specific waypoint (0-indexed) |
332
+ | `viewer.nextWaypoint()` | Go to next waypoint |
333
+ | `viewer.prevWaypoint()` | Go to previous waypoint |
334
+ | `viewer.getCurrentWaypointIndex()` | Get current waypoint index |
335
+ | `viewer.getWaypointCount()` | Get total number of waypoints |
336
+ | **Camera** | |
337
+ | `viewer.setCameraMode(mode)` | Switch mode: `'tour'`, `'explore'`, or `'walk'` |
338
+ | `viewer.getCameraMode()` | Get current camera mode |
339
+ | `viewer.setExploreMode(mode)` | Switch explore sub-mode: `'orbit'` or `'fly'` |
340
+ | `viewer.setPosition(x, y, z)` | Set camera position |
341
+ | `viewer.setRotation(x, y, z)` | Set camera rotation (Euler angles) |
342
+ | `viewer.getPosition()` | Get current camera position |
343
+ | `viewer.getRotation()` | Get current camera rotation |
344
+ | **Playback / Progress** | |
345
+ | `viewer.play()` | Start auto-playing through waypoints |
346
+ | `viewer.pause()` | Pause auto-play |
347
+ | `viewer.stop()` | Stop and reset to first waypoint |
348
+ | `viewer.isPlaying()` | Check if currently auto-playing |
349
+ | `viewer.setProgress(progress)` | Set scroll progress (0-1) |
350
+ | `viewer.getProgress()` | Get current scroll progress (0-1) |
351
+ | **Splat Management** | |
352
+ | `viewer.goToSplat(url)` | Swap to a different splat file at runtime |
353
+ | `viewer.goToOriginalSplat()` | Switch back to the original splat |
354
+ | `viewer.getCurrentSplatUrl()` | Get the currently loaded splat URL |
355
+ | `viewer.isShowingOriginalSplat()` | Check if showing the original splat |
356
+ | `viewer.getAdditionalSplats()` | Get the list of additional splat swap points |
357
+ | **4DGS Frame Sequence** | |
358
+ | `viewer.isFrameSequencePlaying()` | Check if frame sequence is playing |
359
+ | `viewer.setFrame(index)` | Jump to specific frame |
360
+ | `viewer.getCurrentFrame()` | Get current frame index |
361
+ | `viewer.getTotalFrames()` | Get total frame count |
362
+ | `viewer.setFps(fps)` | Set playback FPS |
363
+ | `viewer.getFps()` | Get current FPS |
364
+ | `viewer.getFrameProgress()` | Get frame progress (0-1) |
365
+ | `viewer.setFrameProgress(progress)` | Set frame progress (0-1) |
366
+ | **Audio** | |
367
+ | `viewer.muteAll()` | Mute all audio |
368
+ | `viewer.unmuteAll()` | Unmute all audio |
369
+ | `viewer.isMuted()` | Check if audio is muted |
370
+ | **Hotspots** | |
371
+ | `viewer.getHotspots()` | Get all hotspot data (id, title, type, position) |
372
+ | `viewer.triggerHotspot(id)` | Programmatically open a hotspot popup |
373
+ | `viewer.closeHotspot()` | Close the currently open hotspot popup |
374
+ | **Portals** | |
375
+ | `viewer.navigateToScene(sceneId)` | Navigate to a linked scene via portal |
376
+ | **Lifecycle** | |
377
+ | `viewer.resize()` | Recalculate canvas size (call after container resize) |
378
+ | `viewer.destroy()` | Clean up and remove viewer |
379
+ | **UI** | |
380
+ | `viewer.setButtonLabels(labels)` | Update UI text labels at runtime (see [i18n](#internationalization-i18n)) |
381
+ | **Events** | |
382
+ | `viewer.on(event, callback)` | Register event listener |
383
+ | `viewer.off(event, callback)` | Remove event listener |
384
+
282
385
  ## Configuration Options
283
386
 
284
387
  ### ViewerOptions
@@ -304,6 +407,16 @@ interface ViewerOptions {
304
407
  lazyLoadThumbnail?: string;
305
408
  lazyLoadThumbnailType?: 'image' | 'video' | 'gif'; // Thumbnail media type
306
409
  lazyLoadButtonText?: string;
410
+
411
+ // Allow parent page CSS to affect the viewer
412
+ allowParentStyles?: boolean; // Default: false
413
+
414
+ // Manual analytics (alternative to createViewerFromSceneId auto-tracking)
415
+ analytics?: {
416
+ sceneId: string;
417
+ ownerId: string;
418
+ baseUrl?: string;
419
+ };
307
420
  }
308
421
  ```
309
422
 
@@ -330,6 +443,166 @@ const viewer = createViewer(container, sceneData, {
330
443
  });
331
444
  ```
332
445
 
446
+ ## Scene Data Format
447
+
448
+ When using `createViewer()` with self-hosted JSON, the scene data should match the format exported by the StorySplat editor. The viewer's transform layer normalizes all field variations automatically, so JSON exported from any version of the editor will work.
449
+
450
+ ### Core Fields
451
+
452
+ | Field | Type | Description |
453
+ |-------|------|-------------|
454
+ | `loadedModelUrl` | `string` | URL to the .splat, .ply, or .ksplat file |
455
+ | `sogModelUrl` / `sogUrl` | `string` | SOG compressed format URL (~95% smaller) |
456
+ | `compressedPlyUrl` | `string` | Compressed PLY format URL (~50% smaller) |
457
+ | `lodMetaUrl` | `string` | LOD streaming meta file URL (see [LOD Streaming](#lod-streaming)) |
458
+ | `waypoints` | `array` | Camera path waypoints |
459
+ | `hotspots` | `array` | Interactive hotspot markers |
460
+ | `portals` | `array` | Scene-to-scene navigation portals |
461
+ | `audioEmitters` | `array` | Spatial audio sources (see [Audio Emitters](#audio-emitters)) |
462
+ | `htmlMeshes` | `array` | HTML-in-3D texture panels (see [HTML Meshes](#html-meshes)) |
463
+ | `particleSystems` | `array` | Particle effect systems |
464
+ | `customMeshes` | `array` | Imported 3D models (.glb/.gltf) |
465
+ | `collisionMeshesData` | `array` | Primitive collision shapes |
466
+ | `voxelCollisionUrl` | `string` | Voxel octree collision data URL (see [Voxel Collision](#voxel-collision)) |
467
+ | `additionalSplats` | `array` | Splat swap points along the waypoint path |
468
+
469
+ ### Visual & Environment
470
+
471
+ | Field | Type | Description |
472
+ |-------|------|-------------|
473
+ | `activeSkyboxUrl` | `string` | URL to the skybox HDR/image file |
474
+ | `skyboxRotation` | `number` | Skybox rotation offset in radians |
475
+ | `lights` | `array` | Scene lighting configuration |
476
+ | `backgroundColor` | `string` | Background color (hex) |
477
+ | `splatRelighting` | `object` | Splat relighting config (see [Splat Relighting](#splat-relighting)) |
478
+
479
+ ### Camera & Navigation
480
+
481
+ | Field | Type | Description |
482
+ |-------|------|-------------|
483
+ | `defaultCameraMode` | `string` | Initial camera mode: `'tour'`, `'explore'`, or `'walk'` |
484
+ | `orbitCameraSettings` | `object` | Initial orbit camera position + pivot: `{ cameraPosition: {x,y,z}, pivotPoint: {x,y,z} }` |
485
+ | `doubleTapMoveSpeed` | `number` | Auto-forward speed on double-tap in explore mode (default: 1.0) |
486
+ | `refocusTapMode` | `string` | Camera refocus trigger: `'single'` or `'double'` (default: `'single'`) |
487
+ | `headBobEnabled` | `boolean` | Enable head bob in walk mode (default: true) |
488
+ | `lodSettings` | `object` | LOD display settings (see [LOD Streaming](#lod-streaming)) |
489
+
490
+ ### UI & Appearance
491
+
492
+ | Field | Type | Description |
493
+ |-------|------|-------------|
494
+ | `uiColor` | `string` | Accent color for UI controls |
495
+ | `uiOptions` | `object` | UI visibility toggles and button labels |
496
+ | `uiOptions.viewerTheme` | `ViewerTheme` | Custom viewer theme overrides (see [Theme Customization](#viewer-theme-customization)) |
497
+ | `uiOptions.buttonLabels` | `ButtonLabels` | UI text overrides (see [i18n](#internationalization-i18n)) |
498
+ | `revealStyle` | `string` | Reveal animation: `'bloom'` or `'radial'` |
499
+ | `swapTransitionType` | `string` | Splat swap transition: `'scanline'` or `'dissolve'` (default: `'dissolve'`) |
500
+
501
+ ### Model URL Priority
502
+
503
+ The viewer loads model files in this priority order:
504
+
505
+ 1. `lodMetaUrl` — LOD streaming (best quality, progressive loading)
506
+ 2. `sogModelUrl` / `sogUrl` — SOG compressed (~95% compression)
507
+ 3. `compressedPlyUrl` — Compressed PLY (~50% compression)
508
+ 4. `loadedModelUrl` — Original upload
509
+ 5. `splatUrl` — Legacy fallback
510
+
511
+ ## LOD Streaming
512
+
513
+ LOD (Level-of-Detail) streaming progressively loads splat data in chunks, starting with low-detail and refining to full quality. This dramatically improves initial load times for large scenes.
514
+
515
+ ### How It Works
516
+
517
+ The `lodMetaUrl` field points to a `lod-meta.json` file that references chunk files. Each chunk contains texture data (means, scales, quats, spherical harmonics) at different detail levels.
518
+
519
+ ### LOD Settings
520
+
521
+ Configure LOD display behavior via `lodSettings` in scene data:
522
+
523
+ ```javascript
524
+ const sceneData = {
525
+ lodMetaUrl: '/path/to/lod-meta.json',
526
+ lodSettings: {
527
+ preset: 'auto', // 'auto' | 'desktop-max' | 'desktop' | 'mobile-max' | 'mobile' | 'custom'
528
+ // Custom overrides (only used when preset is 'custom'):
529
+ lodDistances: [10, 20, 40, 80, 160], // 5 distance thresholds
530
+ lodRangeMin: 0, // Min LOD range (0-5)
531
+ lodRangeMax: 5, // Max LOD range (0-5)
532
+ // Per-device overrides when preset is 'auto':
533
+ mobilePreset: 'mobile', // Preset for mobile devices
534
+ desktopPreset: 'desktop', // Preset for desktop devices
535
+ }
536
+ };
537
+ ```
538
+
539
+ | Preset | Description |
540
+ |--------|-------------|
541
+ | `auto` | Automatically selects based on device type |
542
+ | `desktop-max` | Maximum quality for desktop |
543
+ | `desktop` | Balanced quality for desktop |
544
+ | `mobile-max` | Maximum quality for mobile |
545
+ | `mobile` | Balanced quality for mobile (recommended for mobile) |
546
+ | `custom` | Use custom `lodDistances` and `lodRange` values |
547
+
548
+ ## 4DGS Frame Sequences
549
+
550
+ 4DGS (4D Gaussian Splatting) enables playback of frame-by-frame splat animations — like video but in 3D.
551
+
552
+ ### Configuration
553
+
554
+ Add a `frameSequence` config to your scene data:
555
+
556
+ ```javascript
557
+ const sceneData = {
558
+ loadedModelUrl: '/frames/frame_000.ply', // First frame (also used as static fallback)
559
+ frameSequence: {
560
+ frameUrls: [
561
+ '/frames/frame_000.ply',
562
+ '/frames/frame_001.ply',
563
+ '/frames/frame_002.ply',
564
+ // ... up to N frames
565
+ ],
566
+ fps: 24, // Playback FPS (default: 24)
567
+ loop: true, // Loop playback (default: true)
568
+ preloadCount: 10, // Frames to preload ahead (default: 10)
569
+ autoplay: true, // Auto-play immediately (default: false)
570
+ }
571
+ };
572
+ ```
573
+
574
+ ### Controlling Frame Playback
575
+
576
+ ```javascript
577
+ const viewer = createViewer(container, sceneData);
578
+
579
+ // Frame navigation
580
+ viewer.setFrame(0); // Jump to first frame
581
+ viewer.getCurrentFrame(); // Get current frame index
582
+ viewer.getTotalFrames(); // Get total frame count
583
+ viewer.isFrameSequencePlaying(); // Check if playing
584
+
585
+ // Playback speed
586
+ viewer.setFps(30); // Change FPS
587
+ viewer.getFps(); // Get current FPS
588
+
589
+ // Progress (0-1)
590
+ viewer.setFrameProgress(0.5); // Jump to 50% through sequence
591
+ viewer.getFrameProgress(); // Get current progress
592
+ ```
593
+
594
+ ### 4DGS Events
595
+
596
+ ```javascript
597
+ viewer.on('frameChange', ({ frame, total }) => {
598
+ console.log(`Frame ${frame} of ${total}`);
599
+ });
600
+
601
+ viewer.on('frameComplete', () => {
602
+ console.log('Frame sequence finished (non-looping)');
603
+ });
604
+ ```
605
+
333
606
  ## Viewer Theme Customization
334
607
 
335
608
  The viewer supports deep CSS customization via the `ViewerTheme` interface. You can override colors, font sizes, border radii, and more — either via scene data or programmatically.
@@ -362,8 +635,8 @@ const viewer = createViewer(container, sceneData);
362
635
 
363
636
  | Category | Properties |
364
637
  |----------|-----------|
365
- | **Colors** (19) | `buttonBg`, `buttonHoverBg`, `buttonTextColor`, `popupBg`, `popupTextColor`, `popupCloseBtnColor`, `popupLinkBtnColor`, `preloaderBg`, `preloaderTextColor`, `infoBannerBg`, `dropdownBg`, `watermarkBg`, `helpPanelBg`, `errorPopupBg`, `errorTitleColor`, `lazyLoadBg`, `joystickBaseColor`, `joystickThumbColor`, `portalPopupBg` |
366
- | **Typography** (8) | `fontFamily`, `buttonFontSize`, `popupTitleFontSize`, `popupContentFontSize`, `infoBannerTitleFontSize`, `infoBannerContentFontSize`, `progressFontSize`, `modeBtnFontSize`, `watermarkFontSize` |
638
+ | **Colors** (20) | `globalTextColor`, `buttonBg`, `buttonHoverBg`, `buttonTextColor`, `popupBg`, `popupTextColor`, `popupCloseBtnColor`, `popupLinkBtnColor`, `preloaderBg`, `preloaderTextColor`, `infoBannerBg`, `dropdownBg`, `watermarkBg`, `helpPanelBg`, `errorPopupBg`, `errorTitleColor`, `lazyLoadBg`, `joystickBaseColor`, `joystickThumbColor`, `portalPopupBg` |
639
+ | **Typography** (9) | `fontFamily`, `buttonFontSize`, `popupTitleFontSize`, `popupContentFontSize`, `infoBannerTitleFontSize`, `infoBannerContentFontSize`, `progressFontSize`, `modeBtnFontSize`, `watermarkFontSize` |
367
640
  | **Border Radii** (6) | `buttonBorderRadius`, `popupBorderRadius`, `dropdownBorderRadius`, `helpPanelBorderRadius`, `errorPopupBorderRadius`, `lazyLoadBtnBorderRadius` |
368
641
  | **Other** (3) | `dropdownBlur`, `progressBarHeight`, `preloaderBarHeight` |
369
642
 
@@ -398,6 +671,10 @@ const sceneData = {
398
671
  ambientIntensity: 0.8, // 0-1, ambient fill strength
399
672
  allowViewerToggle: true, // Show toggle button in viewer UI
400
673
  viewerDefaultOn: false, // Start with relighting off for end users
674
+ shadowsEnabled: false, // Enable shadow casting
675
+ shadowIntensity: 0.5, // Shadow strength (0-1)
676
+ shadowGroundY: 0, // Ground plane Y position for shadows
677
+ shadowPlaneScale: 10, // Shadow receiving plane size
401
678
  }
402
679
  };
403
680
  ```
@@ -542,6 +819,11 @@ viewer.on('progressUpdate', ({ progress, index }) => {
542
819
  console.log(`Progress: ${(progress * 100).toFixed(0)}%, waypoint ${index}`);
543
820
  });
544
821
 
822
+ // Hotspot clicked
823
+ viewer.on('hotspotClick', ({ hotspot }) => {
824
+ console.log(`Hotspot clicked: ${hotspot.id} - ${hotspot.title}`);
825
+ });
826
+
545
827
  // XR sessions
546
828
  viewer.on('xrStart', ({ type }) => console.log(`Entered ${type}`));
547
829
  viewer.on('xrEnd', () => console.log('Exited XR'));
@@ -551,6 +833,14 @@ viewer.on('splatChange', ({ url, isOriginal }) => {
551
833
  console.log(`Splat changed: ${url} (original: ${isOriginal})`);
552
834
  });
553
835
 
836
+ // 4DGS frame sequence
837
+ viewer.on('frameChange', ({ frame, total }) => {
838
+ console.log(`Frame ${frame} of ${total}`);
839
+ });
840
+ viewer.on('frameComplete', () => {
841
+ console.log('Frame sequence complete');
842
+ });
843
+
554
844
  // Portal activated
555
845
  viewer.on('portalActivated', ({ sceneId }) => {
556
846
  console.log(`Navigating to scene: ${sceneId}`);
@@ -565,260 +855,256 @@ viewer.on('warning', ({ type, message }) => {
565
855
  });
566
856
  ```
567
857
 
568
- ## React Integration
858
+ ### All Events Reference
569
859
 
570
- ```jsx
571
- import { useEffect, useRef } from 'react';
572
- import { createViewerFromSceneId } from 'storysplat-viewer';
860
+ | Event | Data | Description |
861
+ |-------|------|-------------|
862
+ | `ready` | | Viewer initialized |
863
+ | `loaded` | — | Scene fully loaded |
864
+ | `progress` | `{ loaded, total, percent }` | Loading progress |
865
+ | `waypointChange` | `{ index, waypoint }` | Waypoint changed |
866
+ | `playbackStart` | — | Auto-play started |
867
+ | `playbackStop` | — | Auto-play paused/stopped |
868
+ | `playbackComplete` | — | Tour reached the end |
869
+ | `modeChange` | `{ mode }` | Camera mode changed |
870
+ | `progressUpdate` | `{ progress, index }` | Scroll progress updated |
871
+ | `hotspotClick` | `{ hotspot }` | Hotspot clicked |
872
+ | `xrStart` | `{ type }` | Entered VR/AR |
873
+ | `xrEnd` | — | Exited VR/AR |
874
+ | `splatChange` | `{ url, isOriginal }` | Splat file swapped |
875
+ | `frameChange` | `{ frame, total }` | 4DGS frame changed |
876
+ | `frameComplete` | — | 4DGS sequence finished |
877
+ | `portalActivated` | `{ portalId, targetSceneId, targetSceneName }` | Portal triggered |
878
+ | `portalNavigationStart` | `{ targetSceneId }` | Portal navigation beginning |
879
+ | `portalNavigationComplete` | `{ sceneId }` | Portal navigation complete |
880
+ | `portalNavigationError` | `{ error, targetSceneId }` | Portal navigation failed |
881
+ | `error` | `Error` | Viewer error |
882
+ | `warning` | `{ type, message }` | Non-fatal warning |
573
883
 
574
- function StorySplatViewer({ sceneId }) {
575
- const containerRef = useRef(null);
576
- const viewerRef = useRef(null);
884
+ ## Portals (Scene-to-Scene Navigation)
577
885
 
578
- useEffect(() => {
579
- let mounted = true;
886
+ Portals allow users to navigate between multiple 3D scenes by clicking or walking near portal markers.
580
887
 
581
- async function initViewer() {
582
- if (!containerRef.current) return;
888
+ ### How Portals Work
583
889
 
584
- try {
585
- const viewer = await createViewerFromSceneId(
586
- containerRef.current,
587
- sceneId
588
- );
890
+ Portals are configured in the scene JSON with a `targetSceneId`:
589
891
 
590
- if (mounted) {
591
- viewerRef.current = viewer;
592
- } else {
593
- viewer.destroy();
594
- }
595
- } catch (error) {
596
- console.error('Failed to create viewer:', error);
597
- }
892
+ ```json
893
+ {
894
+ "portals": [
895
+ {
896
+ "id": "portal-1",
897
+ "targetSceneId": "abc123xyz",
898
+ "targetSceneName": "Campervan Interior",
899
+ "targetSceneThumbnail": "https://example.com/thumb.jpg",
900
+ "position": { "x": 2, "y": 0, "z": -3 },
901
+ "type": "sphere",
902
+ "activationMode": "click",
903
+ "confirmNavigation": true,
904
+ "menuOnly": false,
905
+ "menuPath": "Building A/Floor 1"
598
906
  }
907
+ ]
908
+ }
909
+ ```
599
910
 
600
- initViewer();
911
+ ### Portal Fields
601
912
 
602
- return () => {
603
- mounted = false;
604
- viewerRef.current?.destroy();
605
- };
606
- }, [sceneId]);
913
+ | Field | Type | Description |
914
+ |-------|------|-------------|
915
+ | `id` | `string` | Unique portal identifier |
916
+ | `targetSceneId` | `string` | Scene ID to navigate to |
917
+ | `targetSceneName` | `string` | Display name for the target scene |
918
+ | `targetSceneThumbnail` | `string` | Thumbnail URL for portal preview |
919
+ | `position` | `{x, y, z}` | 3D position of the portal marker |
920
+ | `type` | `string` | Portal mesh shape (`'sphere'`, `'cube'`, etc.) |
921
+ | `activationMode` | `string` | `'click'` or `'proximity'` |
922
+ | `confirmNavigation` | `boolean` | Show confirmation dialog before navigating |
923
+ | `menuOnly` | `boolean` | If true, portal appears only in the scene menu, not as a 3D mesh |
924
+ | `menuPath` | `string` | Folder path for menu organization (e.g., `"Building A/Floor 1"`) |
607
925
 
608
- return (
609
- <div
610
- ref={containerRef}
611
- style={{ width: '100%', height: '500px' }}
612
- />
613
- );
614
- }
926
+ ### Self-Hosted Portal Navigation
615
927
 
616
- export default StorySplatViewer;
617
- ```
928
+ By default, portals use StorySplat scene IDs and fetch from `discover.storysplat.com`. For fully self-hosted portals, intercept the `portalActivated` event:
618
929
 
619
- ## Error Handling
930
+ ```javascript
931
+ import { createViewer } from 'storysplat-viewer';
620
932
 
621
- The package exports error classes for specific error handling:
933
+ const SCENES = {
934
+ 'campervan': '/scenes/campervan/scene.json',
935
+ 'cabin': '/scenes/cabin/scene.json',
936
+ };
622
937
 
623
- ```javascript
624
- import {
625
- createViewerFromSceneId,
626
- SceneNotFoundError,
627
- SceneApiError
628
- } from 'storysplat-viewer';
938
+ let currentViewer = null;
629
939
 
630
- try {
631
- const viewer = await createViewerFromSceneId(container, sceneId);
632
- } catch (error) {
633
- if (error instanceof SceneNotFoundError) {
634
- console.error('Scene not found or not public');
635
- } else if (error instanceof SceneApiError) {
636
- console.error(`API error (${error.statusCode}): ${error.message}`);
637
- } else {
638
- console.error('Unknown error:', error);
639
- }
640
- }
641
- ```
940
+ async function loadScene(sceneId) {
941
+ if (currentViewer) currentViewer.destroy();
642
942
 
643
- ## Controlling the Viewer
943
+ const sceneData = await fetch(SCENES[sceneId]).then(r => r.json());
944
+ currentViewer = await createViewer(container, sceneData);
644
945
 
645
- The viewer instance provides methods to control playback, navigation, and camera programmatically. This is useful for building custom UI controls outside the viewer.
946
+ currentViewer.on('portalActivated', (data) => {
947
+ loadScene(data.targetSceneId);
948
+ });
949
+ }
646
950
 
647
- ### External Control Example
951
+ loadScene('campervan');
952
+ ```
648
953
 
649
- ```html
650
- <div id="viewer" style="width: 100%; height: 500px;"></div>
954
+ The `targetSceneId` is just a string you define — it doesn't need to exist on StorySplat. Map it to your own file paths.
651
955
 
652
- <div id="controls">
653
- <button id="prev">Previous</button>
654
- <button id="play">Play</button>
655
- <button id="pause">Pause</button>
656
- <button id="next">Next</button>
657
- <span id="waypoint-info"></span>
658
- </div>
956
+ ### Portal Events
659
957
 
660
- <script type="module">
661
- import { createViewerFromSceneId } from 'storysplat-viewer';
958
+ | Event | Description | Data |
959
+ |-------|-------------|------|
960
+ | `portalActivated` | Portal clicked or proximity triggered | `{ portalId, targetSceneId, targetSceneName }` |
961
+ | `portalNavigationStart` | Navigation beginning | `{ targetSceneId }` |
962
+ | `portalNavigationComplete` | New scene loaded | `{ sceneId }` |
963
+ | `portalNavigationError` | Navigation failed | `{ error, targetSceneId }` |
662
964
 
663
- const viewer = await createViewerFromSceneId(
664
- document.getElementById('viewer'),
665
- 'YOUR_SCENE_ID'
666
- );
965
+ ## Audio Emitters
667
966
 
668
- // Navigation
669
- document.getElementById('prev').onclick = () => viewer.prevWaypoint();
670
- document.getElementById('next').onclick = () => viewer.nextWaypoint();
967
+ Audio emitters are standalone spatial audio sources positioned in 3D space. They support distance-based attenuation and spatialization.
671
968
 
672
- // Playback
673
- document.getElementById('play').onclick = () => viewer.play();
674
- document.getElementById('pause').onclick = () => viewer.pause();
675
-
676
- // Update waypoint display
677
- viewer.on('waypointChange', ({ index }) => {
678
- const total = viewer.getWaypointCount();
679
- document.getElementById('waypoint-info').textContent =
680
- `Waypoint ${index + 1} of ${total}`;
681
- });
682
-
683
- // Jump to specific waypoint
684
- function goToWaypoint(index) {
685
- viewer.goToWaypoint(index);
969
+ ```json
970
+ {
971
+ "audioEmitters": [
972
+ {
973
+ "id": "birds",
974
+ "name": "Bird Sounds",
975
+ "url": "https://example.com/birds.mp3",
976
+ "position": { "x": 5, "y": 2, "z": -3 },
977
+ "volume": 0.8,
978
+ "loop": true,
979
+ "autoplay": true,
980
+ "spatialSound": true,
981
+ "distanceModel": "inverse",
982
+ "maxDistance": 50,
983
+ "refDistance": 1,
984
+ "rolloffFactor": 1,
985
+ "enabled": true
986
+ }
987
+ ]
686
988
  }
687
- </script>
688
989
  ```
689
990
 
690
- ### Available Control Methods
691
-
692
- | Method | Description |
693
- |--------|-------------|
694
- | **Navigation** | |
695
- | `viewer.goToWaypoint(index)` | Jump to specific waypoint (0-indexed) |
696
- | `viewer.nextWaypoint()` | Go to next waypoint |
697
- | `viewer.prevWaypoint()` | Go to previous waypoint |
698
- | `viewer.getCurrentWaypointIndex()` | Get current waypoint index |
699
- | `viewer.getWaypointCount()` | Get total number of waypoints |
700
- | **Camera** | |
701
- | `viewer.setCameraMode(mode)` | Switch mode: `'tour'` or `'explore'` |
702
- | `viewer.getCameraMode()` | Get current camera mode |
703
- | `viewer.setExploreMode(mode)` | Switch explore sub-mode: `'orbit'` or `'fly'` |
704
- | `viewer.setPosition(x, y, z)` | Set camera position |
705
- | `viewer.setRotation(x, y, z)` | Set camera rotation (Euler angles) |
706
- | `viewer.getPosition()` | Get current camera position |
707
- | `viewer.getRotation()` | Get current camera rotation |
708
- | **Playback / Progress** | |
709
- | `viewer.play()` | Start auto-playing through waypoints |
710
- | `viewer.pause()` | Pause auto-play |
711
- | `viewer.stop()` | Stop and reset to first waypoint |
712
- | `viewer.isPlaying()` | Check if currently auto-playing |
713
- | `viewer.setProgress(progress)` | Set scroll progress (0-1) |
714
- | `viewer.getProgress()` | Get current scroll progress (0-1) |
715
- | **Splat Management** | |
716
- | `viewer.goToSplat(url)` | Swap to a different splat file at runtime |
717
- | `viewer.goToOriginalSplat()` | Switch back to the original splat |
718
- | `viewer.getCurrentSplatUrl()` | Get the currently loaded splat URL |
719
- | `viewer.isShowingOriginalSplat()` | Check if showing the original splat |
720
- | `viewer.getAdditionalSplats()` | Get the list of additional splat swap points |
721
- | **Audio** | |
722
- | `viewer.muteAll()` | Mute all audio |
723
- | `viewer.unmuteAll()` | Unmute all audio |
724
- | `viewer.isMuted()` | Check if audio is muted |
725
- | **Hotspots** | |
726
- | `viewer.getHotspots()` | Get all hotspot data (id, title, type, position) |
727
- | `viewer.triggerHotspot(id)` | Programmatically open a hotspot popup |
728
- | `viewer.closeHotspot()` | Close the currently open hotspot popup |
729
- | **Portals** | |
730
- | `viewer.navigateToScene(sceneId)` | Navigate to a linked scene via portal |
731
- | **Lifecycle** | |
732
- | `viewer.resize()` | Recalculate canvas size (call after container resize) |
733
- | `viewer.destroy()` | Clean up and remove viewer |
734
- | **UI** | |
735
- | `viewer.setButtonLabels(labels)` | Update UI text labels at runtime (see [i18n](#internationalization-i18n)) |
736
-
737
- ## Analytics & Tracking
991
+ | Field | Type | Default | Description |
992
+ |-------|------|---------|-------------|
993
+ | `id` | `string` | — | Unique identifier |
994
+ | `name` | `string` | — | Display name |
995
+ | `url` | `string` | — | Audio file URL |
996
+ | `position` | `{x, y, z}` | `{0,0,0}` | 3D position |
997
+ | `volume` | `number` | `1` | Volume (0-1) |
998
+ | `loop` | `boolean` | `false` | Loop playback |
999
+ | `autoplay` | `boolean` | `false` | Play automatically on load |
1000
+ | `spatialSound` | `boolean` | `true` | Enable 3D spatial audio |
1001
+ | `distanceModel` | `string` | `'inverse'` | `'inverse'`, `'linear'`, or `'exponential'` |
1002
+ | `maxDistance` | `number` | `100` | Maximum hearing distance |
1003
+ | `refDistance` | `number` | `1` | Distance at which volume is 100% |
1004
+ | `rolloffFactor` | `number` | `1` | How quickly volume decreases with distance |
1005
+ | `enabled` | `boolean` | `true` | Whether this emitter is active |
1006
+
1007
+ ## HTML Meshes
1008
+
1009
+ HTML Meshes render HTML content as textures on 3D planes in the scene. They support text, images, iframes, CSS styling, and interactive elements.
738
1010
 
739
- When using `createViewerFromSceneId`, the viewer automatically tracks:
740
-
741
- - **Views**: Each time a scene is loaded
742
- - **Bandwidth**: Data transferred when loading StorySplat-hosted files
743
-
744
- This data appears in your StorySplat admin dashboard.
745
-
746
- ### What Gets Tracked
747
-
748
- | Scenario | Views | Bandwidth |
749
- |----------|-------|-----------|
750
- | `createViewerFromSceneId` with StorySplat-hosted files | Yes | Yes |
751
- | `createViewerFromSceneId` with self-hosted splat files | Yes | No |
752
- | `createViewer` (self-hosted scenes) | No | No |
753
- | iframe embed | Yes | Yes |
754
-
755
- ### Privacy Note
756
-
757
- Tracking only occurs for scenes loaded via `createViewerFromSceneId`. If you use `createViewer` with your own scene data, no data is sent to StorySplat servers.
758
-
759
- ## Comparison: Scene ID vs JSON File
1011
+ ```json
1012
+ {
1013
+ "htmlMeshes": [
1014
+ {
1015
+ "id": "info-panel",
1016
+ "name": "Info Panel",
1017
+ "htmlContent": "<h2>Welcome</h2><p>This is a 3D info panel</p>",
1018
+ "position": { "x": 0, "y": 2, "z": -5 },
1019
+ "rotation": { "x": 0, "y": 0, "z": 0 },
1020
+ "scale": { "x": 2, "y": 1.5, "z": 1 },
1021
+ "width": 512,
1022
+ "height": 384,
1023
+ "billboard": false,
1024
+ "cssStyles": "background: rgba(0,0,0,0.8); color: white; padding: 20px; font-family: sans-serif;",
1025
+ "visible": true
1026
+ }
1027
+ ]
1028
+ }
1029
+ ```
760
1030
 
761
- | Approach | Best For | Pros | Cons |
762
- |----------|----------|------|------|
763
- | **Scene ID** | Live content, CMS | Always latest, automatic tracking, no redeploy | Needs internet, API dependency |
764
- | **JSON File** | Self-hosted, offline | Full control, git-tracked, no external dependencies | Manual updates, no analytics |
1031
+ | Field | Type | Description |
1032
+ |-------|------|-------------|
1033
+ | `htmlContent` | `string` | HTML to render as texture |
1034
+ | `position` / `rotation` / `scale` | `{x,y,z}` | 3D transform |
1035
+ | `width` / `height` | `number` | Texture resolution in pixels |
1036
+ | `billboard` | `boolean` | Always face the camera |
1037
+ | `cssStyles` | `string` | CSS to apply to the HTML content |
1038
+ | `visible` | `boolean` | Whether the mesh is visible |
765
1039
 
766
- ## Troubleshooting
1040
+ ## Voxel Collision
767
1041
 
768
- ### Container Needs Dimensions
1042
+ Voxel collision provides high-fidelity collision detection derived directly from splat geometry. Instead of placing primitive collision shapes manually, the system voxelizes the splat into an octree structure for accurate ground detection and movement blocking in walk mode.
769
1043
 
770
- The container element must have explicit dimensions:
1044
+ ### Scene Data
771
1045
 
772
- ```css
773
- #viewer {
774
- width: 100%;
775
- height: 500px; /* Must have explicit height */
1046
+ ```json
1047
+ {
1048
+ "voxelCollisionUrl": "https://example.com/scene.voxel.json"
776
1049
  }
777
1050
  ```
778
1051
 
779
- ### CORS Issues
1052
+ The voxel collision system requires two co-located files:
1053
+ - `.voxel.json` — Metadata (bounds, resolution, leaf count)
1054
+ - `.voxel.bin` — Binary octree data (nodes + leaf data)
780
1055
 
781
- If loading splats from a different domain, ensure the server has proper CORS headers or use a proxy.
1056
+ Both primitive collision meshes and voxel collision coexist primitives are checked first, then the octree.
782
1057
 
783
- ### Memory Cleanup
1058
+ ## React Integration
784
1059
 
785
- Always destroy the viewer when unmounting:
1060
+ ```jsx
1061
+ import { useEffect, useRef } from 'react';
1062
+ import { createViewerFromSceneId } from 'storysplat-viewer';
786
1063
 
787
- ```javascript
788
- // Vanilla JS
789
- window.addEventListener('beforeunload', () => {
790
- viewer.destroy();
791
- });
1064
+ function StorySplatViewer({ sceneId }) {
1065
+ const containerRef = useRef(null);
1066
+ const viewerRef = useRef(null);
792
1067
 
793
- // React useEffect cleanup
794
- return () => {
795
- viewer?.destroy();
796
- };
797
- ```
1068
+ useEffect(() => {
1069
+ let mounted = true;
798
1070
 
799
- ### Build Target Must Be ES2016+
1071
+ async function initViewer() {
1072
+ if (!containerRef.current) return;
800
1073
 
801
- If you bundle `storysplat-viewer` with Vite, esbuild, or another bundler, your build target **must be `es2016` or higher** (we recommend `es2020`).
1074
+ try {
1075
+ const viewer = await createViewerFromSceneId(
1076
+ containerRef.current,
1077
+ sceneId
1078
+ );
802
1079
 
803
- PlayCanvas (the 3D engine) serializes its gsplat sort worker via `Function.toString()` into a blob URL. If your bundler targets `es2015`, it will extract the `**` (exponentiation) operator into a module-scoped helper function. That helper doesn't exist inside the worker's isolated scope, causing a `ReferenceError` at runtime (e.g., `ss is not defined`).
1080
+ if (mounted) {
1081
+ viewerRef.current = viewer;
1082
+ } else {
1083
+ viewer.destroy();
1084
+ }
1085
+ } catch (error) {
1086
+ console.error('Failed to create viewer:', error);
1087
+ }
1088
+ }
804
1089
 
805
- ```js
806
- // vite.config.ts
807
- export default {
808
- build: {
809
- target: 'es2020', // Must be es2016+ (NOT es2015)
810
- }
811
- };
812
- ```
1090
+ initViewer();
813
1091
 
814
- This only affects production builds — development servers typically don't minify and won't trigger the issue.
1092
+ return () => {
1093
+ mounted = false;
1094
+ viewerRef.current?.destroy();
1095
+ };
1096
+ }, [sceneId]);
815
1097
 
816
- ### Browser Compatibility
1098
+ return (
1099
+ <div
1100
+ ref={containerRef}
1101
+ style={{ width: '100%', height: '500px' }}
1102
+ />
1103
+ );
1104
+ }
817
1105
 
818
- StorySplat Viewer requires:
819
- - WebGL 2.0 support
820
- - Modern JavaScript (ES2016+)
821
- - Recommended browsers: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+
1106
+ export default StorySplatViewer;
1107
+ ```
822
1108
 
823
1109
  ## Native App Integration
824
1110
 
@@ -932,39 +1218,64 @@ For simple web integration, use an iframe:
932
1218
  </iframe>
933
1219
  ```
934
1220
 
935
- ### Self-Hosting
1221
+ ### CDN Options
936
1222
 
937
- To self-host the viewer bundle instead of using a CDN:
1223
+ The viewer is available on multiple CDNs. **We recommend the bundled build for simplicity** it includes PlayCanvas, so you only need one script:
1224
+
1225
+ ```html
1226
+ <!-- RECOMMENDED: Bundled build (includes PlayCanvas - single script!) -->
1227
+ <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
1228
+
1229
+ <!-- Alternative: Separate scripts (if you need a specific PlayCanvas version) -->
1230
+ <script src="https://cdn.jsdelivr.net/npm/playcanvas@2.14.3/build/playcanvas.min.js"></script>
1231
+ <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.umd.js"></script>
1232
+
1233
+ <!-- unpkg alternative -->
1234
+ <script src="https://unpkg.com/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
1235
+ ```
1236
+
1237
+ ### Self-Hosting the Bundle
938
1238
 
939
- 1. Download the UMD bundle from npm:
940
1239
  ```bash
941
1240
  npm pack storysplat-viewer
942
1241
  # Extract storysplat-viewer-x.x.x.tgz
943
- # Copy dist/storysplat-viewer.umd.js to your server
1242
+ # Copy dist/storysplat-viewer.bundled.umd.js to your server
944
1243
  ```
945
1244
 
946
- 2. Reference your self-hosted bundle:
947
1245
  ```html
948
- <script src="/path/to/storysplat-viewer.umd.js"></script>
1246
+ <script src="/path/to/storysplat-viewer.bundled.umd.js"></script>
949
1247
  ```
950
1248
 
951
- ### CDN Alternatives
1249
+ ## Analytics & Tracking
952
1250
 
953
- The viewer is available on multiple CDNs. **We recommend the bundled build for simplicity** - it includes PlayCanvas, so you only need one script:
1251
+ When using `createViewerFromSceneId`, the viewer automatically tracks:
954
1252
 
955
- ```html
956
- <!-- RECOMMENDED: Bundled build (includes PlayCanvas - single script!) -->
957
- <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
1253
+ - **Views**: Each time a scene is loaded
1254
+ - **Bandwidth**: Data transferred when loading StorySplat-hosted files
958
1255
 
959
- <!-- Alternative: Separate scripts (if you need a specific PlayCanvas version) -->
960
- <script src="https://cdn.jsdelivr.net/npm/playcanvas@2.14.3/build/playcanvas.min.js"></script>
961
- <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.umd.js"></script>
1256
+ This data appears in your StorySplat admin dashboard.
962
1257
 
963
- <!-- unpkg alternative -->
964
- <script src="https://unpkg.com/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
965
- ```
1258
+ ### What Gets Tracked
1259
+
1260
+ | Scenario | Views | Bandwidth |
1261
+ |----------|-------|-----------|
1262
+ | `createViewerFromSceneId` with StorySplat-hosted files | Yes | Yes |
1263
+ | `createViewerFromSceneId` with self-hosted splat files | Yes | No |
1264
+ | `createViewer` (self-hosted scenes) | No | No |
1265
+ | iframe embed | Yes | Yes |
1266
+
1267
+ ### Privacy Note
1268
+
1269
+ Tracking only occurs for scenes loaded via `createViewerFromSceneId`. If you use `createViewer` with your own scene data, no data is sent to StorySplat servers.
966
1270
 
967
- ### Standalone HTML Generation
1271
+ ### Comparison: Scene ID vs JSON File
1272
+
1273
+ | Approach | Best For | Pros | Cons |
1274
+ |----------|----------|------|------|
1275
+ | **Scene ID** | Live content, CMS | Always latest, automatic tracking, no redeploy | Needs internet, API dependency |
1276
+ | **JSON File** | Self-hosted, offline | Full control, git-tracked, no external dependencies | Manual updates, no analytics |
1277
+
1278
+ ## Standalone HTML Generation
968
1279
 
969
1280
  Generate standalone HTML files programmatically:
970
1281
 
@@ -988,7 +1299,7 @@ import fs from 'fs';
988
1299
  fs.writeFileSync('scene.html', html);
989
1300
  ```
990
1301
 
991
- #### GenerateHTMLOptions
1302
+ ### GenerateHTMLOptions
992
1303
 
993
1304
  ```typescript
994
1305
  interface GenerateHTMLOptions {
@@ -1003,208 +1314,32 @@ interface GenerateHTMLOptions {
1003
1314
  }
1004
1315
  ```
1005
1316
 
1006
- ## Portals (Scene-to-Scene Navigation)
1007
-
1008
- Portals allow users to navigate between multiple 3D scenes by clicking or walking near portal markers.
1009
-
1010
- ### How Portals Work
1011
-
1012
- Portals are configured in the scene JSON with a `targetSceneId`:
1013
-
1014
- ```json
1015
- {
1016
- "portals": [
1017
- {
1018
- "id": "portal-1",
1019
- "targetSceneId": "abc123xyz",
1020
- "targetSceneName": "Campervan Interior",
1021
- "position": { "x": 2, "y": 0, "z": -3 },
1022
- "type": "sphere",
1023
- "activationMode": "click"
1024
- }
1025
- ]
1026
- }
1027
- ```
1028
-
1029
- ### Portal Paths for Self-Hosted Scenes
1030
-
1031
- **Important:** By default, portals use StorySplat scene IDs and fetch from `discover.storysplat.com`. For fully self-hosted portals, you need to intercept the `portalActivated` event and implement custom navigation.
1032
-
1033
- #### Self-Hosted Portal Example
1034
-
1035
- ```javascript
1036
- import { createViewer } from 'storysplat-viewer';
1037
-
1038
- // Your self-hosted scene folder structure:
1039
- // /scenes/
1040
- // ├── scene_campervan/
1041
- // │ └── scene.json
1042
- // ├── scene_cabin/
1043
- // │ └── scene.json
1044
- // └── scene_garden/
1045
- // └── scene.json
1046
-
1047
- // Load initial scene
1048
- const initialScene = await fetch('/scenes/scene_campervan/scene.json').then(r => r.json());
1049
- let viewer = await createViewer(container, initialScene);
1050
-
1051
- // Handle portal navigation with custom paths
1052
- viewer.on('portalActivated', async (data) => {
1053
- console.log('Portal clicked:', data.targetSceneId);
1054
-
1055
- // Map scene IDs to your folder paths
1056
- const scenePaths = {
1057
- 'cabin-scene-id': '/scenes/scene_cabin/scene.json',
1058
- 'garden-scene-id': '/scenes/scene_garden/scene.json',
1059
- // Or use the scene ID directly as folder name:
1060
- // [data.targetSceneId]: `/scenes/${data.targetSceneId}/scene.json`
1061
- };
1062
-
1063
- const scenePath = scenePaths[data.targetSceneId];
1064
- if (!scenePath) {
1065
- console.error('Unknown scene:', data.targetSceneId);
1066
- return;
1067
- }
1068
-
1069
- // Fetch from YOUR server
1070
- const newSceneData = await fetch(scenePath).then(r => r.json());
1071
-
1072
- // Dispose old viewer and create new one
1073
- viewer.destroy();
1074
- viewer = await createViewer(container, newSceneData);
1075
-
1076
- // Re-attach portal handler for the new scene
1077
- viewer.on('portalActivated', arguments.callee);
1078
- });
1079
- ```
1080
-
1081
- #### Using Relative Paths
1082
-
1083
- If you want to use the `targetSceneId` directly as a folder name:
1084
-
1085
- ```javascript
1086
- viewer.on('portalActivated', async (data) => {
1087
- // targetSceneId becomes the folder name
1088
- const scenePath = `/scenes/${data.targetSceneId}/scene.json`;
1089
-
1090
- const newSceneData = await fetch(scenePath).then(r => r.json());
1091
- viewer.destroy();
1092
- viewer = await createViewer(container, newSceneData);
1093
- });
1094
- ```
1095
-
1096
- Then structure your folders to match:
1097
- ```
1098
- /scenes/
1099
- ├── campervan/ ← targetSceneId: "campervan"
1100
- │ └── scene.json
1101
- ├── cabin/ ← targetSceneId: "cabin"
1102
- │ └── scene.json
1103
- ```
1104
-
1105
- #### Fully Self-Hosted (No StorySplat at All)
1106
-
1107
- If both scenes are created locally and **never uploaded to StorySplat**, you have full control over the `targetSceneId` values. They don't need to be real StorySplat IDs—use any string identifier you want:
1108
-
1109
- **Step 1: Edit your scene.json files to add portals with custom IDs**
1110
-
1111
- ```json
1112
- // scenes/campervan/scene.json
1113
- {
1114
- "name": "Campervan Tour",
1115
- "portals": [
1116
- {
1117
- "id": "portal-1",
1118
- "targetSceneId": "cabin",
1119
- "targetSceneName": "Mountain Cabin",
1120
- "position": { "x": 2, "y": 0, "z": -3 },
1121
- "type": "sphere",
1122
- "activationMode": "click"
1123
- }
1124
- ]
1125
- }
1126
- ```
1127
-
1128
- ```json
1129
- // scenes/cabin/scene.json
1130
- {
1131
- "name": "Mountain Cabin",
1132
- "portals": [
1133
- {
1134
- "id": "portal-back",
1135
- "targetSceneId": "campervan",
1136
- "targetSceneName": "Back to Campervan",
1137
- "position": { "x": -1, "y": 0, "z": 2 },
1138
- "type": "sphere",
1139
- "activationMode": "click"
1140
- }
1141
- ]
1142
- }
1143
- ```
1317
+ ## Error Handling
1144
1318
 
1145
- **Step 2: Complete self-hosted viewer code**
1319
+ The package exports error classes for specific error handling:
1146
1320
 
1147
1321
  ```javascript
1148
- import { createViewer } from 'storysplat-viewer';
1149
-
1150
- const container = document.getElementById('viewer');
1151
-
1152
- // Map your scene identifiers to file paths
1153
- const SCENES = {
1154
- 'campervan': '/scenes/campervan/scene.json',
1155
- 'cabin': '/scenes/cabin/scene.json',
1156
- 'garden': '/scenes/garden/scene.json'
1157
- };
1158
-
1159
- let currentViewer = null;
1322
+ import {
1323
+ createViewerFromSceneId,
1324
+ SceneNotFoundError,
1325
+ SceneApiError
1326
+ } from 'storysplat-viewer';
1160
1327
 
1161
- async function loadScene(sceneId) {
1162
- // Cleanup previous viewer
1163
- if (currentViewer) {
1164
- currentViewer.destroy();
1328
+ try {
1329
+ const viewer = await createViewerFromSceneId(container, sceneId);
1330
+ } catch (error) {
1331
+ if (error instanceof SceneNotFoundError) {
1332
+ console.error('Scene not found or not public');
1333
+ } else if (error instanceof SceneApiError) {
1334
+ console.error(`API error (${error.statusCode}): ${error.message}`);
1335
+ } else {
1336
+ console.error('Unknown error:', error);
1165
1337
  }
1166
-
1167
- // Fetch scene from YOUR server
1168
- const sceneData = await fetch(SCENES[sceneId]).then(r => r.json());
1169
-
1170
- // Create viewer
1171
- currentViewer = await createViewer(container, sceneData);
1172
-
1173
- // Handle portal clicks - loads target scene from your server
1174
- currentViewer.on('portalActivated', (data) => {
1175
- loadScene(data.targetSceneId);
1176
- });
1177
1338
  }
1178
-
1179
- // Start with initial scene
1180
- loadScene('campervan');
1181
1339
  ```
1182
1340
 
1183
- **Key point:** The `targetSceneId` is just a string you define. It doesn't need to exist on StorySplat—you map it to your own file paths.
1184
-
1185
- #### Portal Events
1186
-
1187
- | Event | Description | Data |
1188
- |-------|-------------|------|
1189
- | `portalActivated` | Portal clicked or proximity triggered | `{ portalId, targetSceneId, targetSceneName }` |
1190
- | `portalNavigationStart` | Navigation beginning | `{ targetSceneId }` |
1191
- | `portalNavigationComplete` | New scene loaded | `{ sceneId }` |
1192
- | `portalNavigationError` | Navigation failed | `{ error, targetSceneId }` |
1193
-
1194
- ### Q: Where does the viewer look for scene files?
1195
-
1196
- **A: It depends on how you load scenes:**
1197
-
1198
- - **`createViewerFromSceneId()`** - Fetches from `discover.storysplat.com/api/scene/{id}`
1199
- - **`createViewer(sceneData)`** - Uses the data object you pass in directly (no fetch)
1200
- - **Splat file URLs in scene JSON** - Can be absolute URLs or relative to your page
1201
-
1202
- The viewer itself has no "base path" concept - splat file paths in your scene JSON should be absolute URLs or relative to your HTML page, not to the viewer JS file.
1203
-
1204
1341
  ## Exports
1205
1342
 
1206
- The package exports the following types and utilities:
1207
-
1208
1343
  ```typescript
1209
1344
  // Core viewer functions
1210
1345
  import {
@@ -1238,13 +1373,71 @@ import {
1238
1373
  getGsplatRelightingClass,
1239
1374
  SceneNotFoundError,
1240
1375
  SceneApiError,
1376
+ BUILD_VERSION,
1241
1377
  } from 'storysplat-viewer';
1242
1378
  ```
1243
1379
 
1380
+ ## Troubleshooting
1381
+
1382
+ ### Container Needs Dimensions
1383
+
1384
+ The container element must have explicit dimensions:
1385
+
1386
+ ```css
1387
+ #viewer {
1388
+ width: 100%;
1389
+ height: 500px; /* Must have explicit height */
1390
+ }
1391
+ ```
1392
+
1393
+ ### CORS Issues
1394
+
1395
+ If loading splats from a different domain, ensure the server has proper CORS headers or use a proxy.
1396
+
1397
+ ### Memory Cleanup
1398
+
1399
+ Always destroy the viewer when unmounting:
1400
+
1401
+ ```javascript
1402
+ // Vanilla JS
1403
+ window.addEventListener('beforeunload', () => {
1404
+ viewer.destroy();
1405
+ });
1406
+
1407
+ // React useEffect cleanup
1408
+ return () => {
1409
+ viewer?.destroy();
1410
+ };
1411
+ ```
1412
+
1413
+ ### Build Target Must Be ES2016+
1414
+
1415
+ If you bundle `storysplat-viewer` with Vite, esbuild, or another bundler, your build target **must be `es2016` or higher** (we recommend `es2020`).
1416
+
1417
+ PlayCanvas (the 3D engine) serializes its gsplat sort worker via `Function.toString()` into a blob URL. If your bundler targets `es2015`, it will extract the `**` (exponentiation) operator into a module-scoped helper function. That helper doesn't exist inside the worker's isolated scope, causing a `ReferenceError` at runtime (e.g., `ss is not defined`).
1418
+
1419
+ ```js
1420
+ // vite.config.ts
1421
+ export default {
1422
+ build: {
1423
+ target: 'es2020', // Must be es2016+ (NOT es2015)
1424
+ }
1425
+ };
1426
+ ```
1427
+
1428
+ This only affects production builds — development servers typically don't minify and won't trigger the issue.
1429
+
1430
+ ### Browser Compatibility
1431
+
1432
+ StorySplat Viewer requires:
1433
+ - WebGL 2.0 support
1434
+ - Modern JavaScript (ES2016+)
1435
+ - Recommended browsers: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+
1436
+
1244
1437
  ## Support
1245
1438
 
1246
1439
  - GitHub Issues: [github.com/SonnyC56/storysplat-viewer/issues](https://github.com/SonnyC56/storysplat-viewer/issues)
1247
- - Documentation: [docs.storysplat.com](https://docs.storysplat.com)
1440
+ - Documentation: [discover.storysplat.com/docs](https://discover.storysplat.com/docs)
1248
1441
 
1249
1442
  ## License
1250
1443