import { useEffect, useRef, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import * as THREE from 'three' import type { CoasterSimulationResult } from '../types/api' import type { TerrainCaptureData } from './useTerrainCapture' import { geoToEnu } from './useTerrainCapture' import styles from './RideRenderer.module.css' // ── Types ────────────────────────────────────────────────────────────────────── type CameraMode = 'first' | 'third' interface Props { simResult: CoasterSimulationResult captureData: TerrainCaptureData onStop: () => void /** Called at ~4 Hz with the current track-fraction position [0,1]. */ onRideProgress?: (sFrac: number) => void } // ── Helpers ─────────────────────────────────────────────────────────────────── /** Largest index i such that arr[i] <= t (arr must be non-decreasing). */ function bisect(arr: number[], t: number): number { let lo = 0, hi = arr.length - 2 while (lo < hi) { const mid = (lo + hi + 1) >> 1 if (arr[mid] <= t) lo = mid else hi = mid - 1 } return lo } function formatRideTime(s: number): string { const m = Math.floor(s / 60) const sec = Math.floor(s % 60) return `${m}:${sec.toString().padStart(2, '0')}` } const UP = new THREE.Vector3(0, 1, 0) // ── Precomputed ride data in Three.js ENU coords ─────────────────────────────── interface RideData { timeArray: number[] sFrac: number[] // profile s_frac at each sample — parallel to timeArray centerline: THREE.Vector3[] rail1: THREE.Vector3[] rail2: THREE.Vector3[] totalDuration: number count: number } function buildRideData(simResult: CoasterSimulationResult): RideData { const { profile, rail_1, rail_2, origin } = simResult const n = Math.min(profile.s_frac.length, rail_1.length, rail_2.length) const r1 = (rail_1 as [number, number, number][]).slice(0, n) .map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin)) const r2 = (rail_2 as [number, number, number][]).slice(0, n) .map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin)) const cl = r1.map((a, i) => a.clone().lerp(r2[i], 0.5)) // Cumulative time using same 1 m/s velocity floor as backend const timeArray: number[] = [0] for (let i = 1; i < n; i++) { const ds = (profile.s_frac[i] - profile.s_frac[i - 1]) * profile.total_length_m const v = Math.max(profile.velocity_ms[i - 1], 1.0) timeArray.push(timeArray[i - 1] + ds / v) } const sFrac = profile.s_frac.slice(0, n) return { timeArray, sFrac, centerline: cl, rail1: r1, rail2: r2, totalDuration: timeArray[n - 1], count: n } } // ── Camera target computation ───────────────────────────────────────────────── interface CameraTarget { pos: THREE.Vector3 quat: THREE.Quaternion } const _mat = new THREE.Matrix4() // PerspectiveCamera.lookAt uses camera convention: −Z axis toward target. // A plain Object3D.lookAt points +Z toward target (opposite), which would // make the camera look backward. const _dummy = new THREE.PerspectiveCamera() function computeCameraTarget( t: number, data: RideData, mode: CameraMode, ): CameraTarget { const { timeArray, centerline, rail1, rail2, count } = data const ct = Math.max(0, Math.min(t, timeArray[count - 1])) const i = bisect(timeArray, ct) const i1 = Math.min(i + 1, count - 1) const alpha = (timeArray[i1] > timeArray[i]) ? (ct - timeArray[i]) / (timeArray[i1] - timeArray[i]) : 0 const pos = centerline[i].clone().lerp(centerline[i1], alpha) const r1i = rail1[i].clone().lerp(rail1[i1], alpha) const r2i = rail2[i].clone().lerp(rail2[i1], alpha) // Wider stencil for smoother forward tangent const pi = Math.max(0, i - 2) const pj = Math.min(count - 1, i1 + 2) const tang = centerline[pj].clone().sub(centerline[pi]).normalize() // Track-local "up": cross(tangent, rail1→rail2) gives the binormal const side = r1i.clone().sub(r2i).normalize() const trackUp = tang.clone().cross(side).normalize() if (trackUp.dot(UP) < 0) trackUp.negate() let camPos: THREE.Vector3 let lookTarget: THREE.Vector3 let upVec: THREE.Vector3 if (mode === 'first') { // Sit 1.5 m above centreline, look 10 m ahead from the seat camPos = pos.clone().addScaledVector(UP, 1.5) lookTarget = camPos.clone().addScaledVector(tang, 10) upVec = trackUp } else { // 40 m behind + 12 m above, looking at the car camPos = pos.clone() .addScaledVector(tang, -40) .addScaledVector(UP, 12) lookTarget = pos upVec = UP } // Build quaternion from look-at matrix (avoids gimbal lock and is slerp-able) _dummy.position.copy(camPos) _dummy.up.copy(upVec) _dummy.lookAt(lookTarget) _dummy.updateMatrixWorld(true) // _dummy.matrixWorld = translation * rotation; extract rotation quaternion _mat.extractRotation(_dummy.matrixWorld) const quat = new THREE.Quaternion().setFromRotationMatrix(_mat) return { pos: camPos, quat } } // ── Three.js scene builder ───────────────────────────────────────────────────── function buildScene(captureData: TerrainCaptureData, rideData: RideData) { const scene = new THREE.Scene() scene.background = new THREE.Color(0x87ceeb) // Lighting scene.add(new THREE.AmbientLight(0xffffff, 0.7)) const sun = new THREE.DirectionalLight(0xfffbe6, 0.85) sun.position.set(200, 500, -300) scene.add(sun) const geos: THREE.BufferGeometry[] = [] const mats: THREE.Material[] = [] const texes: THREE.Texture[] = [] // ── Terrain mesh ──────────────────────────────────────────────────────────── const GRID = captureData.gridSize // 64 const verts = captureData.terrainVertices // GRID×GRID, row-major: idx = j*GRID+i // j=0 → south (lat = tileBbox[1]) // j=GRID-1 → north (lat = tileBbox[3]) // i=0 → west (lon = tileBbox[0]) // i=GRID-1 → east (lon = tileBbox[2]) const posArr = new Float32Array(GRID * GRID * 3) const uvArr = new Float32Array(GRID * GRID * 2) for (let j = 0; j < GRID; j++) { for (let i = 0; i < GRID; i++) { const idx = j * GRID + i const v = verts[idx] posArr[idx * 3] = v.x posArr[idx * 3 + 1] = v.y - 0.5 // shift terrain 0.5 m down so tracks at height 0 sit above it posArr[idx * 3 + 2] = v.z // The stitched canvas has north at pixel row 0 (tj=0 = northernmost tile). // THREE.CanvasTexture has flipY=true by default, which means: // texture V=0 → canvas bottom row → south latitude // texture V=1 → canvas top row → north latitude // So: south (j=0) → V=0, north (j=GRID-1) → V=1 uvArr[idx * 2] = i / (GRID - 1) // U: west→east = 0→1 uvArr[idx * 2 + 1] = j / (GRID - 1) // V: south→north = 0→1 (flipY corrects canvas) } } // Indices — winding must produce upward normals (+Y) when viewed from above. // Quad corners: a=SW, b=SE, c=NE, d=NW (j+1=north, i+1=east) // CCW from above: a→b→d and b→c→d const idxArr: number[] = [] for (let j = 0; j < GRID - 1; j++) { for (let i = 0; i < GRID - 1; i++) { const a = j * GRID + i // SW const b = j * GRID + i + 1 // SE const c = (j + 1) * GRID + i + 1 // NE const d = (j + 1) * GRID + i // NW idxArr.push(a, b, d) // SW→SE→NW (CCW from +Y) idxArr.push(b, c, d) // SE→NE→NW (CCW from +Y) } } const terrainGeo = new THREE.BufferGeometry() terrainGeo.setAttribute('position', new THREE.BufferAttribute(posArr, 3)) terrainGeo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2)) terrainGeo.setIndex(idxArr) terrainGeo.computeVertexNormals() geos.push(terrainGeo) // Draw ImageBitmap onto a real HTMLCanvasElement so CanvasTexture works reliably const texCanvas = document.createElement('canvas') texCanvas.width = captureData.imageBitmap.width texCanvas.height = captureData.imageBitmap.height texCanvas.getContext('2d')!.drawImage(captureData.imageBitmap, 0, 0) const terrainTex = new THREE.CanvasTexture(texCanvas) terrainTex.colorSpace = THREE.SRGBColorSpace texes.push(terrainTex) const terrainMat = new THREE.MeshLambertMaterial({ map: terrainTex, side: THREE.FrontSide }) mats.push(terrainMat) scene.add(new THREE.Mesh(terrainGeo, terrainMat)) // ── Coaster rails ─────────────────────────────────────────────────────────── function addRail(pts: THREE.Vector3[]) { // Decimate: CatmullRomCurve3 doesn't need every sim point const step = Math.max(1, Math.floor(pts.length / 500)) const dpts = pts.filter((_, i) => i % step === 0 || i === pts.length - 1) const curve = new THREE.CatmullRomCurve3(dpts) // TubeGeometry radius: 0.25 m (matches Cesium's ~35 cm shape, slightly smaller for 3D) const geo = new THREE.TubeGeometry(curve, Math.min(dpts.length * 4, 2000), 0.25, 10, false) const mat = new THREE.MeshLambertMaterial({ color: 0xef4444 }) geos.push(geo) mats.push(mat) scene.add(new THREE.Mesh(geo, mat)) } addRail(rideData.rail1) addRail(rideData.rail2) return { scene, disposeAll: () => { geos.forEach(g => g.dispose()) mats.forEach(m => m.dispose()) texes.forEach(t => t.dispose()) }, } } // ── Component ───────────────────────────────────────────────────────────────── export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: Props) { const canvasRef = useRef(null) const rendererRef = useRef(null) const cameraRef = useRef(new THREE.PerspectiveCamera(75, 1, 0.1, 50000)) const sceneRef = useRef(null) const rafRef = useRef(null) // Ride playback refs (mutable, used inside rAF loop) const rideDataRef = useRef(null) const isPlayingRef = useRef(false) const rideTimeRef = useRef(0) const startWallRef = useRef(0) const lastUiUpdateRef = useRef(-1) const cameraModeRef = useRef('third') const prevWallRef = useRef(0) // for frame deltaTime // Smooth camera state (lerped/slerped each frame) const smoothPosRef = useRef(new THREE.Vector3()) const smoothQuatRef = useRef(new THREE.Quaternion()) const needsInitRef = useRef(true) // skip lerp on first frame after play // React state (updated ~4 Hz) const [isPlaying, setIsPlaying] = useState(false) const [rideTime, setRideTime] = useState(0) const [totalDuration, setTotalDuration] = useState(0) const [cameraMode, setCameraMode] = useState('third') useEffect(() => { cameraModeRef.current = cameraMode }, [cameraMode]) // ── Build Three.js scene ────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current if (!canvas) return const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) rendererRef.current = renderer const rideData = buildRideData(simResult) rideDataRef.current = rideData setTotalDuration(rideData.totalDuration) const { scene, disposeAll } = buildScene(captureData, rideData) sceneRef.current = scene function onResize() { const w = window.innerWidth, h = window.innerHeight renderer.setSize(w, h) cameraRef.current.aspect = w / h cameraRef.current.updateProjectionMatrix() } window.addEventListener('resize', onResize) onResize() // Position camera at track start looking forward if (rideData.count > 1) { const t0 = computeCameraTarget(0, rideData, 'third') smoothPosRef.current.copy(t0.pos) smoothQuatRef.current.copy(t0.quat) cameraRef.current.position.copy(t0.pos) cameraRef.current.quaternion.copy(t0.quat) cameraRef.current.updateMatrixWorld() } renderer.render(scene, cameraRef.current) return () => { window.removeEventListener('resize', onResize) if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null } disposeAll() renderer.dispose() rendererRef.current = null sceneRef.current = null } }, [simResult, captureData]) // eslint-disable-line react-hooks/exhaustive-deps // ── rAF render loop ─────────────────────────────────────────────────────── // Smoothing time constants (seconds) — larger = smoother / more lag const POS_TAU = 0.20 // position lag const ROT_TAU = 0.35 // rotation lag function startLoop() { function tick(wallMs: number) { const renderer = rendererRef.current const scene = sceneRef.current if (!renderer || !scene) return const dt = Math.min((wallMs - prevWallRef.current) / 1000, 0.1) prevWallRef.current = wallMs let rideT = rideTimeRef.current if (isPlayingRef.current) { rideT = (wallMs - startWallRef.current) / 1000 rideTimeRef.current = rideT if (rideT - lastUiUpdateRef.current > 0.25) { setRideTime(rideT) lastUiUpdateRef.current = rideT // Emit current s_frac for the plot cursor const d = rideDataRef.current if (d && onRideProgress) { const ci = bisect(d.timeArray, rideT) const ci1 = Math.min(ci + 1, d.count - 1) const ca = (d.timeArray[ci1] > d.timeArray[ci]) ? (rideT - d.timeArray[ci]) / (d.timeArray[ci1] - d.timeArray[ci]) : 0 onRideProgress(d.sFrac[ci] + (d.sFrac[ci1] - d.sFrac[ci]) * ca) } } const data = rideDataRef.current if (data && rideT >= data.totalDuration) { rideT = data.totalDuration rideTimeRef.current = rideT isPlayingRef.current = false setIsPlaying(false) setRideTime(rideT) } } // Compute target camera state for current ride time const data = rideDataRef.current if (data) { const target = computeCameraTarget(rideT, data, cameraModeRef.current) if (needsInitRef.current) { // Snap to target on first frame after play to avoid lerping from wrong pos smoothPosRef.current.copy(target.pos) smoothQuatRef.current.copy(target.quat) needsInitRef.current = false } else { const posAlpha = 1 - Math.exp(-dt / POS_TAU) const rotAlpha = 1 - Math.exp(-dt / ROT_TAU) smoothPosRef.current.lerp(target.pos, posAlpha) smoothQuatRef.current.slerp(target.quat, rotAlpha) } cameraRef.current.position.copy(smoothPosRef.current) cameraRef.current.quaternion.copy(smoothQuatRef.current) cameraRef.current.updateMatrixWorld() } renderer.render(scene, cameraRef.current) rafRef.current = requestAnimationFrame(tick) } prevWallRef.current = performance.now() rafRef.current = requestAnimationFrame(tick) } // ── Playback controls ───────────────────────────────────────────────────── const play = useCallback(() => { if (!rideDataRef.current) return startWallRef.current = performance.now() - rideTimeRef.current * 1000 lastUiUpdateRef.current = -1 isPlayingRef.current = true needsInitRef.current = false // don't re-snap if resuming from pause setIsPlaying(true) }, []) const pause = useCallback(() => { isPlayingRef.current = false setIsPlaying(false) }, []) const stop = useCallback(() => { isPlayingRef.current = false rideTimeRef.current = 0 setIsPlaying(false) setRideTime(0) onStop() }, [onStop]) // Start render loop on mount; auto-play immediately useEffect(() => { needsInitRef.current = true startLoop() // Auto-play const data = rideDataRef.current if (data) { startWallRef.current = performance.now() lastUiUpdateRef.current = -1 isPlayingRef.current = true setIsPlaying(true) } return () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null } } }, []) // eslint-disable-line react-hooks/exhaustive-deps // ── UI ──────────────────────────────────────────────────────────────────── const controls = (
Camera
{formatRideTime(rideTime)} / {formatRideTime(totalDuration)}
) return ( <> {createPortal(controls, document.body)} ) }