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(null) const splatViewerRef = useRef(null) const camerRef = useRef(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 = ( ) return createPortal(overlayCanvas, document.body) }