rcnn/web/src/coaster/RideRenderer.tsx
2026-04-21 21:21:39 +02:00

501 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLCanvasElement>(null)
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
const cameraRef = useRef(new THREE.PerspectiveCamera(75, 1, 0.1, 50000))
const sceneRef = useRef<THREE.Scene | null>(null)
const rafRef = useRef<number | null>(null)
// Ride playback refs (mutable, used inside rAF loop)
const rideDataRef = useRef<RideData | null>(null)
const isPlayingRef = useRef(false)
const rideTimeRef = useRef(0)
const startWallRef = useRef(0)
const lastUiUpdateRef = useRef(-1)
const cameraModeRef = useRef<CameraMode>('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<CameraMode>('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 = (
<div className={styles.rideBar}>
<button
className={styles.ridePlayBtn}
onClick={isPlaying ? pause : play}
title={isPlaying ? 'Pause' : 'Resume'}
>
{isPlaying ? '⏸' : '▶'}
</button>
<button className={styles.rideStopBtn} onClick={stop} title="Stop ride">
</button>
<div className={styles.rideBarDivider} />
<span className={styles.rideCameraLabel}>Camera</span>
<button
className={`${styles.rideCameraBtn}${cameraMode === 'first' ? ` ${styles.rideCameraActive}` : ''}`}
onClick={() => setCameraMode('first')}
>
1st Person
</button>
<button
className={`${styles.rideCameraBtn}${cameraMode === 'third' ? ` ${styles.rideCameraActive}` : ''}`}
onClick={() => setCameraMode('third')}
>
3rd Person
</button>
<div className={styles.rideBarDivider} />
<span className={styles.rideTimeDisplay}>
{formatRideTime(rideTime)}
<span className={styles.rideTimeSep}>/</span>
{formatRideTime(totalDuration)}
</span>
</div>
)
return (
<>
<canvas ref={canvasRef} className={styles.canvas} />
{createPortal(controls, document.body)}
</>
)
}