162 lines
4.8 KiB
TypeScript
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)
|
|
}
|