From ff11fe1d2a99f968301011956583abacfe2501cd Mon Sep 17 00:00:00 2001 From: munsel Date: Tue, 21 Apr 2026 21:21:39 +0200 Subject: [PATCH] add riderenderer --- backend/apps/coaster/physics.py | 20 +- .../AccelerationStripsPanel.module.css | 122 +++++ web/src/coaster/AccelerationStripsPanel.tsx | 61 +++ web/src/coaster/CoasterEditorPage.module.css | 126 +++++ web/src/coaster/CoasterEditorPage.tsx | 160 ++++-- web/src/coaster/RideRenderer.module.css | 121 +++++ web/src/coaster/RideRenderer.tsx | 500 ++++++++++++++++++ web/src/coaster/SimulationPlots.module.css | 2 +- web/src/coaster/SimulationPlots.tsx | 69 +-- web/src/coaster/useCoasterPath.ts | 6 +- web/src/coaster/useTerrainCapture.ts | 190 +++++++ web/src/types/api.ts | 1 + 12 files changed, 1272 insertions(+), 106 deletions(-) create mode 100644 web/src/coaster/AccelerationStripsPanel.module.css create mode 100644 web/src/coaster/AccelerationStripsPanel.tsx create mode 100644 web/src/coaster/RideRenderer.module.css create mode 100644 web/src/coaster/RideRenderer.tsx create mode 100644 web/src/coaster/useTerrainCapture.ts diff --git a/backend/apps/coaster/physics.py b/backend/apps/coaster/physics.py index d62a41d..4ca1197 100644 --- a/backend/apps/coaster/physics.py +++ b/backend/apps/coaster/physics.py @@ -301,7 +301,7 @@ def compute_diagnostics(points, velocities, k, B) -> dict: # ── Profile arrays ──────────────────────────────────────────────────────────── -def build_profile_arrays(s, velocities, k, downsample_factor) -> dict: +def build_profile_arrays(s, velocities, k, downsample_factor, B, T) -> dict: """Return downsampled per-point arrays for frontend charting.""" dvds = np.gradient(velocities, s) accel = velocities * dvds # dv/dt = v * dv/ds (m/s²) @@ -319,11 +319,27 @@ def build_profile_arrays(s, velocities, k, downsample_factor) -> dict: safe_v = np.maximum(velocities, 1.0) total_duration_s = float(np.trapz(1.0 / safe_v, s)) + # Bank / roll angle: signed angle between apparent-down (B) and true vertical, + # measured around the forward tangent. + # right = T × [0,0,1] (local right when looking forward) + # roll = atan2(dot(B, right), -B_z) + # 0° = flat, +90° = banked right, −90° = banked left. + right = np.cross(T, np.array([0.0, 0.0, 1.0])) + r_norm = np.linalg.norm(right, axis=1, keepdims=True) + r_norm = np.where(r_norm < 1e-9, 1.0, r_norm) + right = right / r_norm + roll_rad = np.arctan2( + np.einsum('ij,ij->i', B, right), + -B[:, 2], + ) + roll_deg = np.degrees(roll_rad) + return { 's_frac': s_frac, 'velocity_ms': velocities[idx].tolist(), 'accel_ms2': accel[idx].tolist(), 'g_force': gf[idx].tolist(), + 'roll_deg': roll_deg[idx].tolist(), 'total_length_m': total_length_m, 'total_duration_s': total_duration_s, } @@ -413,7 +429,7 @@ def generate_rails( B = smooth_binormals(B, T, iterations=binormal_smooth_iterations) diag = compute_diagnostics(pts, velocities, k, B) - profile = build_profile_arrays(s, velocities, k, downsample_factor) + profile = build_profile_arrays(s, velocities, k, downsample_factor, B, T) # Rail positions: offset perpendicular to tangent within the binormal plane crosses = np.cross(B, T) # (n, 3) diff --git a/web/src/coaster/AccelerationStripsPanel.module.css b/web/src/coaster/AccelerationStripsPanel.module.css new file mode 100644 index 0000000..3c03966 --- /dev/null +++ b/web/src/coaster/AccelerationStripsPanel.module.css @@ -0,0 +1,122 @@ +.panel { + position: fixed; + right: 16px; + top: 60px; + z-index: 600; + width: 280px; + background: rgba(8, 8, 12, 0.84); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + overflow: hidden; +} + +.toggle { + width: 100%; + padding: 10px 14px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.75); + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-align: left; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.15s; +} +.toggle:hover { + background: rgba(255, 255, 255, 0.06); +} + +.arrow { + font-style: normal; + display: inline-block; + transition: transform 0.15s; + font-size: 10px; + color: rgba(255, 255, 255, 0.4); +} +.arrowOpen { + transform: rotate(90deg); +} + +.count { + margin-left: auto; + font-size: 11px; + font-weight: 500; + color: rgba(245, 158, 11, 0.7); +} + +.body { + padding: 4px 12px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +.stripRow { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + font-size: 12px; +} + +.stripIndex { + color: rgba(245, 158, 11, 0.9); + font-weight: 600; + min-width: 44px; +} + +.stripRange { + color: rgba(255, 255, 255, 0.38); + font-size: 11px; + min-width: 56px; +} + +.accelInput { + width: 50px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + color: #fff; + font-size: 13px; + font-weight: 600; + padding: 2px 5px; + text-align: right; +} +.accelInput:focus { + outline: none; + border-color: rgba(245, 158, 11, 0.5); +} + +.stripUnit { + color: rgba(255, 255, 255, 0.28); + font-size: 11px; +} + +.stripDelete { + margin-left: auto; + width: 20px; + height: 20px; + border-radius: 4px; + border: 1px solid rgba(255, 69, 58, 0.25); + background: rgba(255, 69, 58, 0.08); + color: rgba(255, 100, 80, 0.85); + cursor: pointer; + font-size: 14px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.stripDelete:hover { + background: rgba(255, 69, 58, 0.2); +} + +.empty { + font-size: 11px; + color: rgba(255, 255, 255, 0.25); + padding: 4px 0 2px; +} diff --git a/web/src/coaster/AccelerationStripsPanel.tsx b/web/src/coaster/AccelerationStripsPanel.tsx new file mode 100644 index 0000000..ef695f8 --- /dev/null +++ b/web/src/coaster/AccelerationStripsPanel.tsx @@ -0,0 +1,61 @@ +import { useState, useEffect } from 'react' +import type { AccelerationStrip } from '../types/api' +import styles from './AccelerationStripsPanel.module.css' + +interface Props { + strips: AccelerationStrip[] + onRemoveStrip: (id: string) => void + onUpdateStrip: (id: string, accel_ms2: number) => void +} + +function pct(v: number) { return `${(v * 100).toFixed(0)}%` } + +export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }: Props) { + const [open, setOpen] = useState(true) + + // Auto-open when a strip is added + useEffect(() => { if (strips.length > 0) setOpen(true) }, [strips.length]) + + return ( +
+ + + {open && ( +
+ {strips.length === 0 + ?

No strips placed. Switch to Strip mode to add.

+ : strips.map((s, i) => ( +
+ Strip {i + 1} + + {pct(s.startFrac)}–{pct(s.endFrac)} + + onUpdateStrip(s.id, Number(e.target.value))} + className={styles.accelInput} + /> + m/s² + +
+ )) + } +
+ )} +
+ ) +} diff --git a/web/src/coaster/CoasterEditorPage.module.css b/web/src/coaster/CoasterEditorPage.module.css index cf1422d..e262ceb 100644 --- a/web/src/coaster/CoasterEditorPage.module.css +++ b/web/src/coaster/CoasterEditorPage.module.css @@ -369,6 +369,132 @@ white-space: nowrap; } +/* ── Ride button (in normal toolbar) ────────────────────────────────────────── */ + +.rideBtn { + padding: 7px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: 1px solid rgba(96, 165, 250, 0.5); + background: rgba(96, 165, 250, 0.15); + color: #60a5fa; + transition: background 0.15s; +} +.rideBtn:hover { + background: rgba(96, 165, 250, 0.28); +} + +/* ── Ride control bar (replaces toolbar while riding) ───────────────────────── */ + +.rideBar { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 200; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: rgba(8, 8, 12, 0.88); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(96, 165, 250, 0.25); + border-radius: 14px; + white-space: nowrap; +} + +.ridePlayBtn { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid rgba(96, 165, 250, 0.5); + background: rgba(96, 165, 250, 0.18); + color: #60a5fa; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} +.ridePlayBtn:hover { + background: rgba(96, 165, 250, 0.32); +} + +.rideStopBtn { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} +.rideStopBtn:hover { + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.85); +} + +.rideBarDivider { + width: 1px; + height: 22px; + background: rgba(255, 255, 255, 0.1); + margin: 0 4px; + flex-shrink: 0; +} + +.rideCameraLabel { + font-size: 11px; + color: rgba(255, 255, 255, 0.3); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-right: 2px; +} + +.rideCameraBtn { + padding: 6px 12px; + border-radius: 7px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.rideCameraBtn:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); +} +.rideCameraActive { + background: rgba(96, 165, 250, 0.18); + border-color: rgba(96, 165, 250, 0.5); + color: #60a5fa; +} + +.rideTimeDisplay { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.75); + font-variant-numeric: tabular-nums; + min-width: 80px; + text-align: center; +} + +.rideTimeSep { + color: rgba(255, 255, 255, 0.25); + margin: 0 4px; +} + /* ── Loading overlay ─────────────────────────────────────────────────────────── */ .loading { diff --git a/web/src/coaster/CoasterEditorPage.tsx b/web/src/coaster/CoasterEditorPage.tsx index 34fde03..e0887ae 100644 --- a/web/src/coaster/CoasterEditorPage.tsx +++ b/web/src/coaster/CoasterEditorPage.tsx @@ -7,7 +7,10 @@ import { fetchChallengeDetail } from '../api/challenges' import { simulateCoaster, saveCoaster } from '../api/coaster' import { useCoasterPath } from './useCoasterPath' import { useAccelerationStrips } from './useAccelerationStrips' +import { useTerrainCapture } from './useTerrainCapture' +import { RideRenderer } from './RideRenderer' import { SimulationPlots } from './SimulationPlots' +import { AccelerationStripsPanel } from './AccelerationStripsPanel' import { CoasterListPanel } from './CoasterListPanel' import { effectivePosition } from './bezierUtils' import { useAuthStore } from '../store/authStore' @@ -68,9 +71,15 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) { const [showAnchors, setShowAnchors] = useState(true) const [showStrips, setShowStrips] = useState(true) const [coasterListKey, setCoasterListKey] = useState(0) + const [isRideMode, setIsRideMode] = useState(false) + const [rideCursor, setRideCursor] = useState(null) - const path = useCoasterPath(viewer, showPath, showAnchors) - const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips) + const path = useCoasterPath(viewer, showPath, showAnchors) + const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips) + const terrain = useTerrainCapture(viewer, simResult) + + // Exit ride mode whenever the sim result changes; clear cursor on exit + useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult]) // Refs for simulation result entities (cleared on each new run / unmount) const simEntitiesRef = useRef([]) @@ -263,6 +272,16 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) { return ( <> + {/* ── Three.js ride renderer (fullscreen, mounts over Cesium) ─────── */} + {isRideMode && simResult && terrain.captureData && ( + { setIsRideMode(false); setRideCursor(null) }} + onRideProgress={setRideCursor} + /> + )} + {/* ── Top bar ─────────────────────────────────────────────────────── */}
- {/* ── Mode toolbar ────────────────────────────────────────────────── */} -
- path.setMode('add')} /> - path.setMode('select')} /> - path.setMode('strip')} /> + {/* ── Mode toolbar (hidden while riding) ──────────────────────────── */} + {!isRideMode && ( +
+ path.setMode('add')} /> + path.setMode('select')} /> + path.setMode('strip')} /> -
+
- - + + - {path.anchors.length > 0 && ( - - {path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''} - - )} + {path.anchors.length > 0 && ( + + {path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''} + + )} -
+
- + -
- - - - -
+
+ + + + + + {simResult && ( + <> +
+ {terrain.status === 'loading' ? ( + Preparing… + ) : ( + + )} + + )} +
+ )} {/* ── Simulation error ─────────────────────────────────────────────── */} {simError && ( @@ -398,10 +436,18 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
)} - {/* ── Simulation plots + strip list (left panel) ──────────────────── */} - {(accel.strips.length > 0 || simResult?.profile) && ( + {/* ── Simulation profile charts (left panel) ──────────────────────── */} + {simResult?.profile && ( + )} + + {/* ── Acceleration strips (right panel) ───────────────────────────── */} + {accel.strips.length > 0 && ( + 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)} + + ) +} diff --git a/web/src/coaster/SimulationPlots.module.css b/web/src/coaster/SimulationPlots.module.css index 68210dd..e15d05c 100644 --- a/web/src/coaster/SimulationPlots.module.css +++ b/web/src/coaster/SimulationPlots.module.css @@ -2,7 +2,7 @@ position: fixed; left: 16px; top: 60px; - z-index: 200; + z-index: 600; /* above Three.js ride canvas (z-index 500) */ width: 340px; background: rgba(8, 8, 12, 0.84); backdrop-filter: blur(10px); diff --git a/web/src/coaster/SimulationPlots.tsx b/web/src/coaster/SimulationPlots.tsx index fa3b406..99d0595 100644 --- a/web/src/coaster/SimulationPlots.tsx +++ b/web/src/coaster/SimulationPlots.tsx @@ -9,8 +9,8 @@ import styles from './SimulationPlots.module.css' interface Props { profile: SimulationProfile | null strips: AccelerationStrip[] - onRemoveStrip: (id: string) => void - onUpdateStrip: (id: string, accel_ms2: number) => void + /** Current ride position in [0,1] track fraction; shows a cursor line when set. */ + rideCursor?: number | null } // ── Shared axis / tooltip styles ────────────────────────────────────────────── @@ -39,7 +39,7 @@ function formatDuration(s: number) { return `${m}m ${sec.toString().padStart(2, '0')}s` } -// ── Strip ReferenceAreas (shared across all charts) ─────────────────────────── +// ── Strip ReferenceAreas ────────────────────────────────────────────────────── function StripAreas({ strips }: { strips: AccelerationStrip[] }) { return ( @@ -65,11 +65,12 @@ interface ChartProps { dataKey: string color: string unit: string - strips: AccelerationStrip[] + strips?: AccelerationStrip[] showZero?: boolean + rideCursor?: number | null } -function ProfileChart({ data, dataKey, color, unit, strips, showZero }: ChartProps) { +function ProfileChart({ data, dataKey, color, unit, strips, showZero, rideCursor }: ChartProps) { return ( @@ -96,10 +97,18 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero }: ChartPro formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]} labelFormatter={(l: number) => `s = ${pct(l)}`} /> - + {strips && } {showZero && ( )} + {rideCursor != null && ( + + )} - - {/* ── Strip list ─────────────────────────────────────────────────── */} -

Acceleration Strips

- {strips.length === 0 - ?

No strips placed. Switch to Strip mode to add.

- : strips.map((s, i) => ( -
- Strip {i + 1} - - {pct(s.startFrac)}–{pct(s.endFrac)} - - onUpdateStrip(s.id, Number(e.target.value))} - className={styles.accelInput} - /> - m/s² - -
- )) - } - - {/* ── Charts ─────────────────────────────────────────────────────── */} {data ? ( <> -
Length @@ -194,17 +170,22 @@ export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip

Velocity (km/h)

Acceleration (m/s²)

G-force (g)

+

Roll / Bank (°)

+ ) : ( diff --git a/web/src/coaster/useCoasterPath.ts b/web/src/coaster/useCoasterPath.ts index 015a7fa..0c0104f 100644 --- a/web/src/coaster/useCoasterPath.ts +++ b/web/src/coaster/useCoasterPath.ts @@ -313,8 +313,10 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho return () => { if (!handler.isDestroyed()) handler.destroy() canvas.removeEventListener('contextmenu', suppressCtx) - enableCam() - viewer.scene.canvas.style.cursor = 'default' + if (!viewer.isDestroyed()) { + enableCam() + viewer.scene.canvas.style.cursor = 'default' + } } }, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/web/src/coaster/useTerrainCapture.ts b/web/src/coaster/useTerrainCapture.ts new file mode 100644 index 0000000..90a58e9 --- /dev/null +++ b/web/src/coaster/useTerrainCapture.ts @@ -0,0 +1,190 @@ +import { useState, useEffect, useRef } from 'react' +import * as Cesium from 'cesium' +import * as THREE from 'three' +import type { CoasterSimulationResult } from '../types/api' + +// ── Public types ─────────────────────────────────────────────────────────────── + +export interface TerrainCaptureData { + /** Exact tile-boundary bbox [west, south, east, north] in degrees */ + tileBbox: [number, number, number, number] + /** Stitched satellite imagery */ + imageBitmap: ImageBitmap + /** 64×64 grid of ENU positions (X=East, Y=Up, Z=−North) with terrain heights */ + terrainVertices: THREE.Vector3[] + gridSize: 64 + /** Geodetic origin used for ENU conversions */ + origin: [number, number, number] +} + +export type CaptureStatus = 'idle' | 'loading' | 'ready' | 'error' + +// ── Tile math (Web Mercator) ─────────────────────────────────────────────────── + +function lonLatToTile(lon: number, lat: number, z: number) { + const x = Math.floor((lon + 180) / 360 * 2 ** z) + const r = lat * Math.PI / 180 + const y = Math.floor( + (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * 2 ** z, + ) + return { x, y } +} + +function tileToLon(x: number, z: number) { + return x / 2 ** z * 360 - 180 +} + +function tileToLat(y: number, z: number) { + const n = Math.PI - 2 * Math.PI * y / 2 ** z + return Math.atan(Math.sinh(n)) * 180 / Math.PI +} + +// ── Geo → Three.js ENU (X=East, Y=Up, Z=−North) ─────────────────────────────── + +export function geoToEnu( + lon: number, lat: number, alt: number, + origin: [number, number, number], +): THREE.Vector3 { + const pt = Cesium.Cartesian3.fromDegrees(lon, lat, alt) + const org = Cesium.Cartesian3.fromDegrees(origin[0], origin[1], origin[2]) + const enuFrame = Cesium.Transforms.eastNorthUpToFixedFrame(org) + const inv = Cesium.Matrix4.inverseTransformation(enuFrame, new Cesium.Matrix4()) + const enu = Cesium.Matrix4.multiplyByPoint(inv, pt, new Cesium.Cartesian3()) + // ENU: x=East, y=North, z=Up → Three.js: x=East, y=Up, z=−North + return new THREE.Vector3(enu.x, enu.z, -enu.y) +} + +// ── Core capture logic ──────────────────────────────────────────────────────── + +const ESRI_TILE = (z: number, y: number, x: number) => + `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}` + +async function captureTerrainData( + terrainProvider: Cesium.TerrainProvider, + simResult: CoasterSimulationResult, +): Promise { + const origin = simResult.origin + + // ── Compute bounding box from both rails ────────────────────────────────── + const allPts = [...simResult.rail_1, ...simResult.rail_2] + let minLon = Infinity, maxLon = -Infinity + let minLat = Infinity, maxLat = -Infinity + for (const [lon, lat] of allPts) { + if (lon < minLon) minLon = lon + if (lon > maxLon) maxLon = lon + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + } + // 10% padding + const dLon = (maxLon - minLon) * 0.1 + const dLat = (maxLat - minLat) * 0.1 + minLon -= dLon; maxLon += dLon + minLat -= dLat; maxLat += dLat + + // ── Pick zoom level so tile count stays ≤ 16 ───────────────────────────── + let zoom = 17 + while (zoom > 10) { + const tMin = lonLatToTile(minLon, maxLat, zoom) + const tMax = lonLatToTile(maxLon, minLat, zoom) + const nx = tMax.x - tMin.x + 1 + const ny = tMax.y - tMin.y + 1 + if (nx * ny <= 16) break + zoom-- + } + + const tMin = lonLatToTile(minLon, maxLat, zoom) // NW corner → smallest tile y + const tMax = lonLatToTile(maxLon, minLat, zoom) // SE corner → largest tile y + + // Exact geographic extent of the stitched tile grid + const tileBbox: [number, number, number, number] = [ + tileToLon(tMin.x, zoom), // west + tileToLat(tMax.y + 1, zoom), // south + tileToLon(tMax.x + 1, zoom), // east + tileToLat(tMin.y, zoom), // north + ] + + const nx = tMax.x - tMin.x + 1 + const ny = tMax.y - tMin.y + 1 + const TILE_PX = 256 + + // ── Fetch satellite tiles in parallel ──────────────────────────────────── + const tileImages = await Promise.all( + Array.from({ length: ny }, (_, tj) => + Array.from({ length: nx }, (_, ti) => + fetch(ESRI_TILE(zoom, tMin.y + tj, tMin.x + ti)) + .then(r => r.blob()) + .then(b => createImageBitmap(b)) + .then(img => ({ ti, tj, img })), + ), + ).flat(), + ) + + // ── Stitch into a single OffscreenCanvas ───────────────────────────────── + const canvas = new OffscreenCanvas(nx * TILE_PX, ny * TILE_PX) + const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D + for (const { ti, tj, img } of tileImages) { + ctx.drawImage(img, ti * TILE_PX, tj * TILE_PX) + } + const imageBitmap = await createImageBitmap(canvas) + + // ── Sample terrain heights on a 64×64 grid over the tile extent ────────── + const GRID = 64 + const cartographics: Cesium.Cartographic[] = [] + for (let j = 0; j < GRID; j++) { + for (let i = 0; i < GRID; i++) { + const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * i / (GRID - 1) + const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * j / (GRID - 1) + cartographics.push(Cesium.Cartographic.fromDegrees(lon, lat)) + } + } + + const sampled = await Cesium.sampleTerrainMostDetailed(terrainProvider, cartographics) + + // ── Convert to ENU Three.js vectors ────────────────────────────────────── + const terrainVertices: THREE.Vector3[] = sampled.map((c, idx) => { + const lon = tileBbox[0] + (tileBbox[2] - tileBbox[0]) * (idx % GRID) / (GRID - 1) + const lat = tileBbox[1] + (tileBbox[3] - tileBbox[1]) * Math.floor(idx / GRID) / (GRID - 1) + return geoToEnu(lon, lat, c.height ?? 0, origin) + }) + + return { tileBbox, imageBitmap, terrainVertices, gridSize: 64, origin } +} + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export function useTerrainCapture( + viewer: Cesium.Viewer, + simResult: CoasterSimulationResult | null, +) { + const [status, setStatus] = useState('idle') + const [captureData, setCaptureData] = useState(null) + const abortRef = useRef(false) + + useEffect(() => { + // Reset on new result + setCaptureData(null) + abortRef.current = true // cancel any in-flight capture + + if (!simResult) { + setStatus('idle') + return + } + + abortRef.current = false + setStatus('loading') + + captureTerrainData(viewer.terrainProvider, simResult) + .then(data => { + if (abortRef.current) return + setCaptureData(data) + setStatus('ready') + }) + .catch(err => { + if (abortRef.current) return + console.error('Terrain capture failed:', err) + setStatus('error') + }) + }, [simResult, viewer]) + + return { status, captureData } +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index c58c099..ca2a7ec 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -148,6 +148,7 @@ export interface SimulationProfile { velocity_ms: number[] accel_ms2: number[] g_force: number[] + roll_deg: number[] // bank angle in degrees; 0 = flat, +ve = right, −ve = left total_length_m: number total_duration_s: number }