import { useEffect, useRef, useState, type ReactNode } from 'react' import type { Viewer } from '@mkkellogg/gaussian-splats-3d' export interface CameraSnapshot { position: [number, number, number] target: [number, number, number] up: [number, number, number] } export type SplatPreviewState = 'waiting' | 'loading' | 'ready' | 'failed' export interface SplatPreviewStatus { state: SplatPreviewState message: string } interface SplatPreviewProps { plyUrl: string | null generationKey: number initialCameraPosition?: [number, number, number] initialCameraTarget?: [number, number, number] initialCameraUp?: [number, number, number] bgColor: string fov: number autoRotate: boolean maxScreenSize: number /** Use static-scene rendering when the displayed result will not be transformed live. */ dynamicScene?: boolean splatPosition: [number, number, number] splatRotation: [number, number, number] splatFlip: [boolean, boolean, boolean] onCameraChange?: (snap: CameraSnapshot) => void onViewerStateChange?: (status: SplatPreviewStatus) => void /** Called only after this PLY URL no longer has an attached viewer. */ onViewerDisposed?: (plyUrl: string) => void children?: ReactNode } export function SplatPreview({ plyUrl, generationKey, initialCameraPosition, initialCameraTarget, initialCameraUp, bgColor, fov, autoRotate, maxScreenSize, dynamicScene = true, splatPosition, splatRotation, splatFlip, onCameraChange, onViewerStateChange, onViewerDisposed, children, }: SplatPreviewProps) { const containerRef = useRef(null) const viewerRef = useRef(null) const [viewerStatus, setViewerStatus] = useState({ state: 'waiting', message: 'Waiting for a generated splat…', }) const onCameraChangeRef = useRef(onCameraChange) onCameraChangeRef.current = onCameraChange const onViewerStateChangeRef = useRef(onViewerStateChange) onViewerStateChangeRef.current = onViewerStateChange const onViewerDisposedRef = useRef(onViewerDisposed) onViewerDisposedRef.current = onViewerDisposed // Refs hold the latest transform values so the mount effect can apply them // when the viewer is recreated, without restarting on every transform change. const splatPositionRef = useRef(splatPosition) const splatRotationRef = useRef(splatRotation) const splatFlipRef = useRef(splatFlip) splatPositionRef.current = splatPosition splatRotationRef.current = splatRotation splatFlipRef.current = splatFlip useEffect(() => { let cancelled = false let localViewer: Viewer | null = null const publishStatus = (state: SplatPreviewState, message: string) => { if (cancelled) return const status = { state, message } setViewerStatus(status) onViewerStateChangeRef.current?.(status) } const mount = async () => { if (!plyUrl || !containerRef.current) { publishStatus('waiting', 'Waiting for a generated splat…') return } publishStatus('loading', 'Loading splat preview…') try { const module = await import('@mkkellogg/gaussian-splats-3d') if (cancelled || !containerRef.current) { return } const viewerOptions: Record = { rootElement: containerRef.current, selfDrivenMode: true, useBuiltInControls: true, sharedMemoryForWorkers: false, ignoreDevicePixelRatio: false, inMemoryCompressionLevel: 0, maxScreenSpaceSplatSize: maxScreenSize, dynamicScene, } if (initialCameraPosition) viewerOptions.initialCameraPosition = initialCameraPosition if (initialCameraTarget) viewerOptions.initialCameraLookAt = initialCameraTarget if (initialCameraUp) viewerOptions.cameraUp = initialCameraUp localViewer = new module.Viewer(viewerOptions) viewerRef.current = localViewer localViewer.start() const initialPos = splatPositionRef.current const initialRot = splatRotationRef.current const initialFlip = splatFlipRef.current const sceneOptions: Record = { format: module.SceneFormat.Ply, showLoadingUI: false, splatAlphaRemovalThreshold: 1, } if (initialPos.some((n) => n !== 0)) sceneOptions.position = initialPos if (initialRot.some((n) => n !== 0)) { sceneOptions.rotation = eulerDegToQuaternion(initialRot) } if (initialFlip.some(Boolean)) { sceneOptions.scale = flipsToScale(initialFlip) } await localViewer.addSplatScene(plyUrl, sceneOptions) if (cancelled) return applyLiveSettings(localViewer, { bgColor, fov, autoRotate }) applySplatTransform(localViewer, { position: splatPositionRef.current, rotation: splatRotationRef.current, flip: splatFlipRef.current, }) if (onCameraChangeRef.current) { attachCameraReporter(localViewer, () => onCameraChangeRef.current) } publishStatus('ready', 'Preview ready. Drag to orbit, scroll to zoom.') } catch (error) { if (!cancelled) { publishStatus( 'failed', `Preview failed: ${error instanceof Error ? error.message : String(error)}`, ) } } } void mount() return () => { cancelled = true const viewer = localViewer ?? viewerRef.current viewerRef.current = null const notifyDisposed = () => { if (plyUrl) onViewerDisposedRef.current?.(plyUrl) } if (viewer) { void Promise.resolve() .then(() => viewer.dispose()) .catch(() => undefined) .finally(notifyDisposed) } else { notifyDisposed() } } // maxScreenSize and dynamicScene are constructor options; changing either // requires a viewer recreate. // eslint-disable-next-line react-hooks/exhaustive-deps }, [plyUrl, generationKey, maxScreenSize, dynamicScene]) useEffect(() => { const viewer = viewerRef.current if (!viewer) return applyLiveSettings(viewer, { bgColor, fov, autoRotate }) }, [bgColor, fov, autoRotate]) useEffect(() => { const viewer = viewerRef.current if (!viewer) return applySplatTransform(viewer, { position: splatPosition, rotation: splatRotation, flip: splatFlip, }) }, [splatPosition, splatRotation, splatFlip]) return (
Gaussian Splat Preview {viewerStatus.message}
{!plyUrl ?
Generate a splat to preview it here.
: null}
{children}
) } function applyLiveSettings( viewer: Viewer, settings: { bgColor: string; fov: number; autoRotate: boolean }, ): void { const cam = (viewer as unknown as { camera?: { fov?: number; updateProjectionMatrix?: () => void } }).camera if (cam && typeof cam.fov === 'number' && Number.isFinite(settings.fov)) { cam.fov = settings.fov cam.updateProjectionMatrix?.() } const renderer = (viewer as unknown as { renderer?: { setClearColor?: (c: string) => void } }).renderer renderer?.setClearColor?.(settings.bgColor) const controls = (viewer as unknown as { controls?: { autoRotate?: boolean; autoRotateSpeed?: number } }).controls if (controls) { controls.autoRotate = settings.autoRotate if (settings.autoRotate) controls.autoRotateSpeed = 1.0 } } interface SplatTransform { position: [number, number, number] rotation: [number, number, number] flip: [boolean, boolean, boolean] } interface Vec3Like { set(x: number, y: number, z: number): void } interface QuatLike { setFromEuler(euler: { x: number; y: number; z: number; order?: string }): void } interface SceneObject { position?: Vec3Like quaternion?: QuatLike scale?: Vec3Like } interface SplatMeshLike { scenes?: SceneObject[] getScene?(index: number): SceneObject | null | undefined updateTransforms?(): void } function applySplatTransform(viewer: Viewer, transform: SplatTransform): void { const splatMesh = (viewer as unknown as { splatMesh?: SplatMeshLike }).splatMesh if (!splatMesh) return const scene = splatMesh.getScene?.(0) ?? splatMesh.scenes?.[0] if (!scene) return scene.position?.set(transform.position[0], transform.position[1], transform.position[2]) if (scene.quaternion) { const radX = (transform.rotation[0] * Math.PI) / 180 const radY = (transform.rotation[1] * Math.PI) / 180 const radZ = (transform.rotation[2] * Math.PI) / 180 scene.quaternion.setFromEuler({ x: radX, y: radY, z: radZ, order: 'XYZ' } as never) } const sx = transform.flip[0] ? -1 : 1 const sy = transform.flip[1] ? -1 : 1 const sz = transform.flip[2] ? -1 : 1 scene.scale?.set(sx, sy, sz) splatMesh.updateTransforms?.() } function eulerDegToQuaternion(rot: [number, number, number]): [number, number, number, number] { // ZYX order to match Three.js's default Euler (XYZ extrinsic == ZYX intrinsic). const cx = Math.cos((rot[0] * Math.PI) / 360) const sx = Math.sin((rot[0] * Math.PI) / 360) const cy = Math.cos((rot[1] * Math.PI) / 360) const sy = Math.sin((rot[1] * Math.PI) / 360) const cz = Math.cos((rot[2] * Math.PI) / 360) const sz = Math.sin((rot[2] * Math.PI) / 360) // XYZ Euler -> quaternion (Three.js convention) const x = sx * cy * cz + cx * sy * sz const y = cx * sy * cz - sx * cy * sz const z = cx * cy * sz + sx * sy * cz const w = cx * cy * cz - sx * sy * sz return [x, y, z, w] } function flipsToScale(flip: [boolean, boolean, boolean]): [number, number, number] { return [flip[0] ? -1 : 1, flip[1] ? -1 : 1, flip[2] ? -1 : 1] } function attachCameraReporter(viewer: Viewer, getCallback: () => ((snap: CameraSnapshot) => void) | undefined): void { const controls = ( viewer as unknown as { controls?: { addEventListener?: (type: string, listener: () => void) => void target?: { x: number; y: number; z: number } } } ).controls const cam = ( viewer as unknown as { camera?: { position?: { x: number; y: number; z: number } up?: { x: number; y: number; z: number } } } ).camera if (!controls?.addEventListener || !cam?.position || !cam?.up || !controls.target) return const emit = () => { const cb = getCallback() if (!cb) return cb({ position: [cam.position!.x, cam.position!.y, cam.position!.z], target: [controls.target!.x, controls.target!.y, controls.target!.z], up: [cam.up!.x, cam.up!.y, cam.up!.z], }) } controls.addEventListener('change', emit) emit() }