"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import * as SPLAT from "gsplat";
type Vec3 = [number, number, number];
interface SplatEntry {
src: string;
position?: Vec3;
rotation?: Vec3;
scale?: Vec3;
visible?: boolean;
}
interface ViewerProps {
width?: string;
height?: string;
backgroundColor?: string;
controls?: boolean;
autoRotate?: boolean;
autoRotateSpeed?: number;
cameraPosition?: Vec3 | null;
cameraTarget?: Vec3 | null;
fov?: number | null;
progressBarColor?: string;
progressTrackColor?: string;
progressTextColor?: string;
progressBackgroundColor?: string;
/** Custom loading indicator — overrides the default progress bar */
loadingIndicator?: ReactNode;
/** Splat file URLs to load */
splats?: SplatEntry[];
children?: ReactNode;
}
interface ProgressIndicatorProps {
progress: number;
backgroundColor?: string;
textColor?: string;
barColor?: string;
trackColor?: string;
}
function ProgressIndicator({
progress,
backgroundColor = "#0a0a0a",
textColor = "#666",
barColor = "#fff",
trackColor = "#1e1e1e",
}: ProgressIndicatorProps) {
const pct = Math.round(progress * 100);
return (
);
}
/** Convert a vertical FOV (degrees) to a focal length given a canvas height. */
function fovToFocalLength(fovDeg: number, height: number): number {
return height / (2 * Math.tan((fovDeg * Math.PI) / 360));
}
/** Convert euler angles (degrees, XYZ order) to a Quaternion. */
function eulerToQuaternion(euler: Vec3): SPLAT.Quaternion {
const [ex, ey, ez] = euler.map((d) => (d * Math.PI) / 180) as [
number,
number,
number,
];
const cx = Math.cos(ex / 2),
sx = Math.sin(ex / 2);
const cy = Math.cos(ey / 2),
sy = Math.sin(ey / 2);
const cz = Math.cos(ez / 2),
sz = Math.sin(ez / 2);
return new SPLAT.Quaternion(
sx * cy * cz + cx * sy * sz,
cx * sy * cz - sx * cy * sz,
cx * cy * sz + sx * sy * cz,
cx * cy * cz - sx * sy * sz,
);
}
/**
* Container that manages a WebGL canvas and loads gaussian splats
* using Hugging Face's gsplat.js — a standalone WebGL renderer (no Three.js).
*
* This is an experimental demo component, kept inline in the example app.
*/
export function GaussianSplatViewer({
width = "100%",
height = "100%",
backgroundColor = "#000000",
controls: enableControls = true,
autoRotate = false,
autoRotateSpeed = 1,
cameraPosition = null,
cameraTarget = null,
fov = null,
progressBarColor,
progressTrackColor,
progressTextColor,
progressBackgroundColor,
children,
loadingIndicator,
splats,
}: ViewerProps) {
const containerRef = useRef(null);
const cleanupRef = useRef<(() => void) | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [progress, setProgress] = useState(0);
const [error, setError] = useState(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
let cancelled = false;
async function init() {
try {
// Clean up previous viewer
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
const scene = new SPLAT.Scene();
const camera = new SPLAT.Camera();
const renderer = new SPLAT.WebGLRenderer();
// Apply camera position
if (cameraPosition) {
camera.position = new SPLAT.Vector3(
cameraPosition[0],
cameraPosition[1],
cameraPosition[2],
);
}
// Apply FOV by converting to focal length
const rect = container.getBoundingClientRect();
if (fov) {
const fl = fovToFocalLength(fov, rect.height);
camera.data.fx = fl;
camera.data.fy = fl;
}
// Size the canvas to the container
renderer.setSize(rect.width, rect.height);
// Style and append the canvas — absolute positioning prevents overflow
renderer.canvas.style.position = "absolute";
renderer.canvas.style.top = "0";
renderer.canvas.style.left = "0";
renderer.canvas.style.width = "100%";
renderer.canvas.style.height = "100%";
renderer.canvas.style.display = "block";
container.appendChild(renderer.canvas);
// Only create orbit controls if enabled
let controls: SPLAT.OrbitControls | null = null;
if (enableControls) {
controls = new SPLAT.OrbitControls(camera, renderer.canvas);
// Apply camera target (look-at)
if (cameraTarget) {
controls.setCameraTarget(
new SPLAT.Vector3(
cameraTarget[0],
cameraTarget[1],
cameraTarget[2],
),
);
}
}
// Handle resize (debounced to avoid layout thrashing)
let resizeTimer: ReturnType;
const onResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const r = container.getBoundingClientRect();
renderer.setSize(r.width, r.height);
// Update focal length on resize to maintain FOV
if (fov) {
const fl = fovToFocalLength(fov, r.height);
camera.data.fx = fl;
camera.data.fy = fl;
}
}, 100);
};
window.addEventListener("resize", onResize);
// Load splat files with progress tracking
if (splats && splats.length > 0) {
const totalSplats = splats.length;
for (let i = 0; i < totalSplats; i++) {
if (cancelled) return;
const entry = splats[i]!;
const splatObject = await SPLAT.Loader.LoadAsync(
entry.src,
scene,
(p: number) => {
if (!cancelled) {
const overallProgress = (i + p) / totalSplats;
// Use Math.max to prevent progress bar from jumping backwards
setProgress((prev) => Math.max(prev, overallProgress));
}
},
);
// Apply per-splat transforms
if (entry.position) {
splatObject.position = new SPLAT.Vector3(
entry.position[0],
entry.position[1],
entry.position[2],
);
}
if (entry.rotation) {
splatObject.rotation = eulerToQuaternion(entry.rotation);
}
if (entry.scale) {
splatObject.scale = new SPLAT.Vector3(
entry.scale[0],
entry.scale[1],
entry.scale[2],
);
}
if (entry.visible !== undefined) {
splatObject.visible = entry.visible;
}
}
}
if (cancelled) {
controls?.dispose();
renderer.dispose();
if (renderer.canvas.parentElement) {
renderer.canvas.parentElement.removeChild(renderer.canvas);
}
window.removeEventListener("resize", onResize);
return;
}
setIsLoading(false);
setError(null);
// Render loop with optional auto-rotation
let animationId: number;
let lastTime = performance.now();
const frame = () => {
const now = performance.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
if (autoRotate) {
// Rotate the camera around the camera target (not world origin)
const speed = autoRotateSpeed * 0.5;
const angle = speed * dt;
const pos = camera.position;
const target = cameraTarget
? new SPLAT.Vector3(
cameraTarget[0],
cameraTarget[1],
cameraTarget[2],
)
: new SPLAT.Vector3(0, 0, 0);
const dx = pos.x - target.x;
const dz = pos.z - target.z;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
camera.position = new SPLAT.Vector3(
target.x + dx * cos - dz * sin,
pos.y,
target.z + dx * sin + dz * cos,
);
}
controls?.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(frame);
};
animationId = requestAnimationFrame(frame);
// Store cleanup function
cleanupRef.current = () => {
cancelAnimationFrame(animationId);
clearTimeout(resizeTimer);
window.removeEventListener("resize", onResize);
controls?.dispose();
renderer.dispose();
if (renderer.canvas.parentElement) {
renderer.canvas.parentElement.removeChild(renderer.canvas);
}
};
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to initialize viewer",
);
setIsLoading(false);
}
}
}
init();
return () => {
cancelled = true;
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}, [
splats,
enableControls,
autoRotate,
autoRotateSpeed,
cameraPosition,
cameraTarget,
fov,
]);
return (
{isLoading &&
(loadingIndicator ?? (
))}
{error && (
{error}
)}
{children}
);
}