add riderenderer
This commit is contained in:
parent
b38b8be3e3
commit
ff11fe1d2a
@ -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)
|
||||
|
||||
122
web/src/coaster/AccelerationStripsPanel.module.css
Normal file
122
web/src/coaster/AccelerationStripsPanel.module.css
Normal 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;
|
||||
}
|
||||
61
web/src/coaster/AccelerationStripsPanel.tsx
Normal file
61
web/src/coaster/AccelerationStripsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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<number | null>(null)
|
||||
|
||||
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<Cesium.Entity[]>([])
|
||||
@ -263,6 +272,16 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
|
||||
|
||||
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 ─────────────────────────────────────────────────────── */}
|
||||
<div className={styles.topBar}>
|
||||
<button className={styles.backBtn} onClick={() => navigate('/')}>
|
||||
@ -274,7 +293,8 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
|
||||
<span className={styles.badge}>Coaster Editor</span>
|
||||
</div>
|
||||
|
||||
{/* ── Mode toolbar ────────────────────────────────────────────────── */}
|
||||
{/* ── Mode toolbar (hidden while riding) ──────────────────────────── */}
|
||||
{!isRideMode && (
|
||||
<div className={styles.toolbar}>
|
||||
<ModeButton label="Add Point" active={path.mode === 'add'} onClick={() => path.setMode('add')} />
|
||||
<ModeButton label="Select / Move" active={path.mode === 'select'} onClick={() => path.setMode('select')} />
|
||||
@ -331,7 +351,25 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
{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 ─────────────────────────────────────────────── */}
|
||||
{simError && (
|
||||
@ -398,10 +436,18 @@ function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Simulation plots + strip list (left panel) ──────────────────── */}
|
||||
{(accel.strips.length > 0 || simResult?.profile) && (
|
||||
{/* ── Simulation profile charts (left panel) ──────────────────────── */}
|
||||
{simResult?.profile && (
|
||||
<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}
|
||||
onRemoveStrip={accel.removeStrip}
|
||||
onUpdateStrip={accel.updateStrip}
|
||||
|
||||
121
web/src/coaster/RideRenderer.module.css
Normal file
121
web/src/coaster/RideRenderer.module.css
Normal 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;
|
||||
}
|
||||
500
web/src/coaster/RideRenderer.tsx
Normal file
500
web/src/coaster/RideRenderer.tsx
Normal 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)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 (
|
||||
<ResponsiveContainer width="100%" height={108}>
|
||||
<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]}
|
||||
labelFormatter={(l: number) => `s = ${pct(l)}`}
|
||||
/>
|
||||
<StripAreas strips={strips} />
|
||||
{strips && <StripAreas strips={strips} />}
|
||||
{showZero && (
|
||||
<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
|
||||
dataKey={dataKey}
|
||||
dot={false}
|
||||
@ -114,7 +123,7 @@ function ProfileChart({ data, dataKey, color, unit, strips, showZero }: ChartPro
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip }: Props) {
|
||||
export function SimulationPlots({ profile, strips, rideCursor }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// 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
|
||||
acceleration: profile.accel_ms2[i],
|
||||
gForce: profile.g_force[i],
|
||||
roll: profile.roll_deg[i],
|
||||
}))
|
||||
: null
|
||||
|
||||
@ -138,42 +148,8 @@ export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip
|
||||
|
||||
{open && (
|
||||
<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 ? (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.rideStats}>
|
||||
<span className={styles.rideStat}>
|
||||
<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>
|
||||
<ProfileChart
|
||||
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>
|
||||
<ProfileChart
|
||||
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>
|
||||
<ProfileChart
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -313,9 +313,11 @@ export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAncho
|
||||
return () => {
|
||||
if (!handler.isDestroyed()) handler.destroy()
|
||||
canvas.removeEventListener('contextmenu', suppressCtx)
|
||||
if (!viewer.isDestroyed()) {
|
||||
enableCam()
|
||||
viewer.scene.canvas.style.cursor = 'default'
|
||||
}
|
||||
}
|
||||
}, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── cleanup on unmount ─────────────────────────────────────────────────────
|
||||
|
||||
190
web/src/coaster/useTerrainCapture.ts
Normal file
190
web/src/coaster/useTerrainCapture.ts
Normal 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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user