@heliguy-xyz/splat-viewer-react-ui
v1.0.0-alpha.4
Published
Full-featured React UI component for 3D Gaussian Splat viewer with controls, model management, and user permissions
Downloads
270
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
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([]);
return (
<SplatSceneViewer
viewOnly={false}
models={models}
onBackClick={() => window.history.back()}
onModelTransformChange={(modelId, transform) => {
console.log('Transform changed:', modelId, transform);
}}
/>
);
}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 previewBlob = await uploaderRef.current?.capturePreview();
// Upload file and preview to backend
const formData = new FormData();
formData.append('modelFile', modelFile);
if (previewBlob) {
formData.append('preview', previewBlob, 'preview.png');
}
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 |
| className | string | '' | No | Additional CSS classes for the root container |
| models | ModelItem[] | [] | No | Array of models to display in the timeline panel |
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. If not provided, uses native file picker |
| uploadModel | (file: File) => Promise<void> | No | Function to upload model file to backend |
| onModelSelect | (modelId: string \| null) => void | No | Called when a model is selected/deselected in timeline |
| 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: ModelTransform) => void | No | Called when model transform changes (debounced at 100ms) |
Types
ModelItem
Represents a model in the scene.
interface ModelItem {
id: string; // Unique identifier
name: string; // Display name
uploadedAt: string; // ISO date string (e.g., "2026-01-15T14:30:00Z")
visible: boolean; // Visibility state
info?: ModelInfo; // Model metadata from viewer (optional)
collisionMeshUrl?: string; // URL to collision mesh (optional)
url?: string; // URL to load model from (optional, e.g., S3, CDN)
}Example:
const models: ModelItem[] = [
{
id: 'model-123',
name: 'Building Scan.ply',
uploadedAt: '2026-01-15T14:30:00Z',
visible: true,
url: 'https://my-bucket.s3.amazonaws.com/models/building.splat',
collisionMeshUrl: 'https://cdn.example.com/collision/building.obj'
}
];Loading from URLs: When you provide a url 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.
ModelTransform
Represents a model's 3D transformation.
interface ModelTransform {
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}Example:
const transform: ModelTransform = {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 45, z: 0 },
scale: { x: 1, y: 1, z: 1 }
};Scene Settings
Settings that can be changed via onSceneSettingsChange:
{
backgroundColor?: string; // Hex color (e.g., "#27272A")
fieldOfView?: number; // Camera FOV in degrees (30-120)
shBands?: number; // Spherical harmonics bands (0-3)
flySpeed?: number; // Fly camera speed (0.1-20)
showGrid?: boolean; // Grid visibility
fakeSkyEnabled?: boolean; // Skybox visibility
showBound?: boolean; // Bounding box visibility
}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)}
uploadModel={handleUpload}
/>
{showImportModal && (
<ModelImportModal
onClose={() => setShowImportModal(false)}
onSelect={(file) => handleUpload(file)}
/>
)}uploadModel
Function to handle model file upload. The component will load the model into the viewer after upload completes.
const handleUpload = async (file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('sceneId', sceneId);
const response = await fetch('/api/models/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
// Refresh models list
const updated = await fetchModels(sceneId);
setModels(updated);
toast.success('Model uploaded successfully');
} catch (error) {
console.error('Upload error:', error);
toast.error('Failed to upload model');
}
};
<SplatSceneViewer uploadModel={handleUpload} />onModelSelect
Called when a model is selected or deselected in the timeline panel.
<SplatSceneViewer
onModelSelect={(modelId) => {
if (modelId) {
console.log('Model selected:', modelId);
setSelectedModelId(modelId);
// Fetch additional model details if needed
} else {
console.log('Model deselected');
setSelectedModelId(null);
}
}}
/>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.
<SplatSceneViewer
onModelTransformChange={async (modelId, transform) => {
try {
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, ModelTransform } 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: ModelTransform) => {
try {
await fetch(`/api/scenes/${params.id}/models/${modelId}/transform`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(transform),
});
} 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)}
uploadModel={handleUploadModel}
onModelSelect={(id) => console.log('Selected:', id)}
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
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: ModelTransform
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",
"uploadedAt": "2026-01-15T14:30:00Z",
"visible": true,
"collisionMeshUrl": "https://cdn.example.com/collision/building.obj"
}
]Important:
uploadedAtmust be an ISO 8601 date string- Models are grouped by date and sorted by time in the timeline panel
collisionMeshUrlis 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:
uploadModelprop is provided- Backend endpoint accepts
multipart/form-data - File size limits on backend
- 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, ModelTransform } from '@heliguy-xyz/splat-viewer-react-ui';
<SplatSceneViewer
// Core
viewOnly={false}
className=""
models={[]}
// Navigation
onBackClick={() => {}}
onShareClick={() => {}}
// Lifecycle
onReady={() => {}}
onError={(error) => {}}
// Models
onModelImportOpen={() => {}}
uploadModel={async (file) => {}}
onModelSelect={(modelId) => {}}
onModelDelete={async (modelId) => {}}
// Updates (debounced internally)
onModelTransformChange={async (modelId, transform) => {}}
onSceneSettingsChange={async (settings) => {}}
/>Support
- Documentation: GitHub Repository
- Issues: GitHub Issues
- Changelog: CHANGELOG.md
License
See LICENSE in the root directory.
