rcnn/web/src/splat/SplatRenderer.tsx
Marius Unsel d93412cd0d Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:12:40 +02:00

162 lines
4.8 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import * as THREE from 'three'
import { useCesiumViewer } from '../cesium/cesiumContext'
import { useMapStore } from '../store/mapStore'
import { useSplatStore } from '../store/splatStore'
import { syncSplatCamera } from './useSplatCamera'
import { getSplatDownloadUrl } from './splatLoader'
import { buildSplatWorldMatrix } from '../cesium/geoUtils'
import { fetchSplatDetail } from '../api/splats'
// Only render the splat when the camera is below this altitude
const RENDER_HEIGHT = 500
export function SplatRenderer() {
const viewer = useCesiumViewer()
const canvasRef = useRef<HTMLCanvasElement>(null)
const splatViewerRef = useRef<unknown>(null)
const camerRef = useRef<THREE.PerspectiveCamera>(new THREE.PerspectiveCamera())
const activeSplatId = useMapStore((s) => s.activeSplatId)
const cameraHeight = useMapStore((s) => s.cameraHeight)
const { setSplatDetail, splatCache } = useSplatStore()
// Keep canvas dimensions in sync with Cesium canvas
useEffect(() => {
const cesiumCanvas = viewer.canvas
const overlayCanvas = canvasRef.current
if (!overlayCanvas) return
function syncSize() {
overlayCanvas!.width = cesiumCanvas.width
overlayCanvas!.height = cesiumCanvas.height
camerRef.current.aspect = cesiumCanvas.width / cesiumCanvas.height
camerRef.current.updateProjectionMatrix()
}
syncSize()
const observer = new ResizeObserver(syncSize)
observer.observe(cesiumCanvas)
return () => observer.disconnect()
}, [viewer])
// Load / unload splat when activeSplatId changes
useEffect(() => {
if (!activeSplatId) {
disposeSplatViewer()
return
}
let cancelled = false
async function loadSplat() {
if (!activeSplatId) return
// Fetch detail if not cached
let detail = splatCache.get(activeSplatId)
if (!detail) {
detail = await fetchSplatDetail(activeSplatId)
if (cancelled) return
setSplatDetail(activeSplatId, detail)
}
if (!detail.location || !detail.is_published) return
const url = await getSplatDownloadUrl(activeSplatId)
if (cancelled) return
// Dynamically import the library to keep initial bundle lean
const { Viewer: GaussianViewer } = await import('@mkkellogg/gaussian-splats-3d')
if (cancelled) return
const canvas = canvasRef.current
if (!canvas) return
disposeSplatViewer()
const gViewer = new GaussianViewer({
selfDrivenMode: false,
useBuiltInControls: false,
renderer: new THREE.WebGLRenderer({ canvas, alpha: true }),
camera: camerRef.current,
})
// Geo-anchor the splat
const [lon, lat] = detail.location.coordinates
const alt = detail.altitude ?? 0
const heading = detail.heading ?? 0
const worldMatrix = buildSplatWorldMatrix(lon, lat, alt, heading)
await gViewer.addSplatScene(url, {
progressiveLoad: true,
onProgress: () => { /* optional: update progress UI */ },
})
if (cancelled) {
gViewer.dispose()
return
}
// Apply geo-anchor transform to the loaded scene
const scene = gViewer.splatMesh
if (scene) {
scene.matrixAutoUpdate = false
scene.matrix.copy(worldMatrix)
scene.matrixWorld.copy(worldMatrix)
}
splatViewerRef.current = gViewer
}
loadSplat().catch(console.error)
return () => {
cancelled = true
}
}, [activeSplatId]) // eslint-disable-line react-hooks/exhaustive-deps
// Drive the splat render loop from Cesium's postRender event
useEffect(() => {
const remove = viewer.scene.postRender.addEventListener(() => {
const gViewer = splatViewerRef.current as import('@mkkellogg/gaussian-splats-3d').Viewer | null
if (!gViewer || cameraHeight > RENDER_HEIGHT) return
const canvas = canvasRef.current
if (!canvas) return
syncSplatCamera(viewer, camerRef.current, canvas)
gViewer.update()
gViewer.render()
})
return () => remove()
}, [viewer, cameraHeight])
function disposeSplatViewer() {
const gViewer = splatViewerRef.current as { dispose?: () => void } | null
if (gViewer?.dispose) gViewer.dispose()
splatViewerRef.current = null
// Clear the overlay canvas
const canvas = canvasRef.current
if (canvas) {
const ctx = canvas.getContext('2d')
ctx?.clearRect(0, 0, canvas.width, canvas.height)
}
}
// Overlay canvas portal — sits above the Cesium canvas, no pointer events
const overlayCanvas = (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 10,
}}
/>
)
return createPortal(overlayCanvas, document.body)
}