add riderenderer

This commit is contained in:
munsel 2026-04-21 21:21:39 +02:00
parent b38b8be3e3
commit ff11fe1d2a
12 changed files with 1272 additions and 106 deletions

View File

@ -301,7 +301,7 @@ def compute_diagnostics(points, velocities, k, B) -> dict:
# ── Profile arrays ──────────────────────────────────────────────────────────── # ── 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.""" """Return downsampled per-point arrays for frontend charting."""
dvds = np.gradient(velocities, s) dvds = np.gradient(velocities, s)
accel = velocities * dvds # dv/dt = v * dv/ds (m/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) safe_v = np.maximum(velocities, 1.0)
total_duration_s = float(np.trapz(1.0 / safe_v, s)) 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 { return {
's_frac': s_frac, 's_frac': s_frac,
'velocity_ms': velocities[idx].tolist(), 'velocity_ms': velocities[idx].tolist(),
'accel_ms2': accel[idx].tolist(), 'accel_ms2': accel[idx].tolist(),
'g_force': gf[idx].tolist(), 'g_force': gf[idx].tolist(),
'roll_deg': roll_deg[idx].tolist(),
'total_length_m': total_length_m, 'total_length_m': total_length_m,
'total_duration_s': total_duration_s, 'total_duration_s': total_duration_s,
} }
@ -413,7 +429,7 @@ def generate_rails(
B = smooth_binormals(B, T, iterations=binormal_smooth_iterations) B = smooth_binormals(B, T, iterations=binormal_smooth_iterations)
diag = compute_diagnostics(pts, velocities, k, B) 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 # Rail positions: offset perpendicular to tangent within the binormal plane
crosses = np.cross(B, T) # (n, 3) crosses = np.cross(B, T) # (n, 3)

View File

@ -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;
}

View File

@ -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 (
<div className={styles.panel}>
<button className={styles.toggle} onClick={() => setOpen(o => !o)}>
<i className={`${styles.arrow}${open ? ` ${styles.arrowOpen}` : ''}`}></i>
Acceleration Strips
<span className={styles.count}>{strips.length > 0 ? `${strips.length} strip${strips.length !== 1 ? 's' : ''}` : ''}</span>
</button>
{open && (
<div className={styles.body}>
{strips.length === 0
? <p className={styles.empty}>No strips placed. Switch to Strip mode to add.</p>
: strips.map((s, i) => (
<div key={s.id} className={styles.stripRow}>
<span className={styles.stripIndex}>Strip {i + 1}</span>
<span className={styles.stripRange}>
{pct(s.startFrac)}{pct(s.endFrac)}
</span>
<input
type="number"
step={0.5}
min={-50}
max={50}
value={s.accel_ms2}
onChange={e => onUpdateStrip(s.id, Number(e.target.value))}
className={styles.accelInput}
/>
<span className={styles.stripUnit}>m/s²</span>
<button
className={styles.stripDelete}
onClick={() => onRemoveStrip(s.id)}
title="Remove strip"
>
×
</button>
</div>
))
}
</div>
)}
</div>
)
}

View File

@ -369,6 +369,132 @@
white-space: nowrap; 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 overlay ─────────────────────────────────────────────────────────── */
.loading { .loading {

View File

@ -7,7 +7,10 @@ import { fetchChallengeDetail } from '../api/challenges'
import { simulateCoaster, saveCoaster } from '../api/coaster' import { simulateCoaster, saveCoaster } from '../api/coaster'
import { useCoasterPath } from './useCoasterPath' import { useCoasterPath } from './useCoasterPath'
import { useAccelerationStrips } from './useAccelerationStrips' import { useAccelerationStrips } from './useAccelerationStrips'
import { useTerrainCapture } from './useTerrainCapture'
import { RideRenderer } from './RideRenderer'
import { SimulationPlots } from './SimulationPlots' import { SimulationPlots } from './SimulationPlots'
import { AccelerationStripsPanel } from './AccelerationStripsPanel'
import { CoasterListPanel } from './CoasterListPanel' import { CoasterListPanel } from './CoasterListPanel'
import { effectivePosition } from './bezierUtils' import { effectivePosition } from './bezierUtils'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
@ -68,9 +71,15 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
const [showAnchors, setShowAnchors] = useState(true) const [showAnchors, setShowAnchors] = useState(true)
const [showStrips, setShowStrips] = useState(true) const [showStrips, setShowStrips] = useState(true)
const [coasterListKey, setCoasterListKey] = useState(0) const [coasterListKey, setCoasterListKey] = useState(0)
const [isRideMode, setIsRideMode] = useState(false)
const [rideCursor, setRideCursor] = useState<number | null>(null)
const path = useCoasterPath(viewer, showPath, showAnchors) const path = useCoasterPath(viewer, showPath, showAnchors)
const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips) 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) // Refs for simulation result entities (cleared on each new run / unmount)
const simEntitiesRef = useRef<Cesium.Entity[]>([]) const simEntitiesRef = useRef<Cesium.Entity[]>([])
@ -263,6 +272,16 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
return ( return (
<> <>
{/* ── Three.js ride renderer (fullscreen, mounts over Cesium) ─────── */}
{isRideMode && simResult && terrain.captureData && (
<RideRenderer
simResult={simResult}
captureData={terrain.captureData}
onStop={() => { setIsRideMode(false); setRideCursor(null) }}
onRideProgress={setRideCursor}
/>
)}
{/* ── Top bar ─────────────────────────────────────────────────────── */} {/* ── Top bar ─────────────────────────────────────────────────────── */}
<div className={styles.topBar}> <div className={styles.topBar}>
<button className={styles.backBtn} onClick={() => navigate('/')}> <button className={styles.backBtn} onClick={() => navigate('/')}>
@ -274,64 +293,83 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
<span className={styles.badge}>Coaster Editor</span> <span className={styles.badge}>Coaster Editor</span>
</div> </div>
{/* ── Mode toolbar ────────────────────────────────────────────────── */} {/* ── Mode toolbar (hidden while riding) ──────────────────────────── */}
<div className={styles.toolbar}> {!isRideMode && (
<ModeButton label="Add Point" active={path.mode === 'add'} onClick={() => path.setMode('add')} /> <div className={styles.toolbar}>
<ModeButton label="Select / Move" active={path.mode === 'select'} onClick={() => path.setMode('select')} /> <ModeButton label="Add Point" active={path.mode === 'add'} onClick={() => path.setMode('add')} />
<ModeButton label="Strip" active={path.mode === 'strip'} onClick={() => path.setMode('strip')} /> <ModeButton label="Select / Move" active={path.mode === 'select'} onClick={() => path.setMode('select')} />
<ModeButton label="Strip" active={path.mode === 'strip'} onClick={() => path.setMode('strip')} />
<div className={styles.divider} /> <div className={styles.divider} />
<button className={styles.ghostBtn} onClick={path.undoLast} disabled={path.anchors.length === 0}> <button className={styles.ghostBtn} onClick={path.undoLast} disabled={path.anchors.length === 0}>
Undo Undo
</button> </button>
<button className={styles.clearBtn} onClick={path.clearAll} disabled={path.anchors.length === 0}> <button className={styles.clearBtn} onClick={path.clearAll} disabled={path.anchors.length === 0}>
Clear Clear
</button> </button>
{path.anchors.length > 0 && ( {path.anchors.length > 0 && (
<span className={styles.countBadge}> <span className={styles.countBadge}>
{path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''} {path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''}
</span> </span>
)} )}
<div className={styles.divider} /> <div className={styles.divider} />
<button <button
className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`} className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`}
onClick={handleSimulate} onClick={handleSimulate}
disabled={path.anchors.length < 2 || simulating} disabled={path.anchors.length < 2 || simulating}
> >
{simulating ? 'Simulating…' : 'Simulate'} {simulating ? 'Simulating…' : 'Simulate'}
</button> </button>
<div className={styles.divider} /> <div className={styles.divider} />
<button <button
className={`${styles.toolBtn}${showPath ? ` ${styles.active}` : ''}`} className={`${styles.toolBtn}${showPath ? ` ${styles.active}` : ''}`}
onClick={() => setShowPath(p => !p)} onClick={() => setShowPath(p => !p)}
> >
Path Path
</button> </button>
<button <button
className={`${styles.toolBtn}${showAnchors ? ` ${styles.active}` : ''}`} className={`${styles.toolBtn}${showAnchors ? ` ${styles.active}` : ''}`}
onClick={() => setShowAnchors(a => !a)} onClick={() => setShowAnchors(a => !a)}
> >
Anchors Anchors
</button> </button>
<button <button
className={`${styles.toolBtn}${showStrips ? ` ${styles.active}` : ''}`} className={`${styles.toolBtn}${showStrips ? ` ${styles.active}` : ''}`}
onClick={() => setShowStrips(s => !s)} onClick={() => setShowStrips(s => !s)}
> >
Strips Strips
</button> </button>
<button <button
className={styles.ghostBtn} className={styles.ghostBtn}
onClick={handleSave} onClick={handleSave}
disabled={path.anchors.length < 2} disabled={path.anchors.length < 2}
> >
Save Save
</button> </button>
</div>
{simResult && (
<>
<div className={styles.divider} />
{terrain.status === 'loading' ? (
<span className={styles.badge}>Preparing</span>
) : (
<button
className={styles.rideBtn}
onClick={() => setIsRideMode(true)}
disabled={terrain.status !== 'ready'}
>
Ride
</button>
)}
</>
)}
</div>
)}
{/* ── Simulation error ─────────────────────────────────────────────── */} {/* ── Simulation error ─────────────────────────────────────────────── */}
{simError && ( {simError && (
@ -398,10 +436,18 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
</div> </div>
)} )}
{/* ── Simulation plots + strip list (left panel) ──────────────────── */} {/* ── Simulation profile charts (left panel) ──────────────────────── */}
{(accel.strips.length > 0 || simResult?.profile) && ( {simResult?.profile && (
<SimulationPlots <SimulationPlots
profile={simResult?.profile ?? null} profile={simResult.profile}
strips={accel.strips}
rideCursor={rideCursor}
/>
)}
{/* ── Acceleration strips (right panel) ───────────────────────────── */}
{accel.strips.length > 0 && (
<AccelerationStripsPanel
strips={accel.strips} strips={accel.strips}
onRemoveStrip={accel.removeStrip} onRemoveStrip={accel.removeStrip}
onUpdateStrip={accel.updateStrip} onUpdateStrip={accel.updateStrip}

View File

@ -0,0 +1,121 @@
/* ── Full-screen Three.js canvas ─────────────────────────────────────────────── */
.canvas {
position: fixed;
inset: 0;
z-index: 500;
/* pointer events pass through — the ride control bar handles interaction */
pointer-events: none;
display: block;
width: 100% !important;
height: 100% !important;
}
/* ── Ride control bar (same visual design as CoasterEditorPage rideBar) ─────── */
.rideBar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 501;
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;
}

View File

@ -0,0 +1,500 @@
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)}
</>
)
}

View File

@ -2,7 +2,7 @@
position: fixed; position: fixed;
left: 16px; left: 16px;
top: 60px; top: 60px;
z-index: 200; z-index: 600; /* above Three.js ride canvas (z-index 500) */
width: 340px; width: 340px;
background: rgba(8, 8, 12, 0.84); background: rgba(8, 8, 12, 0.84);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);

View File

@ -9,8 +9,8 @@ import styles from './SimulationPlots.module.css'
interface Props { interface Props {
profile: SimulationProfile | null profile: SimulationProfile | null
strips: AccelerationStrip[] strips: AccelerationStrip[]
onRemoveStrip: (id: string) => void /** Current ride position in [0,1] track fraction; shows a cursor line when set. */
onUpdateStrip: (id: string, accel_ms2: number) => void rideCursor?: number | null
} }
// ── Shared axis / tooltip styles ────────────────────────────────────────────── // ── Shared axis / tooltip styles ──────────────────────────────────────────────
@ -39,7 +39,7 @@ function formatDuration(s: number) {
return `${m}m ${sec.toString().padStart(2, '0')}s` return `${m}m ${sec.toString().padStart(2, '0')}s`
} }
// ── Strip ReferenceAreas (shared across all charts) ─────────────────────────── // ── Strip ReferenceAreas ──────────────────────────────────────────────────────
function StripAreas({ strips }: { strips: AccelerationStrip[] }) { function StripAreas({ strips }: { strips: AccelerationStrip[] }) {
return ( return (
@ -65,11 +65,12 @@ interface ChartProps {
dataKey: string dataKey: string
color: string color: string
unit: string unit: string
strips: AccelerationStrip[] strips?: AccelerationStrip[]
showZero?: boolean 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 ( return (
<ResponsiveContainer width="100%" height={108}> <ResponsiveContainer width="100%" height={108}>
<LineChart data={data} margin={{ top: 4, right: 6, bottom: 0, left: 28 }}> <LineChart data={data} margin={{ top: 4, right: 6, bottom: 0, left: 28 }}>
@ -96,10 +97,18 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero }: ChartPro
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]} formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]}
labelFormatter={(l: number) => `s = ${pct(l)}`} labelFormatter={(l: number) => `s = ${pct(l)}`}
/> />
<StripAreas strips={strips} /> {strips && <StripAreas strips={strips} />}
{showZero && ( {showZero && (
<ReferenceLine y={0} stroke="rgba(255,255,255,0.18)" strokeDasharray="4 3" /> <ReferenceLine y={0} stroke="rgba(255,255,255,0.18)" strokeDasharray="4 3" />
)} )}
{rideCursor != null && (
<ReferenceLine
x={rideCursor}
stroke="rgba(255,255,255,0.75)"
strokeWidth={1.5}
strokeDasharray="3 3"
/>
)}
<Line <Line
dataKey={dataKey} dataKey={dataKey}
dot={false} dot={false}
@ -114,7 +123,7 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero }: ChartPro
// ── Main component ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip }: Props) { export function SimulationPlots({ profile, strips, rideCursor }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Open the panel when a profile arrives; never auto-close it // Open the panel when a profile arrives; never auto-close it
@ -126,6 +135,7 @@ export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip
velocity: profile.velocity_ms[i] * 3.6, // m/s → km/h velocity: profile.velocity_ms[i] * 3.6, // m/s → km/h
acceleration: profile.accel_ms2[i], acceleration: profile.accel_ms2[i],
gForce: profile.g_force[i], gForce: profile.g_force[i],
roll: profile.roll_deg[i],
})) }))
: null : null
@ -138,42 +148,8 @@ export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip
{open && ( {open && (
<div className={styles.plotsBody}> <div className={styles.plotsBody}>
{/* ── Strip list ─────────────────────────────────────────────────── */}
<p className={styles.sectionLabel}>Acceleration Strips</p>
{strips.length === 0
? <p className={styles.noStrips}>No strips placed. Switch to Strip mode to add.</p>
: strips.map((s, i) => (
<div key={s.id} className={styles.stripRow}>
<span className={styles.stripIndex}>Strip {i + 1}</span>
<span className={styles.stripRange}>
{pct(s.startFrac)}{pct(s.endFrac)}
</span>
<input
type="number"
step={0.5}
min={-50}
max={50}
value={s.accel_ms2}
onChange={e => onUpdateStrip(s.id, Number(e.target.value))}
className={styles.accelInput}
/>
<span className={styles.stripUnit}>m/s²</span>
<button
className={styles.stripDelete}
onClick={() => onRemoveStrip(s.id)}
title="Remove strip"
>
×
</button>
</div>
))
}
{/* ── Charts ─────────────────────────────────────────────────────── */}
{data ? ( {data ? (
<> <>
<div className={styles.divider} />
<div className={styles.rideStats}> <div className={styles.rideStats}>
<span className={styles.rideStat}> <span className={styles.rideStat}>
<span className={styles.rideStatLabel}>Length</span> <span className={styles.rideStatLabel}>Length</span>
@ -194,17 +170,22 @@ export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip
<p className={styles.chartLabel}>Velocity (km/h)</p> <p className={styles.chartLabel}>Velocity (km/h)</p>
<ProfileChart <ProfileChart
data={data} dataKey="velocity" color="#4ade80" data={data} dataKey="velocity" color="#4ade80"
unit="km/h" strips={strips} unit="km/h" strips={strips} rideCursor={rideCursor}
/> />
<p className={styles.chartLabel}>Acceleration (m/s²)</p> <p className={styles.chartLabel}>Acceleration (m/s²)</p>
<ProfileChart <ProfileChart
data={data} dataKey="acceleration" color="#60a5fa" data={data} dataKey="acceleration" color="#60a5fa"
unit="m/s²" strips={strips} showZero unit="m/s²" strips={strips} showZero rideCursor={rideCursor}
/> />
<p className={styles.chartLabel}>G-force (g)</p> <p className={styles.chartLabel}>G-force (g)</p>
<ProfileChart <ProfileChart
data={data} dataKey="gForce" color="#f87171" data={data} dataKey="gForce" color="#f87171"
unit="g" strips={strips} showZero unit="g" showZero rideCursor={rideCursor}
/>
<p className={styles.chartLabel}>Roll / Bank (°)</p>
<ProfileChart
data={data} dataKey="roll" color="#a78bfa"
unit="°" showZero rideCursor={rideCursor}
/> />
</> </>
) : ( ) : (

View File

@ -313,8 +313,10 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
return () => { return () => {
if (!handler.isDestroyed()) handler.destroy() if (!handler.isDestroyed()) handler.destroy()
canvas.removeEventListener('contextmenu', suppressCtx) canvas.removeEventListener('contextmenu', suppressCtx)
enableCam() if (!viewer.isDestroyed()) {
viewer.scene.canvas.style.cursor = 'default' enableCam()
viewer.scene.canvas.style.cursor = 'default'
}
} }
}, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps }, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps

View File

@ -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<TerrainCaptureData> {
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<CaptureStatus>('idle')
const [captureData, setCaptureData] = useState<TerrainCaptureData | null>(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 }
}

View File

@ -148,6 +148,7 @@ export interface SimulationProfile {
velocity_ms: number[] velocity_ms: number[]
accel_ms2: number[] accel_ms2: number[]
g_force: number[] g_force: number[]
roll_deg: number[] // bank angle in degrees; 0 = flat, +ve = right, ve = left
total_length_m: number total_length_m: number
total_duration_s: number total_duration_s: number
} }