npm package: @heliguy-xyz/splat-viewer-react-ui
Description: Full-featured React UI component for 3D Gaussian Splat viewer with controls, model management, and user permissions
Author: Heliguy
Latest version: 1.0.0-alpha.19

# @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](#installation)
- [Quick Start](#quick-start)
- [Components](#components)
  - [SplatSceneViewer](#splatsceneviewer)
  - [SplatFileUploader](#splatfileuploader)
- [Component API](#component-api)
  - [Props](#props)
  - [Types](#types)
- [Events & Callbacks](#events--callbacks)
- [Usage Examples](#usage-examples)
- [Styling](#styling)
- [Backend Integration](#backend-integration)
- [Troubleshooting](#troubleshooting)

## 📚 Additional Documentation

- **[Props Reference](./PROPS_REFERENCE.md)** - Quick props interface reference for both components
- **[Quick Start Guide](./QUICK_START.md)** - Get started with example code
- **[Callbacks API Reference](./CALLBACKS_API.md)** - Detailed documentation for all callbacks
- **[SplatFileUploader API](./SPLAT_FILE_UPLOADER_API.md)** - Upload component documentation

## Installation

```bash
npm install @heliguy-xyz/splat-viewer-react-ui @heliguy-xyz/splat-viewer
```

**Required peer dependencies:**
- `react` >= 18.0.0
- `react-dom` >= 18.0.0
- `@heliguy-xyz/splat-viewer` >= 1.0.0-rc.0

## Quick Start

### SplatSceneViewer - Full Scene Management

```tsx
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

```tsx
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.

📖 **[Full API Documentation](./CALLBACKS_API.md)**

### SplatFileUploader

A drag-and-drop file upload component with 3D preview and rotation controls. Perfect for uploading individual 3D models with initial rotation settings.

📖 **[Full API Documentation](./SPLAT_FILE_UPLOADER_API.md)**

**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.

```typescript
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:**

```typescript
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`:

```typescript
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:**

```typescript
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.

```typescript
<SplatSceneViewer
  onBackClick={() => {
    router.push('/scenes'); // Next.js
    // or window.history.back();
  }}
/>
```

#### `onShareClick`

Called when the share button is clicked. Implement your sharing logic here.

```typescript
<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.

```typescript
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.

```typescript
<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.

```typescript
<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.

```typescript
<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

```tsx
'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

```tsx
'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

```tsx
<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)

```tsx
<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

```bash
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```

### 2. Configure Tailwind

Update your `tailwind.config.js`:

```javascript
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

```tsx
// 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:

```tsx
<SplatSceneViewer
  className="custom-viewer"
  // ... other props
/>
```

## Backend Integration

### API Endpoints Example

Your backend should provide these endpoints:

```typescript
// 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:

```json
[
  {
    "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_at` must be an ISO 8601 date string
- Models are grouped by date and sorted by time in the timeline panel
- `path` is the URL to load the model from (required)
- `transform` is a 16-element array (4x4 matrix, row-major) - optional
- `init_rotation` is [x, y, z] rotation in degrees - optional
- `collision_mesh_src` is optional

## Troubleshooting

### Component doesn't render

**Check:**
1. Both packages are installed: `@heliguy-xyz/splat-viewer` and `@heliguy-xyz/splat-viewer-react-ui`
2. Tailwind CSS is properly configured
3. Styles are imported in your app

### Styles are missing/broken

**Fix:**
1. Add the package to Tailwind `content` array
2. Import the CSS file: `import '@heliguy-xyz/splat-viewer-react-ui/dist/styles.css'`
3. Ensure Tailwind CSS is processing the component files

### Transform changes not saving

**Check:**
1. `onModelTransformChange` callback is implemented
2. API endpoint is working correctly
3. Check browser console for errors
4. Changes are debounced at 100ms - wait before checking

### Models not appearing in timeline

**Check:**
1. `models` prop is provided and has correct format
2. Each model has required fields: `id`, `name`, `uploadedAt`, `visible`
3. `uploadedAt` is a valid ISO date string
4. Check browser console for errors

### File upload not working

**Check:**
1. `onModelImportOpen` callback is implemented
2. Your upload modal handles file upload to backend
3. After upload, `models` prop is updated with new model
4. CORS is configured correctly

### Performance issues

**Optimize:**
1. Limit number of models loaded at once
2. Use proper backend pagination
3. Optimize model file sizes before upload
4. Consider using lower-quality models for preview

## API Quick Reference

```typescript
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](https://github.com/Heliguy-com/3d-web-viewer)
- **Issues:** [GitHub Issues](https://github.com/Heliguy-com/3d-web-viewer/issues)
- **Changelog:** [CHANGELOG.md](../../CHANGELOG.md)

## License

See [LICENSE](../../LICENSE) in the root directory.