@heliguy-xyz/splat-viewer-react-ui
v1.0.0-alpha.19
Published
Full-featured React UI component for 3D Gaussian Splat viewer with controls, model management, and user permissions
Readme
@heliguy-xyz/splat-viewer-react-ui
A complete React UI component library for 3D Gaussian Splat viewing with full scene management and file upload capabilities.
Table of Contents
- Installation
- Quick Start
- Components
- Component API
- Events & Callbacks
- Usage Examples
- Styling
- Backend Integration
- Troubleshooting
📚 Additional Documentation
- Props Reference - Quick props interface reference for both components
- Quick Start Guide - Get started with example code
- Callbacks API Reference - Detailed documentation for all callbacks
- SplatFileUploader API - Upload component documentation
Installation
npm install @heliguy-xyz/splat-viewer-react-ui @heliguy-xyz/splat-viewerRequired peer dependencies:
react>= 18.0.0react-dom>= 18.0.0@heliguy-xyz/splat-viewer>= 1.0.0-rc.0
Quick Start
SplatSceneViewer - Full Scene Management
import { SplatSceneViewer } from '@heliguy-xyz/splat-viewer-react-ui';
import '@heliguy-xyz/splat-viewer-react-ui/dist/styles.css'; // Import styles
function App() {
const [models, setModels] = useState([]);
const [sceneSettings, setSceneSettings] = useState({
backgroundColor: '#27272A',
fieldOfView: 65,
showGrid: false,
});
return (
<SplatSceneViewer
viewOnly={false}
models={models}
sceneSettings={sceneSettings}
onBackClick={() => window.history.back()}
onModelTransformChange={(modelId, transform) => {
console.log('Transform changed:', modelId, transform); // transform is 16-element array (4x4 matrix)
}}
onSceneSettingsChange={(settings) => {
console.log('Settings changed:', settings);
setSceneSettings(prev => ({ ...prev, ...settings }));
}}
/>
);
}SplatFileUploader - File Upload with Preview
import { useRef, useState } from 'react';
import {
SplatFileUploader,
type SplatFileUploaderHandle
} from '@heliguy-xyz/splat-viewer-react-ui';
import '@heliguy-xyz/splat-viewer-react-ui/dist/styles.css';
function UploadPage() {
const uploaderRef = useRef<SplatFileUploaderHandle>(null);
const [modelFile, setModelFile] = useState<File | null>(null);
const handleSubmit = async () => {
if (!modelFile) return;
// Capture preview image
const previewFile = await uploaderRef.current?.capturePreview();
// Upload file and preview to backend
const formData = new FormData();
formData.append('modelFile', modelFile);
if (previewFile) {
formData.append('preview', previewFile);
}
await fetch('/api/upload', { method: 'POST', body: formData });
};
return (
<>
<SplatFileUploader
ref={uploaderRef}
onFileLoad={setModelFile}
onRotationChange={(rotation) => console.log('Rotation:', rotation)}
/>
<button onClick={handleSubmit} disabled={!modelFile}>
Submit
</button>
</>
);
}Components
This package provides two main components:
SplatSceneViewer
A full-featured 3D scene viewer with model management, timeline, properties panel, and editing tools. Perfect for viewing and managing multiple 3D models in a scene.
SplatFileUploader
A drag-and-drop file upload component with 3D preview and rotation controls. Perfect for uploading individual 3D models with initial rotation settings.
Key Features:
- Drag & drop file upload
- File type validation
- Real-time 3D preview
- Interactive rotation controls (X, Y, Z axes)
- Preview image capture via ref (for thumbnails/backend storage)
- Callback-based integration
- Automatic resource cleanup
Component API
Props
Core Props
| Prop | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| viewOnly | boolean | false | No | When true, disables all editing features and hides edit controls |
| hideUI | boolean | false | No | When true, hides all UI elements and shows only the viewer canvas (no toolbar, panels, or tooltips) |
| className | string | '' | No | Additional CSS classes for the root container |
| models | ModelItem[] | [] | No | Array of models to display in the timeline panel |
| sceneSettings | Partial<SceneSettings> | {} | No | Initial scene configuration (background, FOV, grid, etc.) |
Navigation Callbacks
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| onBackClick | () => void | No | Called when back button in toolbar is clicked |
| onShareClick | () => void | No | Called when share button is clicked |
Lifecycle Callbacks
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| onReady | () => void | No | Called when viewer initialization completes |
| onError | (error: Error) => void | No | Called when an error occurs |
Model Management
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| onModelImportOpen | () => void | No | Called to open model import modal. Upload should be handled in your modal |
| onModelDelete | (modelId: string) => void | No | Called after model is deleted from scene |
Scene & Transform Updates
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| onSceneSettingsChange | (settings: Record<string, any>) => void | No | Called when scene settings change (background, FOV, grid, etc.) |
| onModelTransformChange | (modelId: string, transform: number[]) => void | No | Called when model transform changes (debounced at 100ms). Transform is a 16-element array representing a 4x4 matrix |
Types
ModelItem
Represents a model in the scene.
interface ModelItem {
id: string // Unique model identifier
name: string // Model display name
path: string // URL/path to load model from (e.g., S3 URL)
visible: boolean // Model visibility state
created_at: string // ISO date string (creation timestamp)
updated_at?: string // ISO date string (last update timestamp)
transform?: number[] // 4x4 transformation matrix (16 elements, row-major)
init_rotation?: [number, number, number] // Initial rotation [x, y, z] in degrees
collision_mesh_src?: string // URL to collision mesh file
info?: any // Populated by viewer after model loads
}Example:
const models: ModelItem[] = [
{
id: 'model-123',
name: 'Building Scan.ply',
path: 'https://my-bucket.s3.amazonaws.com/models/building.splat',
visible: true,
created_at: '2026-01-15T14:30:00Z',
updated_at: '2026-01-16T10:00:00Z',
collision_mesh_src: 'https://cdn.example.com/collision/building.obj',
transform: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
5, 0, 10, 1
], // 4x4 transformation matrix (row-major)
init_rotation: [0, 90, 0] // Initial rotation [x, y, z] in degrees
}
];Loading from URLs: When you provide a path field, the model will be automatically loaded from that URL when the scene opens. The URL must be publicly accessible or properly configured for CORS.
Transform Matrix: The transform field is a 16-element array representing a 4x4 transformation matrix in row-major order. This includes position, rotation, and scale. This is useful for restoring saved transform states from your database.
Initial Rotation: The init_rotation field is an optional array of three numbers [x, y, z] representing initial rotation in degrees. This is applied when the model first loads.
SceneSettings
Scene configuration settings that can be provided via sceneSettings prop and modified via onSceneSettingsChange:
interface SceneSettings {
backgroundColor: string // Scene background color (hex)
fieldOfView: number // Camera FOV in degrees
showGrid: boolean // Infinite grid visibility
showBoundingBox: boolean // Model bounding box visibility
flySpeed: number // Fly camera movement speed
sh: number // Spherical harmonics bands (0-3)
tonemapping?: string // Tonemapping mode: 'none' | 'linear' | 'neutral' | 'aces' | 'aces2' | 'filmic' | 'hejl'
// Optional legacy fields
skyboxColor?: string
centersSize?: number
outlineSelection?: boolean
fakeSkyEnabled?: boolean
}Example:
const sceneSettings: Partial<SceneSettings> = {
backgroundColor: '#27272A',
fieldOfView: 65,
sh: 1,
flySpeed: 5,
showGrid: false,
showBoundingBox: false,
tonemapping: 'aces'
};
<SplatSceneViewer
sceneSettings={sceneSettings}
onSceneSettingsChange={(settings) => {
// Save changes to database
updateSceneSettings(settings);
}}
/>Events & Callbacks
Navigation Events
onBackClick
Called when the back button in the top toolbar is clicked. Use this for navigation.
<SplatSceneViewer
onBackClick={() => {
router.push('/scenes'); // Next.js
// or window.history.back();
}}
/>onShareClick
Called when the share button is clicked. Implement your sharing logic here.
<SplatSceneViewer
onShareClick={() => {
const url = window.location.href;
navigator.clipboard.writeText(url)
.then(() => toast.success('Link copied!'))
.catch(() => setShowShareModal(true));
}}
/>Model Management Events
onModelImportOpen
Called when user clicks the import button. If not provided, falls back to native file picker.
const [showImportModal, setShowImportModal] = useState(false);
<SplatSceneViewer
onModelImportOpen={() => setShowImportModal(true)}
/>
{showImportModal && (
<YourUploadModal
sceneId={sceneId}
onClose={() => setShowImportModal(false)}
onUploadSuccess={async (uploadedModel) => {
// Update models list after successful upload
const updated = await fetchModels(sceneId);
setModels(updated);
setShowImportModal(false);
toast.success('Model uploaded successfully');
}}
/>
)}Note: Model upload to backend should be handled entirely within your custom modal component. After successful upload, update the models prop and the viewer will automatically load the new models.
onModelDelete
Called after a model is successfully deleted from the scene. Update your state and backend here.
<SplatSceneViewer
onModelDelete={async (modelId) => {
try {
// Delete from backend
await fetch(`/api/scenes/${sceneId}/models/${modelId}`, {
method: 'DELETE',
});
// Update local state
setModels(models.filter(m => m.id !== modelId));
toast.success('Model deleted');
} catch (error) {
console.error('Delete failed:', error);
toast.error('Failed to delete model');
}
}}
/>Transform & Settings Events
onModelTransformChange
Called when a model's transform changes. Automatically debounced at 100ms to avoid excessive API calls.
The transform parameter is a 16-element number array representing a 4x4 transformation matrix in row-major order.
<SplatSceneViewer
onModelTransformChange={async (modelId, transform) => {
try {
// transform is a 16-element array: [m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44]
await fetch(`/api/scenes/${sceneId}/models/${modelId}/transform`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transform }),
});
} catch (error) {
console.error('Failed to save transform:', error);
// Optionally show error to user
}
}}
/>Note: You don't need to implement your own debouncing - the component handles this internally.
onSceneSettingsChange
Called when any scene setting changes. Each setting change triggers a separate callback with a partial settings object.
<SplatSceneViewer
onSceneSettingsChange={async (settings) => {
try {
await fetch(`/api/scenes/${sceneId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
console.log('Settings updated:', settings);
// Example: { backgroundColor: "#27272A" }
// or: { fieldOfView: 75 }
// or: { showGrid: true }
} catch (error) {
console.error('Failed to save settings:', error);
}
}}
/>Usage Examples
Basic Scene Viewer
'use client';
import { useState, useEffect } from 'react';
import { SplatSceneViewer } from '@heliguy-xyz/splat-viewer-react-ui';
import type { ModelItem } from '@heliguy-xyz/splat-viewer-react-ui';
export default function ScenePage({ params }: { params: { id: string } }) {
const [models, setModels] = useState<ModelItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch models from backend
fetch(`/api/scenes/${params.id}/models`)
.then(res => res.json())
.then(data => {
setModels(data);
setLoading(false);
});
}, [params.id]);
if (loading) return <div>Loading...</div>;
return (
<SplatSceneViewer
models={models}
onBackClick={() => window.history.back()}
/>
);
}With Full Backend Integration
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { SplatSceneViewer } from '@heliguy-xyz/splat-viewer-react-ui';
import type { ModelItem } from '@heliguy-xyz/splat-viewer-react-ui';
import { toast } from 'sonner';
export default function ScenePage({ params }: { params: { id: string } }) {
const router = useRouter();
const [models, setModels] = useState<ModelItem[]>([]);
const [viewOnly, setViewOnly] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
useEffect(() => {
// Fetch initial data
Promise.all([
fetch(`/api/scenes/${params.id}/models`).then(r => r.json()),
fetch(`/api/scenes/${params.id}/permissions`).then(r => r.json()),
]).then(([modelsData, permissionsData]) => {
setModels(modelsData);
setViewOnly(!permissionsData.canEdit);
});
}, [params.id]);
const handleUploadModel = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
formData.append('sceneId', params.id);
try {
const response = await fetch('/api/models/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
// Refresh models
const updated = await fetch(`/api/scenes/${params.id}/models`).then(r => r.json());
setModels(updated);
toast.success('Model uploaded successfully');
setShowImportModal(false);
} catch (error) {
console.error('Upload error:', error);
toast.error('Failed to upload model');
}
};
const handleModelDelete = async (modelId: string) => {
try {
await fetch(`/api/scenes/${params.id}/models/${modelId}`, {
method: 'DELETE',
});
setModels(models.filter(m => m.id !== modelId));
toast.success('Model deleted');
} catch (error) {
console.error('Delete failed:', error);
toast.error('Failed to delete model');
}
};
const handleTransformChange = async (modelId: string, transform: number[]) => {
try {
await fetch(`/api/scenes/${params.id}/models/${modelId}/transform`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transform }), // transform is 16-element array (4x4 matrix)
});
} catch (error) {
console.error('Failed to save transform:', error);
}
};
const handleSettingsChange = async (settings: Record<string, any>) => {
try {
await fetch(`/api/scenes/${params.id}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
} catch (error) {
console.error('Failed to save settings:', error);
}
};
return (
<>
<SplatSceneViewer
viewOnly={viewOnly}
models={models}
// Navigation
onBackClick={() => router.push('/scenes')}
onShareClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success('Link copied to clipboard');
}}
// Model management
onModelImportOpen={() => setShowImportModal(true)}
onModelDelete={handleModelDelete}
// Updates
onModelTransformChange={handleTransformChange}
onSceneSettingsChange={handleSettingsChange}
// Lifecycle
onReady={() => console.log('Viewer ready')}
onError={(error) => {
console.error('Viewer error:', error);
toast.error(`Viewer error: ${error.message}`);
}}
/>
{showImportModal && (
<ModelImportModal
onClose={() => setShowImportModal(false)}
onUpload={handleUploadModel}
/>
)}
</>
);
}Read-Only Viewer
<SplatSceneViewer
viewOnly={true}
models={models}
onBackClick={() => router.push('/scenes')}
onShareClick={() => {/* share logic */}}
/>When viewOnly is true:
- Edit/View toggle is hidden
- Properties panel is hidden
- All transform controls are disabled
- Delete buttons are hidden
- Model import is disabled
UI-Hidden Mode (Headless Viewer)
<SplatSceneViewer
hideUI={true}
models={models}
onBackClick={() => {}}
onShareClick={() => {}}
// ... other required callbacks
/>When hideUI is true:
- All UI elements are completely hidden (toolbar, timeline, properties panel, navigation cube)
- Only the 3D viewer canvas is shown
- No tooltips or overlays are displayed
- Useful for embedding the viewer in custom layouts or full-screen presentations
- Camera controls still work (mouse/keyboard interactions with the viewer)
Styling
The component requires Tailwind CSS in your project.
1. Install Tailwind CSS
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p2. Configure Tailwind
Update your tailwind.config.js:
module.exports = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
// Add the package path
'./node_modules/@heliguy-xyz/splat-viewer-react-ui/**/*.{js,jsx}',
],
theme: {
extend: {},
},
plugins: [],
};3. Import Styles
// In your root layout or _app.tsx
import '@heliguy-xyz/splat-viewer-react-ui/dist/styles.css';Custom Styling
You can customize the component appearance using the className prop:
<SplatSceneViewer
className="custom-viewer"
// ... other props
/>Backend Integration
API Endpoints Example
Your backend should provide these endpoints:
// Get models for a scene
GET /api/scenes/:sceneId/models
Response: ModelItem[]
// Upload new model
POST /api/models/upload
Body: FormData with 'file' and 'sceneId'
Response: { success: boolean, modelId: string }
// Update model transform
PATCH /api/scenes/:sceneId/models/:modelId/transform
Body: { transform: number[] } // 16-element array (4x4 matrix)
Response: { success: boolean }
// Update scene settings
PATCH /api/scenes/:sceneId/settings
Body: Partial<SceneSettings>
Response: { success: boolean }
// Delete model
DELETE /api/scenes/:sceneId/models/:modelId
Response: { success: boolean }
// Get permissions
GET /api/scenes/:sceneId/permissions
Response: { canEdit: boolean, canView: boolean }Data Format
Models should be returned in this format:
[
{
"id": "model-123",
"name": "Building.ply",
"path": "https://my-bucket.s3.amazonaws.com/models/building.splat",
"visible": true,
"created_at": "2026-01-15T14:30:00Z",
"updated_at": "2026-01-16T10:00:00Z",
"transform": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
"init_rotation": [0, 90, 0],
"collision_mesh_src": "https://cdn.example.com/collision/building.obj"
}
]Important:
created_atmust be an ISO 8601 date string- Models are grouped by date and sorted by time in the timeline panel
pathis the URL to load the model from (required)transformis a 16-element array (4x4 matrix, row-major) - optionalinit_rotationis [x, y, z] rotation in degrees - optionalcollision_mesh_srcis optional
Troubleshooting
Component doesn't render
Check:
- Both packages are installed:
@heliguy-xyz/splat-viewerand@heliguy-xyz/splat-viewer-react-ui - Tailwind CSS is properly configured
- Styles are imported in your app
Styles are missing/broken
Fix:
- Add the package to Tailwind
contentarray - Import the CSS file:
import '@heliguy-xyz/splat-viewer-react-ui/dist/styles.css' - Ensure Tailwind CSS is processing the component files
Transform changes not saving
Check:
onModelTransformChangecallback is implemented- API endpoint is working correctly
- Check browser console for errors
- Changes are debounced at 100ms - wait before checking
Models not appearing in timeline
Check:
modelsprop is provided and has correct format- Each model has required fields:
id,name,uploadedAt,visible uploadedAtis a valid ISO date string- Check browser console for errors
File upload not working
Check:
onModelImportOpencallback is implemented- Your upload modal handles file upload to backend
- After upload,
modelsprop is updated with new model - CORS is configured correctly
Performance issues
Optimize:
- Limit number of models loaded at once
- Use proper backend pagination
- Optimize model file sizes before upload
- Consider using lower-quality models for preview
API Quick Reference
import { SplatSceneViewer } from '@heliguy-xyz/splat-viewer-react-ui';
import type { ModelItem } from '@heliguy-xyz/splat-viewer-react-ui';
<SplatSceneViewer
// Core
viewOnly={false}
hideUI={false}
className=""
models={[]}
// Navigation
onBackClick={() => {}}
onShareClick={() => {}}
// Lifecycle
onReady={() => {}}
onError={(error) => {}}
// Models
onModelImportOpen={() => {}}
onModelDelete={async (modelId) => {}}
// Updates (debounced internally)
onModelTransformChange={async (modelId, transform: number[]) => {}} // transform is 16-element array
onSceneSettingsChange={async (settings) => {}}
/>Support
- Documentation: GitHub Repository
- Issues: GitHub Issues
- Changelog: CHANGELOG.md
License
See LICENSE in the root directory.
