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
}