add physics-based coaster editor

This commit is contained in:
munsel 2026-04-21 16:33:15 +02:00
parent d93412cd0d
commit b38b8be3e3
48 changed files with 4425 additions and 113 deletions

View File

@ -7,6 +7,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgeos-dev \
libproj-dev \
binutils \
libgl1 \
&& rm -rf /var/lib/apt/lists/*
# X11/OpenGL runtime libs required by OCP (OpenCASCADE Python bindings used by build123d)
RUN apt-get update && apt-get install -y --no-install-recommends \
libxrender1 \
libxi6 \
libxext6 \
libx11-6 \
&& rm -rf /var/lib/apt/lists/*
ENV GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so

View File

@ -72,10 +72,12 @@ class ChallengeDetailSerializer(serializers.ModelSerializer):
]
def get_region(self, obj):
return obj.region.geojson if obj.region else None
import json
return json.loads(obj.region.geojson) if obj.region else None
def get_region_centroid(self, obj):
return obj.region_centroid.geojson if obj.region_centroid else None
import json
return json.loads(obj.region_centroid.geojson) if obj.region_centroid else None
def get_participant_count(self, obj):
return obj.participants.count()

View File

@ -28,7 +28,14 @@ def _parse_bbox(bbox_str):
class ChallengeListCreateView(APIView):
def get(self, request):
qs = Challenge.objects.all()
qs = Challenge.objects.select_related("creator")
# ?mine=true — return the authenticated user's own challenges, all statuses
if request.query_params.get("mine") == "true":
qs = qs.filter(creator=request.user)
return Response(
ChallengeDetailSerializer(qs, many=True, context={"request": request}).data
)
status_filter = request.query_params.get("status", "active")
qs = qs.filter(status=status_filter)

View File

@ -0,0 +1 @@
default_app_config = 'apps.coaster.apps.CoasterConfig'

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoasterConfig(AppConfig):
name = 'apps.coaster'
default_auto_field = 'django.db.models.BigAutoField'

View File

@ -0,0 +1,97 @@
"""
GLB 3D model builder for roller coaster rails.
Builds a tube mesh along each rail path using pure numpy + trimesh (no extra
deps). build123d is installed for future complex solid geometry work.
"""
import logging
import numpy as np
logger = logging.getLogger(__name__)
def _tube_mesh(path: np.ndarray, radius: float, segments: int = 8):
"""
Create a trimesh.Trimesh tube swept along a 3-D polyline path.
Parameters
----------
path : (n, 3) float path centreline in metres
radius : float tube radius in metres
segments : int number of cross-section vertices
"""
import trimesh # type: ignore
n = len(path)
angles = np.linspace(0, 2 * np.pi, segments, endpoint=False)
circle_xy = np.column_stack([np.cos(angles), np.sin(angles)]) # (seg, 2)
rings = []
for i in range(n):
# Tangent vector
if i == 0:
t = path[1] - path[0]
elif i == n - 1:
t = path[-1] - path[-2]
else:
t = path[i + 1] - path[i - 1]
t = t / (np.linalg.norm(t) + 1e-12)
# Build an orthonormal frame (right, up2) perpendicular to t
world_up = np.array([0.0, 0.0, 1.0])
if abs(np.dot(t, world_up)) > 0.9:
world_up = np.array([1.0, 0.0, 0.0])
right = np.cross(t, world_up)
right /= np.linalg.norm(right) + 1e-12
up2 = np.cross(right, t)
# Place the circle ring at path[i]
ring = path[i] + (circle_xy[:, 0:1] * right + circle_xy[:, 1:2] * up2) * radius
rings.append(ring)
verts = np.array(rings).reshape(-1, 3) # (n * segments, 3)
# Stitch adjacent rings into quads (two triangles each)
faces = []
for i in range(n - 1):
for j in range(segments):
j1 = (j + 1) % segments
a = i * segments + j
b = i * segments + j1
c = (i + 1) * segments + j
d = (i + 1) * segments + j1
faces.append([a, b, d])
faces.append([a, d, c])
return trimesh.Trimesh(vertices=verts, faces=np.array(faces), process=False)
def build_glb(rail_1_pts: np.ndarray, rail_2_pts: np.ndarray, radius_m: float = 0.05) -> bytes:
"""
Build a GLB model containing two solid rail tubes.
Parameters
----------
rail_1_pts, rail_2_pts : np.ndarray shape (n, 3) metres, local ENU frame
radius_m : float tube radius in metres
Returns
-------
bytes GLB binary data
"""
import trimesh # type: ignore
logger.info("Building rail tubes (radius=%.3f m)…", radius_m)
mesh_1 = _tube_mesh(rail_1_pts, radius_m)
mesh_2 = _tube_mesh(rail_2_pts, radius_m)
# Steel-grey colour
for mesh in (mesh_1, mesh_2):
mesh.visual.face_colors = [180, 185, 192, 255]
scene = trimesh.Scene([mesh_1, mesh_2])
glb_bytes = scene.export(file_type="glb")
logger.info("GLB size: %d bytes", len(glb_bytes))
return glb_bytes

View File

@ -0,0 +1,36 @@
# Generated by Django 5.1.4 on 2026-04-21 02:49
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('challenges', '0002_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Coaster',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, default='', max_length=255)),
('anchors', models.JSONField(default=list)),
('acceleration_strips', models.JSONField(default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('challenge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coasters', to='challenges.challenge')),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coasters', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-updated_at'],
'unique_together': {('creator', 'challenge')},
},
),
]

View File

@ -0,0 +1,31 @@
import uuid
from django.db import models
from django.conf import settings
class Coaster(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
creator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='coasters',
)
challenge = models.ForeignKey(
'challenges.Challenge',
on_delete=models.CASCADE,
related_name='coasters',
)
name = models.CharField(max_length=255, blank=True, default='')
# Each anchor: {id, lon, lat, terrainAlt, heightOffset}
anchors = models.JSONField(default=list)
# Each strip: {id, startFrac, endFrac, accel_ms2}
acceleration_strips = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [('creator', 'challenge')]
ordering = ['-updated_at']
def __str__(self):
return f'{self.creator.username} / {self.challenge_id}'

View File

@ -0,0 +1,427 @@
"""
Physics-Based Roller Coaster Rail Generator
============================================
Refactored from the project root for backend use.
Removed: build123d, ocp_vscode, sample_wire(), __main__ block.
All core mathematics is pure numpy.
Public API
----------
generate_rails(points_m, **kwargs) -> (rail_1_pts, rail_2_pts)
Takes a (n, 3) numpy array of centreline points in metres (local frame)
and returns two (m, 3) numpy arrays of rail points in metres.
"""
import numpy as np
GRAVITY = 9.81 # m/s²
# ── Utilities ──────────────────────────────────────────────────────────────────
def normalize(v):
mag = np.linalg.norm(v)
if mag < 1e-12:
return np.zeros_like(v) if hasattr(v, "__len__") else 0
return v / mag
def arc_length(points):
n = len(points)
s = np.zeros(n)
for i in range(1, n):
s[i] = s[i - 1] + np.linalg.norm(points[i] - points[i - 1])
return s
def _fit_spline_and_sample(pts: np.ndarray, n: int):
"""
Fit a cubic B-spline through *pts* (n_ctrl × 3, in metres) and sample *n*
evenly-spaced points along it.
Returns (positions, arc_lengths, unit_tangents, curvatures, curvature_vectors)
where derivatives are evaluated **analytically** from the spline not via
finite differences on a piecewise-linear path. This avoids the curvature
spikes that occur at polyline vertices and are the primary source of jitter
in the binormal / rail computation.
The spline is clamped at both ends: the tangent direction at the first and
last sampled points matches the direction of the first and last input
segments respectively.
"""
from scipy.interpolate import make_interp_spline
s_raw = arc_length(pts)
if s_raw[-1] < 1e-9:
raise ValueError("Path has zero length")
# Normalise to [0, 1] and remove any duplicate parameter values.
u_raw = s_raw / s_raw[-1]
mask = np.concatenate([[True], np.diff(u_raw) > 1e-12])
pts_c = pts[mask]
u_c = u_raw[mask]
k_order = min(3, len(pts_c) - 1) # cubic when ≥ 4 pts, else lower
# Clamped boundary conditions: fix dr/du at both endpoints so the spline
# leaves / arrives in the direction of the first / last input segment.
if k_order == 3 and len(pts_c) >= 4:
tang_start = (pts_c[1] - pts_c[0]) / (u_c[1] - u_c[0])
tang_end = (pts_c[-1] - pts_c[-2]) / (u_c[-1] - u_c[-2])
bc_type = ([(1, tang_start)], [(1, tang_end)])
else:
bc_type = None
bsp = make_interp_spline(u_c, pts_c, k=k_order, bc_type=bc_type)
u_new = np.linspace(0, 1, n)
r0 = bsp(u_new) # positions, shape (n, 3)
r1 = bsp(u_new, 1) # dr/du, shape (n, 3)
r2 = bsp(u_new, 2) # d²r/du², shape (n, 3)
s_new = arc_length(r0)
# Unit tangent T = r1 / |r1|
r1_mag = np.linalg.norm(r1, axis=1, keepdims=True)
r1_mag = np.where(r1_mag < 1e-12, 1e-12, r1_mag)
T = r1 / r1_mag
# Curvature vector (w.r.t. arc-length parameter s):
# κ_vec = (r2 (r2 · T) T) / |r1|²
r2_along_T = np.sum(r2 * T, axis=1, keepdims=True)
k_vector = (r2 - r2_along_T * T) / (r1_mag ** 2)
k = np.linalg.norm(k_vector, axis=1)
return r0, s_new, T, k, k_vector
# ── Differential geometry ──────────────────────────────────────────────────────
def compute_geometry(points, s):
"""Return T, k, k_vector, dk along the path."""
n = len(points)
T = np.zeros_like(points)
for i in range(1, n - 1):
ds = s[i + 1] - s[i - 1]
if ds > 1e-12:
T[i] = (points[i + 1] - points[i - 1]) / ds
T[0], T[-1] = T[1], T[-2]
T = np.array([normalize(t) for t in T])
k_vector = np.zeros_like(points)
for i in range(1, n - 1):
ds1 = s[i] - s[i - 1]
ds2 = s[i + 1] - s[i]
if ds1 > 1e-12 and ds2 > 1e-12:
k_vector[i] = (
2 * ((points[i + 1] - points[i]) / ds2 - (points[i] - points[i - 1]) / ds1)
/ (ds1 + ds2)
)
k_vector[0], k_vector[-1] = k_vector[1], k_vector[-2]
k = np.linalg.norm(k_vector, axis=1)
dk = np.gradient(k, s)
return T, k, k_vector, dk
# ── Junction detection ─────────────────────────────────────────────────────────
def detect_junctions(k, dk, threshold_percentile=88.0, min_separation=50):
abs_dk = np.abs(dk)
d2k = np.abs(np.gradient(dk))
severity = abs_dk + 2 * d2k
threshold = np.percentile(severity, threshold_percentile)
junctions = []
for i in range(10, len(severity) - 10):
if severity[i] > threshold and severity[i] > severity[i - 1] and severity[i] > severity[i + 1]:
junctions.append((i, severity[i]))
if junctions:
junctions.sort(key=lambda x: x[1], reverse=True)
merged = [junctions[0][0]]
for j_idx, _ in junctions[1:]:
if all(abs(j_idx - m) > min_separation for m in merged):
merged.append(j_idx)
junctions = sorted(merged)
return junctions
# ── G2-continuous transitions ──────────────────────────────────────────────────
def quintic_hermite_blend(t, p0, v0, a0, p1, v1, a1):
t2, t3, t4, t5 = t**2, t**3, t**4, t**5
h0 = 1 - 10*t3 + 15*t4 - 6*t5
h1 = t - 6*t3 + 8*t4 - 3*t5
h2 = 0.5*t2 - 1.5*t3 + 1.5*t4 - 0.5*t5
h3 = 10*t3 - 15*t4 + 6*t5
h4 = -4*t3 + 7*t4 - 3*t5
h5 = 0.5*t3 - t4 + 0.5*t5
return h0*p0 + h1*v0 + h2*a0 + h3*p1 + h4*v1 + h5*a1
def create_g2_transition(p_start, T_start, k_vector_start, p_end, T_end, k_vector_end, n_points=100):
L = np.linalg.norm(p_end - p_start)
t = np.linspace(0, 1, n_points)
v0, v1 = T_start * L, T_end * L
a0, a1 = k_vector_start * L**2, k_vector_end * L**2
pts = np.zeros((n_points, 3))
for i, ti in enumerate(t):
pts[i] = quintic_hermite_blend(ti, p_start, v0, a0, p_end, v1, a1)
return pts
def replace_junctions_with_g2_transitions(points, s, junctions, window_length=0.025):
if not junctions:
return points
T, k, k_vector, dk = compute_geometry(points, s)
pts_new = points.copy()
windows = []
for jidx in junctions:
hw = window_length / 2
si = np.searchsorted(s, s[jidx] - hw)
ei = np.searchsorted(s, s[jidx] + hw)
si = max(5, si)
ei = min(len(points) - 5, ei)
if ei - si >= 10:
windows.append((jidx, si, ei))
replaced = []
for jidx, si, ei in windows:
if any(not (ei < rs or re < si) for rs, re in replaced):
continue
n_t = ei - si + 1
transition = create_g2_transition(
pts_new[si], T[si], k_vector[si],
pts_new[ei], T[ei], k_vector[ei],
n_points=n_t,
)
pts_new[si:ei + 1] = transition
replaced.append((si, ei))
return pts_new
# ── Physics simulation ─────────────────────────────────────────────────────────
def compute_velocity_profile(points, s, mass, v0, friction_coeff, g_scaled,
acceleration_strips=None):
n = len(points)
s_total = s[-1] if s[-1] > 0 else 1.0
v = np.zeros(n)
v[0] = v0
for i in range(1, n):
d = np.linalg.norm(points[i] - points[i - 1])
dh = points[i - 1][2] - points[i][2]
friction_work = friction_coeff * mass * g_scaled * d
strip_accel = 0.0
if acceleration_strips:
s_frac_i = s[i] / s_total
for strip in acceleration_strips:
if strip['start_frac'] <= s_frac_i <= strip['end_frac']:
strip_accel += strip['accel_ms2']
v2 = v[i - 1]**2 + 2 * g_scaled * dh - 2 * friction_work / mass + 2 * strip_accel * d
v[i] = np.sqrt(max(v2, 0))
return v
def compute_hanging_binormals(points, velocities, T, k, k_vector, g_scaled):
n = len(points)
gravity = np.array([0.0, 0.0, -g_scaled])
B = np.zeros((n, 3))
def _apparent(i):
T_i = normalize(T[i])
if k[i] < 1e-9:
f = gravity
else:
R = 1 / k[i]
N_in = normalize(k_vector[i])
f = gravity + (velocities[i]**2 / R) * (-N_in)
perp = f - np.dot(f, T_i) * T_i
return normalize(perp)
B[0] = _apparent(0)
if B[0][2] > 0:
B[0] = -B[0]
for i in range(1, n):
b = _apparent(i)
if np.dot(b, B[i - 1]) < 0:
b = -b
B[i] = b
return B
def smooth_binormals(B, tangents, iterations=5):
B_s = B.copy()
n = len(B)
for _ in range(iterations):
B_new = B_s.copy()
for i in range(2, n - 2):
avg = (B_s[i - 1] + 2 * B_s[i] + B_s[i + 1]) / 4
T_i = normalize(tangents[i])
avg_perp = avg - np.dot(avg, T_i) * T_i
B_new[i] = normalize(avg_perp)
B_s = B_new
return B_s
# ── Diagnostics ────────────────────────────────────────────────────────────────
def compute_diagnostics(points, velocities, k, B) -> dict:
"""Return a dict of ride-quality metrics (no side effects)."""
g_forces = []
for i in range(len(points)):
if k[i] > 1e-6:
R = 1 / k[i]
if R > 0.002:
g_forces.append(velocities[i]**2 / R / GRAVITY)
stall_idx = np.where(velocities < 0.01)[0]
binormal_changes = np.array([np.linalg.norm(B[i] - B[i - 1]) for i in range(1, len(B))])
down_pct = 100 * np.sum(B[:, 2] < 0) / len(B)
return {
"height_range_m": [float(np.min(points[:, 2])), float(np.max(points[:, 2]))],
"velocity_range_ms": [float(np.min(velocities)), float(np.max(velocities))],
"g_force_range": [float(min(g_forces)), float(max(g_forces))] if g_forces else None,
"stall_at_pct": float(100 * stall_idx[0] / len(points)) if len(stall_idx) else None,
"binormal_variation_max": float(np.max(binormal_changes)),
"binormal_downward_pct": float(down_pct),
}
# ── Profile arrays ────────────────────────────────────────────────────────────
def build_profile_arrays(s, velocities, k, downsample_factor) -> dict:
"""Return downsampled per-point arrays for frontend charting."""
dvds = np.gradient(velocities, s)
accel = velocities * dvds # dv/dt = v * dv/ds (m/s²)
gf = (velocities ** 2 * k) / GRAVITY
idx = np.arange(0, len(s), downsample_factor)
s_ds = s[idx]
s_frac = (s_ds / s_ds[-1]).tolist() if s_ds[-1] > 0 else s_ds.tolist()
# Total arc length and ride duration (integrate dt = ds/v).
# Floor at 1 m/s so that isolated transient-zero points (coaster just
# barely crests a hill, sqrt(max(v2,0)) == 0 for one step) each
# contribute at most ~2 s instead of ~1400 s to the integral.
total_length_m = float(s[-1])
safe_v = np.maximum(velocities, 1.0)
total_duration_s = float(np.trapz(1.0 / safe_v, s))
return {
's_frac': s_frac,
'velocity_ms': velocities[idx].tolist(),
'accel_ms2': accel[idx].tolist(),
'g_force': gf[idx].tolist(),
'total_length_m': total_length_m,
'total_duration_s': total_duration_s,
}
# ── Public API ─────────────────────────────────────────────────────────────────
def generate_rails(
points_m: np.ndarray,
rail_spacing: float = 1.5,
mass: float = 1000.0,
initial_velocity: float = 1.0,
friction_coeff: float = 0.02,
junction_window_length: float = 5.0,
junction_threshold: float = 88.0,
binormal_smooth_iterations: int = 5,
downsample_factor: int = 10,
internal_steps: int = 5000,
acceleration_strips: list | None = None,
) -> tuple:
"""
Generate physics-based roller coaster rails from a centreline path.
Parameters
----------
points_m : np.ndarray shape (n, 3)
Centreline waypoints in **metres**, local coordinate frame.
rail_spacing : float
Distance between the two rails in metres (default 1.5 standard gauge).
mass : float
Mass of the coaster car in kg, used for friction losses.
initial_velocity : float
Speed at the start of the track in m/s.
friction_coeff : float
Rolling resistance coefficient (steel-on-steel 0.02).
junction_window_length : float
Arc-length of G2 transition windows in metres.
junction_threshold : float
Percentile threshold for curvature-discontinuity detection (8595).
binormal_smooth_iterations : int
Number of binormal smoothing passes.
downsample_factor : int
Output rail points = internal_steps / downsample_factor.
internal_steps : int
Number of points used internally for the simulation.
Returns
-------
rail_1_pts, rail_2_pts : np.ndarray shape (m, 3)
Left and right rail positions in metres (same local frame as input).
diagnostics : dict
Ride-quality metrics.
"""
if len(points_m) < 2:
raise ValueError("Need at least 2 path points")
# Scale resolution with path length: 1 000 steps per km (≈ 1 per metre).
# The caller-supplied internal_steps acts as a lower bound.
approx_length = float(arc_length(points_m)[-1])
internal_steps = max(internal_steps, int(approx_length))
# Fit a cubic B-spline through the control points and evaluate positions
# plus analytical tangents/curvatures at internal_steps evenly-spaced samples.
# This eliminates the curvature spikes that arise from computing finite
# differences on a piecewise-linear polyline approximation.
pts, s, T, k, k_vector = _fit_spline_and_sample(points_m, internal_steps)
dk = np.gradient(k, s)
# Junction detection + G2 smoothing (handles any remaining curvature
# discontinuities, e.g. from anchors placed very close together).
avg_spacing = s[-1] / len(pts)
min_sep = int((junction_window_length * 1.2) / avg_spacing)
junctions = detect_junctions(k, dk, threshold_percentile=junction_threshold, min_separation=min_sep)
if junctions:
pts = replace_junctions_with_g2_transitions(pts, s, junctions, window_length=junction_window_length)
s = arc_length(pts)
# Recompute geometry via finite differences only after the path has been
# modified; for the common (no-junction) path the spline values are used.
T, k, k_vector, dk = compute_geometry(pts, s)
# Physics
velocities = compute_velocity_profile(pts, s, mass, initial_velocity, friction_coeff, GRAVITY,
acceleration_strips=acceleration_strips)
B = compute_hanging_binormals(pts, velocities, T, k, k_vector, GRAVITY)
if binormal_smooth_iterations > 0:
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)
# Rail positions: offset perpendicular to tangent within the binormal plane
crosses = np.cross(B, T) # (n, 3)
rail_1 = pts + rail_spacing * crosses
rail_2 = pts - rail_spacing * crosses
# Downsample
rail_1 = rail_1[::downsample_factor]
rail_2 = rail_2[::downsample_factor]
return rail_1, rail_2, diag, profile

View File

@ -0,0 +1,14 @@
from rest_framework import serializers
from .models import Coaster
class CoasterSerializer(serializers.ModelSerializer):
creator_username = serializers.ReadOnlyField(source='creator.username')
class Meta:
model = Coaster
fields = [
'id', 'creator_username', 'challenge', 'name',
'anchors', 'acceleration_strips', 'created_at', 'updated_at',
]
read_only_fields = ['id', 'creator_username', 'created_at', 'updated_at']

View File

@ -0,0 +1,8 @@
from django.urls import path
from .views import CoasterSimulateView, CoasterListCreateView, CoasterDeleteView
urlpatterns = [
path("simulate/", CoasterSimulateView.as_view()),
path("challenges/<uuid:challenge_id>/coasters/", CoasterListCreateView.as_view()),
path("<uuid:pk>/", CoasterDeleteView.as_view()),
]

View File

@ -0,0 +1,290 @@
"""
POST /api/v1/coaster/simulate/
Accepts a list of geographic coordinates, runs the physics-based rail
generator, optionally builds a GLB model, and returns the rail point arrays
plus a presigned S3 URL for the model.
"""
import logging
import math
import uuid
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
# numpy and physics deps are imported lazily inside the view so the server
# can start (and serve all other endpoints) even before the container has been
# rebuilt with the new requirements.
logger = logging.getLogger(__name__)
# ── Coordinate helpers ─────────────────────────────────────────────────────────
_R_EARTH = 6_371_000.0 # metres
def _to_enu(coords: list) -> tuple:
"""
Convert [[lon, lat, alt], ] to a local ENU numpy array (metres).
Uses a flat-Earth approximation; accurate to < 0.01 % for areas < 10 km.
Returns (points_m, origin) where origin = (lon0, lat0, alt0).
"""
lon0, lat0, alt0 = coords[0]
cos_lat = math.cos(math.radians(lat0))
pts = []
for lon, lat, alt in coords:
x = (lon - lon0) * math.radians(1) * cos_lat * _R_EARTH # East
y = (lat - lat0) * math.radians(1) * _R_EARTH # North
z = alt - alt0 # Up
pts.append([x, y, z])
import numpy as np # lazy
return np.array(pts), (lon0, lat0, alt0)
def _from_enu(pts_m, origin: tuple) -> list:
"""Convert local ENU numpy array back to [[lon, lat, alt], …]."""
lon0, lat0, alt0 = origin
cos_lat = math.cos(math.radians(lat0))
result = []
for x, y, z in pts_m:
lon = lon0 + x / (cos_lat * _R_EARTH) * math.degrees(1)
lat = lat0 + y / _R_EARTH * math.degrees(1)
alt = alt0 + z
result.append([lon, lat, alt])
return result
# ── S3 upload ──────────────────────────────────────────────────────────────────
def _upload_glb(glb_bytes: bytes) -> str | None:
"""
Upload GLB bytes to S3 and return a presigned GET URL (1 h).
Returns None in development (no S3 configured).
"""
try:
from apps.utils.storage import _is_s3_storage, _get_client
from django.conf import settings
if not _is_s3_storage():
logger.info("S3 not configured — skipping GLB upload")
return None
key = f"coaster/models/{uuid.uuid4()}.glb"
client = _get_client()
client.put_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
Key=key,
Body=glb_bytes,
ContentType="model/gltf-binary",
)
return client.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": key},
ExpiresIn=3600,
)
except Exception:
logger.exception("GLB upload failed")
return None
# ── Allowed simulation params ──────────────────────────────────────────────────
_PARAM_SCHEMA = {
"rail_spacing": (float, 0.5, 10.0, 1.5),
"mass": (float, 1.0, 50000, 1000.0),
"initial_velocity": (float, 0.0, 100.0, 1.0),
"friction_coeff": (float, 0.0, 1.0, 0.02),
"junction_window_length": (float, 0.5, 50.0, 5.0),
"junction_threshold": (float, 50.0, 99.0, 88.0),
"binormal_smooth_iterations": (int, 0, 50, 5),
"downsample_factor": (int, 1, 100, 10),
"internal_steps": (int, 500, 20000, 5000),
}
def _parse_strips(raw) -> list:
"""
Validate and sanitize acceleration strip list from request body.
Each strip: { start_frac: float, end_frac: float, accel_ms2: float }
"""
if not isinstance(raw, list):
return []
result = []
for item in raw:
if not isinstance(item, dict):
continue
try:
sf = float(item['start_frac'])
ef = float(item['end_frac'])
ac = float(item['accel_ms2'])
except (KeyError, TypeError, ValueError):
continue
sf = max(0.0, min(1.0, sf))
ef = max(0.0, min(1.0, ef))
ac = max(-50.0, min(50.0, ac))
if sf >= ef:
continue
result.append({'start_frac': sf, 'end_frac': ef, 'accel_ms2': ac})
return result
def _parse_params(raw: dict) -> dict:
out = {}
for name, (typ, lo, hi, default) in _PARAM_SCHEMA.items():
val = raw.get(name, default)
try:
val = typ(val)
except (TypeError, ValueError):
val = default
out[name] = max(lo, min(hi, val))
return out
# ── View ───────────────────────────────────────────────────────────────────────
class CoasterSimulateView(APIView):
"""
POST /api/v1/coaster/simulate/
Request body:
{
"path": [[lon, lat, alt_m], ], // 2 points
"params": { } // optional overrides
}
Response 200:
{
"rail_1": [[lon, lat, alt], ],
"rail_2": [[lon, lat, alt], ],
"origin": [lon0, lat0, alt0],
"model_url": "<presigned URL or null>",
"diagnostics": { }
}
"""
def post(self, request):
path = request.data.get("path")
if not isinstance(path, list) or len(path) < 2:
return Response(
{"error": "invalid_path", "detail": "path must be a list of ≥ 2 [lon, lat, alt] coordinates."},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate each coordinate
try:
path = [[float(v) for v in pt] for pt in path]
for pt in path:
if len(pt) != 3:
raise ValueError
except (TypeError, ValueError):
return Response(
{"error": "invalid_path", "detail": "Each point must be [lon, lat, alt_m] (three numbers)."},
status=status.HTTP_400_BAD_REQUEST,
)
params = _parse_params(request.data.get("params") or {})
strips = _parse_strips(request.data.get("acceleration_strips") or [])
# Convert to local ENU
pts_enu, origin = _to_enu(path)
# Run physics
try:
from .physics import generate_rails
rail_1, rail_2, diag, profile = generate_rails(
pts_enu, **params, acceleration_strips=strips
)
except Exception as exc:
logger.exception("Physics simulation failed")
return Response(
{"error": "simulation_failed", "detail": str(exc)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Build GLB (build123d + trimesh) — gracefully skip on failure
model_url = None
try:
from .mesh import build_glb
glb_bytes = build_glb(rail_1, rail_2)
model_url = _upload_glb(glb_bytes)
except Exception:
logger.exception("GLB generation failed — returning coordinates only")
return Response({
"rail_1": _from_enu(rail_1, origin),
"rail_2": _from_enu(rail_2, origin),
"origin": list(origin),
"model_url": model_url,
"diagnostics": diag,
"profile": profile,
})
# ── Coaster persistence ────────────────────────────────────────────────────────
from rest_framework.permissions import IsAuthenticated # noqa: E402
class CoasterListCreateView(APIView):
"""
GET /api/v1/coaster/challenges/{challenge_id}/coasters/ list all coasters
POST /api/v1/coaster/challenges/{challenge_id}/coasters/ save (upsert)
"""
permission_classes = [IsAuthenticated]
def get(self, request, challenge_id):
from .models import Coaster
from .serializers import CoasterSerializer
coasters = (
Coaster.objects
.filter(challenge_id=challenge_id)
.select_related('creator')
.order_by('-updated_at')
)
return Response(CoasterSerializer(coasters, many=True).data)
def post(self, request, challenge_id):
from .models import Coaster
from .serializers import CoasterSerializer
from apps.challenges.models import Challenge
try:
challenge = Challenge.objects.get(id=challenge_id)
except Challenge.DoesNotExist:
return Response(
{"error": "challenge_not_found"},
status=status.HTTP_404_NOT_FOUND,
)
coaster, created = Coaster.objects.update_or_create(
creator=request.user,
challenge=challenge,
defaults={
"name": request.data.get("name", ""),
"anchors": request.data.get("anchors", []),
"acceleration_strips": request.data.get("acceleration_strips", []),
},
)
return Response(
CoasterSerializer(coaster).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
class CoasterDeleteView(APIView):
"""DELETE /api/v1/coaster/{pk}/"""
permission_classes = [IsAuthenticated]
def delete(self, request, pk):
from .models import Coaster
try:
coaster = Coaster.objects.get(id=pk)
except Coaster.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if coaster.creator != request.user:
return Response(status=status.HTTP_403_FORBIDDEN)
coaster.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -5,4 +5,5 @@ urlpatterns = [
path("splats/", include("apps.splats.urls")),
path("challenges/", include("apps.challenges.urls")),
path("jobs/", include("apps.jobs.urls")),
path("coaster/", include("apps.coaster.urls")),
]

View File

@ -26,6 +26,7 @@ INSTALLED_APPS = [
"apps.splats",
"apps.challenges",
"apps.jobs",
"apps.coaster",
]
MIDDLEWARE = [
@ -77,13 +78,16 @@ AUTHENTICATION_BACKENDS = [
]
# Authentik OIDC
_OIDC_BASE = os.environ.get("OIDC_OP_BASE_URL", "")
# OIDC_OP_BASE_URL is the per-application URL, e.g. https://auth.example.com/application/o/rcnn
# Global endpoints (token, userinfo) live one level up at /application/o/
_OIDC_APP_BASE = os.environ.get("OIDC_OP_BASE_URL", "")
_OIDC_GLOBAL_BASE = _OIDC_APP_BASE.rsplit("/", 1)[0] # strips the app slug
OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "")
OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "")
OIDC_OP_AUTHORIZATION_ENDPOINT = f"{_OIDC_BASE}/authorize/"
OIDC_OP_TOKEN_ENDPOINT = f"{_OIDC_BASE}/token/"
OIDC_OP_USER_ENDPOINT = f"{_OIDC_BASE}/userinfo/"
OIDC_OP_JWKS_ENDPOINT = f"{_OIDC_BASE}/jwks/"
OIDC_OP_AUTHORIZATION_ENDPOINT = f"{_OIDC_GLOBAL_BASE}/authorize/"
OIDC_OP_TOKEN_ENDPOINT = f"{_OIDC_GLOBAL_BASE}/token/"
OIDC_OP_USER_ENDPOINT = f"{_OIDC_GLOBAL_BASE}/userinfo/"
OIDC_OP_JWKS_ENDPOINT = f"{_OIDC_APP_BASE}/jwks/"
OIDC_RP_SIGN_ALGO = "RS256"
OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_ID_TOKEN = True

View File

@ -19,3 +19,12 @@ MEDIA_ROOT = BASE_DIR / "media"
# Log Celery tasks to console
CELERY_TASK_ALWAYS_EAGER = False # set True to run tasks synchronously for debugging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"mozilla_django_oidc": {"handlers": ["console"], "level": "DEBUG"},
},
}

View File

@ -1,4 +1,8 @@
Django==5.1.4
numpy==2.2.5
scipy==1.15.3
build123d==0.9.0
trimesh==4.6.10
djangorestframework==3.15.2
djangorestframework-gis==1.1
django-cors-headers==4.6.0

417
web/package-lock.json generated
View File

@ -15,6 +15,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"recharts": "^3.8.1",
"three": "^0.171.0",
"zustand": "^5.0.3"
},
@ -60,7 +61,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -889,6 +889,42 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1270,6 +1306,18 @@
"pnpm": ">=8"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@ -1322,6 +1370,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1351,7 +1462,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1395,6 +1505,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@ -1519,7 +1635,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -1573,7 +1688,6 @@
"resolved": "https://registry.npmjs.org/cesium/-/cesium-1.140.0.tgz",
"integrity": "sha512-3RvW0rvZWuXiS6regtNE5u9vt0uXohgpsRBIo6Qc922IIIamkitYiEdr4fg+u4qX4EoK9xS3BosCza7iPOExEQ==",
"license": "Apache-2.0",
"peer": true,
"workspaces": [
"packages/engine",
"packages/widgets",
@ -1587,6 +1701,15 @@
"node": ">=20.19.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1632,6 +1755,127 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1650,6 +1894,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1784,6 +2034,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@ -1857,6 +2117,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@ -2082,6 +2348,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2089,6 +2365,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@ -2455,7 +2740,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -2465,7 +2749,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -2473,6 +2756,36 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -2521,6 +2834,57 @@
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@ -2685,8 +3049,13 @@
"version": "0.171.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz",
"integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
@ -2785,13 +3154,43 @@
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View File

@ -16,6 +16,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"recharts": "^3.8.1",
"three": "^0.171.0",
"zustand": "^5.0.3"
},

View File

@ -8,6 +8,7 @@ import { ChallengeLayer } from './challenges/ChallengeLayer'
import { ChallengePanel } from './challenges/ChallengePanel'
import { ChallengeCreator } from './challenges/ChallengeCreator'
import { MapOverlay } from './ui/MapOverlay'
import { CoasterEditorPage } from './coaster/CoasterEditorPage'
function MapPage() {
return (
@ -32,6 +33,7 @@ export default function App() {
<Routes>
<Route path="/auth/callback" element={<CallbackPage />} />
<Route path="/" element={<MapPage />} />
<Route path="/challenges/:id/coaster" element={<CoasterEditorPage />} />
</Routes>
</AuthProvider>
</BrowserRouter>

View File

@ -22,6 +22,11 @@ export async function fetchChallenges(
return data
}
export async function fetchMyChallenges(): Promise<ChallengeDetail[]> {
const { data } = await apiClient.get('/challenges/', { params: { mine: 'true' } })
return data
}
export async function fetchChallengeDetail(id: string): Promise<ChallengeDetail> {
const { data } = await apiClient.get(`/challenges/${id}/`)
return data

39
web/src/api/coaster.ts Normal file
View File

@ -0,0 +1,39 @@
import { apiClient } from './client'
import type {
CoasterSimulateBody,
CoasterSimulationResult,
SavedCoaster,
StoredAnchor,
AccelerationStrip,
} from '../types/api'
export async function simulateCoaster(body: CoasterSimulateBody): Promise<CoasterSimulationResult> {
const res = await apiClient.post<CoasterSimulationResult>('/coaster/simulate/', body)
return res.data
}
export async function listCoasters(challengeId: string): Promise<SavedCoaster[]> {
const res = await apiClient.get<SavedCoaster[]>(
`/coaster/challenges/${challengeId}/coasters/`,
)
return res.data
}
export async function saveCoaster(
challengeId: string,
body: {
name?: string
anchors: StoredAnchor[]
acceleration_strips: Array<Pick<AccelerationStrip, 'id' | 'startFrac' | 'endFrac' | 'accel_ms2'>>
},
): Promise<SavedCoaster> {
const res = await apiClient.post<SavedCoaster>(
`/coaster/challenges/${challengeId}/coasters/`,
body,
)
return res.data
}
export async function deleteCoaster(id: string): Promise<void> {
await apiClient.delete(`/coaster/${id}/`)
}

View File

@ -16,9 +16,7 @@ export function CallbackPage() {
navigate(state?.returnTo ?? '/', { replace: true })
})
.catch(() => {
// If the callback fails (e.g. page refreshed on the callback URL),
// kick off a fresh login instead of showing a blank screen.
userManager.signinRedirect()
navigate('/', { replace: true })
})
}, []) // eslint-disable-line react-hooks/exhaustive-deps

View File

@ -4,9 +4,8 @@ export const userManager = new UserManager({
authority: import.meta.env.VITE_OIDC_AUTHORITY,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
silent_redirect_uri: `${window.location.origin}/auth/silent-callback.html`,
scope: 'openid profile email',
response_type: 'code',
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
automaticSilentRenew: false,
})

View File

@ -38,9 +38,12 @@ export function CesiumViewer({ children }: Props) {
})
.catch(() => {/* non-fatal: fall back to ellipsoid */})
// Hide Cesium's own credit container — we'll add our own if needed
const creditContainer = v.cesiumWidget.creditContainer as HTMLElement
creditContainer.style.display = 'none'
// Async: switch base imagery to Bing Aerial with Labels
Cesium.createWorldImageryAsync({ style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS })
.then((ip) => {
if (!v.isDestroyed()) v.imageryLayers.get(0).imageryProvider = ip
})
.catch(() => {/* non-fatal: keep default imagery */})
setViewer(v)

View File

@ -1,12 +1,16 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
pointer-events: none;
}
.modal {
pointer-events: auto;
}
.modal {

View File

@ -121,6 +121,7 @@ export function ChallengeLayer() {
// Cleanup on unmount
useEffect(() => {
return () => {
if (viewer.isDestroyed()) return
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
entityMapRef.current.clear()
if (regionEntityRef.current) viewer.entities.remove(regionEntityRef.current)

View File

@ -14,6 +14,31 @@
.meta dt { color: rgba(255,255,255,0.5); }
.meta dd { margin: 0; }
.centerBtn {
padding: 8px 16px;
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.8);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
text-align: left;
}
.centerBtn:hover { background: rgba(255,255,255,0.13); color: #fff; }
.coasterBtn {
padding: 8px 16px;
background: rgba(245,158,11,0.1);
color: #f59e0b;
border: 1px solid rgba(245,158,11,0.35);
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-align: left;
}
.coasterBtn:hover { background: rgba(245,158,11,0.18); border-color: rgba(245,158,11,0.55); }
.participateBtn {
padding: 10px 20px;
background: #f59e0b;

View File

@ -1,11 +1,16 @@
import { useEffect, useState } from 'react'
import * as Cesium from 'cesium'
import { useNavigate } from 'react-router-dom'
import { Panel } from '../ui/Panel'
import { useCesiumViewer } from '../cesium/cesiumContext'
import { useChallengeStore } from '../store/challengeStore'
import { fetchChallengeDetail, participateInChallenge } from '../api/challenges'
import type { ChallengeDetail } from '../types/api'
import styles from './ChallengePanel.module.css'
export function ChallengePanel() {
const viewer = useCesiumViewer()
const navigate = useNavigate()
const { selectedChallengeId, setSelectedChallengeId } = useChallengeStore()
const [detail, setDetail] = useState<ChallengeDetail | null>(null)
const [participating, setParticipating] = useState(false)
@ -23,6 +28,15 @@ export function ChallengePanel() {
.catch(console.error)
}, [selectedChallengeId])
function handleCenterMap() {
if (!detail?.region_centroid) return
const [lon, lat] = detail.region_centroid.coordinates
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, 2000),
duration: 1.5,
})
}
async function handleParticipate() {
if (!selectedChallengeId) return
await participateInChallenge(selectedChallengeId)
@ -59,6 +73,17 @@ export function ChallengePanel() {
)}
</dl>
<button className={styles.centerBtn} onClick={handleCenterMap}>
Center map
</button>
<button
className={styles.coasterBtn}
onClick={() => navigate(`/challenges/${selectedChallengeId}/coaster`)}
>
Plan coaster route
</button>
{detail.status === 'active' && !participating && (
<button className={styles.participateBtn} onClick={handleParticipate}>
Accept challenge

View File

@ -0,0 +1,103 @@
.panel {
position: fixed;
top: 16px;
left: 140px;
z-index: 20;
width: 300px;
max-height: 480px;
display: flex;
flex-direction: column;
background: rgba(15, 15, 20, 0.92);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
color: #fff;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.close {
background: none;
border: none;
color: rgba(255,255,255,0.5);
cursor: pointer;
font-size: 16px;
padding: 0;
line-height: 1;
}
.close:hover { color: #fff; }
.list {
list-style: none;
margin: 0;
padding: 6px 0;
overflow-y: auto;
flex: 1;
}
.item {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.item:last-child { border-bottom: none; }
.item:hover { background: rgba(255,255,255,0.05); }
.itemTitle {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemMeta {
display: flex;
align-items: center;
gap: 10px;
}
.badge {
font-size: 11px;
padding: 2px 7px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.active { background: rgba(34,197,94,0.2); color: #4ade80; }
.closed { background: rgba(239,68,68,0.2); color: #f87171; }
.count {
font-size: 12px;
color: rgba(255,255,255,0.45);
}
.state {
padding: 16px;
margin: 0;
color: rgba(255,255,255,0.45);
font-size: 14px;
}
.error {
padding: 16px;
margin: 0;
color: #f87171;
font-size: 14px;
}

View File

@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import { fetchMyChallenges } from '../api/challenges'
import { useChallengeStore } from '../store/challengeStore'
import type { ChallengeDetail } from '../types/api'
import styles from './MyChallengesPanel.module.css'
interface Props {
onClose: () => void
}
export function MyChallengesPanel({ onClose }: Props) {
const { setSelectedChallengeId } = useChallengeStore()
const [challenges, setChallenges] = useState<ChallengeDetail[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetchMyChallenges()
.then(setChallenges)
.catch(() => setError('Failed to load challenges.'))
.finally(() => setLoading(false))
}, [])
function handleSelect(id: string) {
setSelectedChallengeId(id)
onClose()
}
return (
<div className={styles.panel}>
<div className={styles.header}>
<h3 className={styles.title}>My Challenges</h3>
<button className={styles.close} onClick={onClose}></button>
</div>
{loading && <p className={styles.state}>Loading</p>}
{error && <p className={styles.error}>{error}</p>}
{!loading && !error && challenges.length === 0 && (
<p className={styles.state}>No challenges yet.</p>
)}
<ul className={styles.list}>
{challenges.map((c) => (
<li key={c.id} className={styles.item} onClick={() => handleSelect(c.id)}>
<div className={styles.itemTitle}>{c.title}</div>
<div className={styles.itemMeta}>
<span className={`${styles.badge} ${styles[c.status]}`}>{c.status}</span>
<span className={styles.count}>{c.submission_count} submission{c.submission_count !== 1 ? 's' : ''}</span>
</div>
</li>
))}
</ul>
</div>
)
}

View File

@ -3,87 +3,77 @@ import * as Cesium from 'cesium'
import { useCesiumViewer } from '../cesium/cesiumContext'
import { useChallengeStore } from '../store/challengeStore'
function vertsToGeoJson(verts: Cesium.Cartesian3[]): GeoJSON.Polygon {
const coords: [number, number][] = verts.map((v) => {
const c = Cesium.Cartographic.fromCartesian(v)
return [Cesium.Math.toDegrees(c.longitude), Cesium.Math.toDegrees(c.latitude)]
})
return { type: 'Polygon', coordinates: [[...coords, coords[0]]] }
}
function geoJsonToVerts(polygon: GeoJSON.Polygon): Cesium.Cartesian3[] {
const ring = polygon.coordinates[0]
// Drop the closing duplicate point
return ring.slice(0, -1).map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
}
function pickGlobe(
viewer: Cesium.Viewer,
windowPos: Cesium.Cartesian2,
): Cesium.Cartesian3 | null {
const ray = viewer.camera.getPickRay(windowPos)
if (!ray) return null
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
}
/**
* When drawingMode is true, intercepts Cesium mouse events to let the user
* draw a polygon by clicking vertices on the globe.
* Manages two phases of polygon interaction:
*
* LEFT_CLICK add vertex
* RIGHT_CLICK close polygon and write GeoJSON Polygon to challengeStore
* Drawing (drawingMode=true)
* LEFT_CLICK place vertex
* MOUSE_MOVE rubber-band line from last vertex to cursor
* RIGHT_CLICK close polygon (3 verts), enter edit phase
*
* Editing (drawingMode=false, draftPolygon set)
* Drag vertex handles to reposition them.
* Changes are written back to the store on mouse-up so the submit
* form always reads the latest geometry.
*/
export function usePolygonDraw() {
const viewer = useCesiumViewer()
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore()
const verticesRef = useRef<Cesium.Cartesian3[]>([])
const previewEntityRef = useRef<Cesium.Entity | null>(null)
const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } =
useChallengeStore()
// Persists vertex positions across edit-phase effect re-runs that are
// triggered by setDraftPolygon being called after each drag.
const editVertsRef = useRef<Cesium.Cartesian3[]>([])
// ── Drawing phase ──────────────────────────────────────────────────────────
useEffect(() => {
if (!drawingMode) {
cleanup()
return
}
if (!drawingMode) return
verticesRef.current = []
const verts: Cesium.Cartesian3[] = []
const vertPointEntities: Cesium.Entity[] = []
let outlineEntity: Cesium.Entity | null = null
let rubberBandEntity: Cesium.Entity | null = null
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
const canvas = viewer.scene.canvas
handler.setInputAction((event: { position: Cesium.Cartesian2 }) => {
const ray = viewer.camera.getPickRay(event.position)
if (!ray) return
const intersection = viewer.scene.globe.pick(ray, viewer.scene)
if (!intersection) return
// Prevent the browser context menu so right-click can close the polygon.
const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
canvas.addEventListener('contextmenu', suppressContextMenu)
verticesRef.current.push(intersection.clone())
updatePreview()
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
handler.setInputAction(() => {
const verts = verticesRef.current
if (verts.length < 3) return
// Convert Cartesian3 vertices to [lon, lat] degree pairs
const coords: [number, number][] = verts.map((v) => {
const carto = Cesium.Cartographic.fromCartesian(v)
return [
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
]
})
// Close the ring
coords.push(coords[0])
const polygon: GeoJSON.Polygon = {
type: 'Polygon',
coordinates: [coords],
function refreshOutline() {
if (outlineEntity) {
viewer.entities.remove(outlineEntity)
outlineEntity = null
}
cleanup()
setDraftPolygon(polygon)
setDrawingMode(false)
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
return () => {
handler.destroy()
}
function cleanup() {
verticesRef.current = []
if (previewEntityRef.current) {
viewer.entities.remove(previewEntityRef.current)
previewEntityRef.current = null
}
}
function updatePreview() {
if (previewEntityRef.current) {
viewer.entities.remove(previewEntityRef.current)
}
const verts = verticesRef.current
if (verts.length < 2) return
previewEntityRef.current = viewer.entities.add({
outlineEntity = viewer.entities.add({
polyline: {
positions: [...verts, verts[0]], // close the preview ring
positions: [...verts, verts[0]],
width: 2,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.YELLOW.withAlpha(0.9),
@ -92,5 +82,199 @@ export function usePolygonDraw() {
},
})
}
function refreshRubberBand(mousePos: Cesium.Cartesian3) {
if (rubberBandEntity) {
viewer.entities.remove(rubberBandEntity)
rubberBandEntity = null
}
if (verts.length === 0) return
rubberBandEntity = viewer.entities.add({
polyline: {
positions: [verts[verts.length - 1], mousePos],
width: 1.5,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.WHITE.withAlpha(0.5),
),
clampToGround: true,
},
})
}
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
const pos = pickGlobe(viewer, e.endPosition)
if (pos) refreshRubberBand(pos)
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
const pos = pickGlobe(viewer, e.position)
if (!pos) return
verts.push(pos.clone())
vertPointEntities.push(
viewer.entities.add({
position: pos,
point: {
pixelSize: 8,
color: Cesium.Color.YELLOW,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}),
)
refreshOutline()
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
handler.setInputAction(() => {
if (verts.length < 3) return
const polygon = vertsToGeoJson(verts)
cleanup()
setDraftPolygon(polygon)
setDrawingMode(false)
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
function cleanup() {
if (viewer.isDestroyed()) return
vertPointEntities.forEach((e) => viewer.entities.remove(e))
vertPointEntities.length = 0
if (outlineEntity) {
viewer.entities.remove(outlineEntity)
outlineEntity = null
}
if (rubberBandEntity) {
viewer.entities.remove(rubberBandEntity)
rubberBandEntity = null
}
}
return () => {
handler.destroy()
canvas.removeEventListener('contextmenu', suppressContextMenu)
cleanup()
}
}, [drawingMode, viewer, setDrawingMode, setDraftPolygon])
// ── Edit phase ─────────────────────────────────────────────────────────────
useEffect(() => {
if (drawingMode || !draftPolygon) {
editVertsRef.current = []
return
}
// Only initialise from the store on the first entry into edit mode.
// Subsequent runs (triggered by setDraftPolygon after each drag) reuse
// the already-mutated ref so vertex positions are not reset.
if (editVertsRef.current.length === 0) {
editVertsRef.current = geoJsonToVerts(draftPolygon)
}
const verts = editVertsRef.current
let draggingIndex = -1
const entities: Cesium.Entity[] = []
const canvas = viewer.scene.canvas
const suppressContextMenu = (e: MouseEvent) => e.preventDefault()
canvas.addEventListener('contextmenu', suppressContextMenu)
// Filled polygon
entities.push(
viewer.entities.add({
polygon: {
hierarchy: new Cesium.CallbackProperty(
() => new Cesium.PolygonHierarchy(verts),
false,
),
material: Cesium.Color.YELLOW.withAlpha(0.15),
},
}),
)
// Outline
entities.push(
viewer.entities.add({
polyline: {
positions: new Cesium.CallbackProperty(
() => [...verts, verts[0]],
false,
),
width: 2,
material: new Cesium.ColorMaterialProperty(
Cesium.Color.YELLOW.withAlpha(0.9),
),
clampToGround: true,
},
}),
)
// Vertex handles — one point entity per vertex, driven by CallbackProperty
// so they track the mutable verts array without entity recreation.
const vertEntities: Cesium.Entity[] = verts.map((_, i) => {
const e = viewer.entities.add({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
position: new Cesium.CallbackProperty(() => verts[i], false) as any,
point: {
pixelSize: 10,
color: Cesium.Color.WHITE,
outlineColor: Cesium.Color.YELLOW,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
})
entities.push(e)
return e
})
const handler = new Cesium.ScreenSpaceEventHandler(canvas)
handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => {
if (draggingIndex !== -1) {
// Update dragged vertex
const pos = pickGlobe(viewer, e.endPosition)
if (pos) verts[draggingIndex] = pos
return
}
// Cursor feedback
const picked = viewer.scene.pick(e.endPosition)
const overVertex =
picked?.id instanceof Cesium.Entity &&
vertEntities.includes(picked.id)
canvas.style.cursor = overVertex ? 'grab' : 'default'
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction((e: { position: Cesium.Cartesian2 }) => {
const picked = viewer.scene.pick(e.position)
if (!(picked?.id instanceof Cesium.Entity)) return
const idx = vertEntities.indexOf(picked.id)
if (idx === -1) return
draggingIndex = idx
canvas.style.cursor = 'grabbing'
viewer.scene.screenSpaceCameraController.enableRotate = false
viewer.scene.screenSpaceCameraController.enableTranslate = false
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
handler.setInputAction(() => {
if (draggingIndex === -1) return
draggingIndex = -1
canvas.style.cursor = 'default'
viewer.scene.screenSpaceCameraController.enableRotate = true
viewer.scene.screenSpaceCameraController.enableTranslate = true
// Sync updated geometry back to the store for the submit form.
setDraftPolygon(vertsToGeoJson(verts))
}, Cesium.ScreenSpaceEventType.LEFT_UP)
function cleanup() {
if (viewer.isDestroyed()) return
entities.forEach((e) => viewer.entities.remove(e))
entities.length = 0
canvas.style.cursor = 'default'
viewer.scene.screenSpaceCameraController.enableRotate = true
viewer.scene.screenSpaceCameraController.enableTranslate = true
}
return () => {
handler.destroy()
canvas.removeEventListener('contextmenu', suppressContextMenu)
cleanup()
}
}, [drawingMode, draftPolygon, viewer, setDraftPolygon])
}

View File

@ -0,0 +1,385 @@
/* ── Top bar ─────────────────────────────────────────────────────────────────── */
.topBar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 200;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: rgba(8, 8, 12, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.backBtn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: rgba(255, 255, 255, 0.75);
font-size: 14px;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.backBtn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.title {
flex: 1;
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
padding: 4px 10px;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #f59e0b;
flex-shrink: 0;
}
/* ── Bottom toolbar ──────────────────────────────────────────────────────────── */
.toolbar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(8, 8, 12, 0.80);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
white-space: nowrap;
}
.toolBtn {
padding: 7px 15px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.65);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.toolBtn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.toolBtn.active {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.5);
color: #f59e0b;
}
.divider {
width: 1px;
height: 22px;
background: rgba(255, 255, 255, 0.12);
margin: 0 2px;
flex-shrink: 0;
}
.simulateBtn {
padding: 7px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid rgba(245, 158, 11, 0.5);
background: rgba(245, 158, 11, 0.18);
color: #f59e0b;
transition: background 0.15s, opacity 0.15s;
}
.simulateBtn:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.3);
}
.simulateBtn:disabled {
opacity: 0.4;
cursor: default;
}
.simulateBtn.simulating {
opacity: 0.7;
cursor: wait;
}
.ghostBtn {
padding: 7px 13px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: rgba(255, 255, 255, 0.5);
transition: background 0.15s, color 0.15s;
}
.ghostBtn:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
}
.clearBtn {
padding: 7px 13px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
border: 1px solid rgba(255, 69, 58, 0.25);
background: rgba(255, 69, 58, 0.08);
color: rgba(255, 100, 80, 0.85);
transition: background 0.15s;
}
.clearBtn:hover {
background: rgba(255, 69, 58, 0.18);
}
.countBadge {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
padding: 0 6px;
}
/* ── Selected-point panel ────────────────────────────────────────────────────── */
.selectedPanel {
position: fixed;
right: 20px;
bottom: 90px;
z-index: 200;
width: 230px;
padding: 16px;
background: rgba(8, 8, 12, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
color: #fff;
}
.panelHeading {
margin: 0 0 14px;
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.heightRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.heightLabel {
flex: 1;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
}
.heightValue {
font-size: 15px;
font-weight: 700;
color: #f59e0b;
min-width: 46px;
text-align: right;
}
.stepBtns {
display: flex;
flex-direction: column;
gap: 3px;
}
.stepBtn {
width: 24px;
height: 24px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.stepBtn:hover {
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
.stepGroup {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
margin-bottom: 12px;
}
.stepGroupBtn {
padding: 6px 0;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 12px;
text-align: center;
transition: background 0.12s;
}
.stepGroupBtn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.stepGroupBtn.up {
border-color: rgba(100, 220, 130, 0.2);
color: rgba(100, 220, 130, 0.8);
}
.stepGroupBtn.up:hover {
background: rgba(100, 220, 130, 0.1);
}
.stepGroupBtn.down {
border-color: rgba(255, 130, 100, 0.2);
color: rgba(255, 130, 100, 0.8);
}
.stepGroupBtn.down:hover {
background: rgba(255, 130, 100, 0.1);
}
.velocityInput {
width: 52px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 14px;
font-weight: 600;
padding: 3px 6px;
text-align: right;
}
.velocityInput:focus {
outline: none;
border-color: rgba(245, 158, 11, 0.5);
}
.removeBtn {
width: 100%;
padding: 8px;
border-radius: 8px;
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: 13px;
transition: background 0.12s;
}
.removeBtn:hover {
background: rgba(255, 69, 58, 0.18);
}
/* ── Simulation error ────────────────────────────────────────────────────────── */
.simError {
position: fixed;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
padding: 8px 18px;
background: rgba(220, 38, 38, 0.18);
border: 1px solid rgba(220, 38, 38, 0.4);
border-radius: 8px;
font-size: 13px;
color: rgba(255, 160, 140, 0.95);
white-space: nowrap;
pointer-events: none;
}
/* ── Diagnostics strip ───────────────────────────────────────────────────────── */
.diagStrip {
position: fixed;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
gap: 16px;
padding: 7px 18px;
background: rgba(8, 8, 12, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
pointer-events: none;
white-space: nowrap;
}
.diagWarn {
color: rgba(251, 191, 36, 0.9);
}
/* ── Hint strip ──────────────────────────────────────────────────────────────── */
.hint {
position: fixed;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
padding: 7px 16px;
background: rgba(8, 8, 12, 0.65);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
pointer-events: none;
white-space: nowrap;
}
/* ── Loading overlay ─────────────────────────────────────────────────────────── */
.loading {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
color: rgba(255, 255, 255, 0.7);
font-size: 15px;
pointer-events: none;
}

View File

@ -0,0 +1,435 @@
import { useEffect, useRef, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import * as Cesium from 'cesium'
import { CesiumViewer } from '../cesium/CesiumViewer'
import { useCesiumViewer } from '../cesium/cesiumContext'
import { fetchChallengeDetail } from '../api/challenges'
import { simulateCoaster, saveCoaster } from '../api/coaster'
import { useCoasterPath } from './useCoasterPath'
import { useAccelerationStrips } from './useAccelerationStrips'
import { SimulationPlots } from './SimulationPlots'
import { CoasterListPanel } from './CoasterListPanel'
import { effectivePosition } from './bezierUtils'
import { useAuthStore } from '../store/authStore'
import type { ChallengeDetail, CoasterSimulationResult, SavedCoaster } from '../types/api'
import styles from './CoasterEditorPage.module.css'
// ── Route page ────────────────────────────────────────────────────────────────
export function CoasterEditorPage() {
const { id } = useParams<{ id: string }>()
const [challenge, setChallenge] = useState<ChallengeDetail | null>(null)
useEffect(() => {
if (!id) return
fetchChallengeDetail(id).then(setChallenge).catch(console.error)
}, [id])
return (
<CesiumViewer>
<CoasterEditorScene challengeId={id} challenge={challenge} />
</CesiumViewer>
)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Build a circle cross-section shape for PolylineVolumeGraphics. */
function buildCircleShape(radius: number, segments = 8): Cesium.Cartesian2[] {
const pts: Cesium.Cartesian2[] = []
for (let i = 0; i < segments; i++) {
const angle = (2 * Math.PI * i) / segments
pts.push(new Cesium.Cartesian2(Math.cos(angle) * radius, Math.sin(angle) * radius))
}
return pts
}
const RAIL_SHAPE = buildCircleShape(0.35, 8) // 35 cm radius tube
// ── Inner scene (needs viewer context) ────────────────────────────────────────
interface SceneProps {
challengeId: string | undefined
challenge: ChallengeDetail | null
}
function CoasterEditorScene({ challengeId, challenge }: SceneProps) {
const viewer = useCesiumViewer()
const navigate = useNavigate()
const authUser = useAuthStore(s => s.user)
const currentUsername = authUser?.profile?.preferred_username as string | undefined
const [simResult, setSimResult] = useState<CoasterSimulationResult | null>(null)
const [simulating, setSimulating] = useState(false)
const [simError, setSimError] = useState<string | null>(null)
const [initialVelocity, setInitialVelocity] = useState(1.0)
const [showPath, setShowPath] = useState(true)
const [showAnchors, setShowAnchors] = useState(true)
const [showStrips, setShowStrips] = useState(true)
const [coasterListKey, setCoasterListKey] = useState(0)
const path = useCoasterPath(viewer, showPath, showAnchors)
const accel = useAccelerationStrips(viewer, path.pathPts, path.mode === 'strip', showStrips)
// Refs for simulation result entities (cleared on each new run / unmount)
const simEntitiesRef = useRef<Cesium.Entity[]>([])
const simPrimitivesRef = useRef<Cesium.Primitive[]>([])
// ── Fly to challenge region ───────────────────────────────────────────────
useEffect(() => {
if (!challenge) return
const coords = challenge.region.coordinates[0]
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere())
sphere.radius = Math.max(sphere.radius + 20, 50)
viewer.camera.flyToBoundingSphere(sphere, {
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3),
duration: 1.2,
})
}, [challenge, viewer])
// ── Challenge boundary polygon ────────────────────────────────────────────
const regionEntityRef = useRef<Cesium.Entity | null>(null)
useEffect(() => {
if (regionEntityRef.current) {
viewer.entities.remove(regionEntityRef.current)
regionEntityRef.current = null
}
if (!challenge) return
const coords = challenge.region.coordinates[0]
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
regionEntityRef.current = viewer.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
material: Cesium.Color.CYAN.withAlpha(0.04),
outline: true,
outlineColor: Cesium.Color.CYAN.withAlpha(0.45),
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
})
return () => {
if (regionEntityRef.current && !viewer.isDestroyed()) {
viewer.entities.remove(regionEntityRef.current)
regionEntityRef.current = null
}
}
}, [challenge, viewer])
// ── Render simulation result rails ────────────────────────────────────────
useEffect(() => {
// Clear previous simulation entities
if (!viewer.isDestroyed()) {
simEntitiesRef.current.forEach(e => viewer.entities.remove(e))
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p))
}
simEntitiesRef.current = []
simPrimitivesRef.current = []
if (!simResult) return
const toC3 = ([lon, lat, alt]: [number, number, number]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, alt)
const r1Pts = simResult.rail_1.map(toC3)
const r2Pts = simResult.rail_2.map(toC3)
const rail1 = viewer.entities.add({
polylineVolume: {
positions: r1Pts,
shape: RAIL_SHAPE,
material: Cesium.Color.fromCssColorString('#ef4444'),
cornerType: Cesium.CornerType.ROUNDED,
},
})
const rail2 = viewer.entities.add({
polylineVolume: {
positions: r2Pts,
shape: RAIL_SHAPE,
material: Cesium.Color.fromCssColorString('#ef4444'),
cornerType: Cesium.CornerType.ROUNDED,
},
})
simEntitiesRef.current = [rail1, rail2]
// Load GLB model if available
if (simResult.model_url) {
const [lon0, lat0, alt0] = simResult.origin
const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(lon0, lat0, alt0),
)
Cesium.Model.fromGltfAsync({ url: simResult.model_url, modelMatrix })
.then(model => {
if (viewer.isDestroyed()) return
viewer.scene.primitives.add(model)
simPrimitivesRef.current.push(model as unknown as Cesium.Primitive)
})
.catch(err => console.error('GLB model load failed:', err))
}
return () => {
if (viewer.isDestroyed()) return
simEntitiesRef.current.forEach(e => viewer.entities.remove(e))
simPrimitivesRef.current.forEach(p => viewer.scene.primitives.remove(p))
simEntitiesRef.current = []
simPrimitivesRef.current = []
}
}, [simResult, viewer])
// ── Load / Save handlers ─────────────────────────────────────────────────
function handleLoad(coaster: SavedCoaster) {
const anchorPoints = coaster.anchors.map(a => ({
id: a.id,
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, a.terrainAlt),
heightOffset: a.heightOffset,
}))
path.loadAnchors(anchorPoints)
accel.loadStrips(coaster.acceleration_strips)
setSimResult(null)
}
async function handleSave() {
if (!challengeId || path.anchors.length < 2) return
const storedAnchors = path.anchors.map(a => {
const carto = Cesium.Cartographic.fromCartesian(effectivePosition(a))
return {
id: a.id,
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
terrainAlt: carto.height,
heightOffset: a.heightOffset,
}
})
await saveCoaster(challengeId, {
anchors: storedAnchors,
acceleration_strips: accel.strips,
})
setCoasterListKey(k => k + 1)
}
// ── Simulate handler ──────────────────────────────────────────────────────
async function handleSimulate() {
if (path.anchors.length < 2) return
setSimulating(true)
setSimError(null)
try {
// Send only the anchor control points (not the pre-sampled Bezier
// polyline). The backend fits its own smooth B-spline with analytical
// derivatives, which avoids the curvature spikes that arise from
// finite-differencing a piecewise-linear polyline approximation.
const geoPath = path.anchors.map(anchor => {
const pos = effectivePosition(anchor)
const carto = Cesium.Cartographic.fromCartesian(pos)
return [
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
carto.height,
] as [number, number, number]
})
const result = await simulateCoaster({
path: geoPath,
params: { initial_velocity: initialVelocity },
acceleration_strips: accel.strips.map(s => ({
start_frac: s.startFrac,
end_frac: s.endFrac,
accel_ms2: s.accel_ms2,
})),
})
setSimResult(result)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Simulation failed'
setSimError(msg)
} finally {
setSimulating(false)
}
}
// ── Derived UI state ──────────────────────────────────────────────────────
const selected = path.anchors.find(a => a.id === path.selectedId)
const selectedIndex = path.anchors.findIndex(a => a.id === path.selectedId)
const diag = simResult?.diagnostics
return (
<>
{/* ── Top bar ─────────────────────────────────────────────────────── */}
<div className={styles.topBar}>
<button className={styles.backBtn} onClick={() => navigate('/')}>
Map
</button>
<h1 className={styles.title}>
{challenge ? challenge.title : 'Loading…'}
</h1>
<span className={styles.badge}>Coaster Editor</span>
</div>
{/* ── Mode toolbar ────────────────────────────────────────────────── */}
<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')} />
<ModeButton label="Strip" active={path.mode === 'strip'} onClick={() => path.setMode('strip')} />
<div className={styles.divider} />
<button className={styles.ghostBtn} onClick={path.undoLast} disabled={path.anchors.length === 0}>
Undo
</button>
<button className={styles.clearBtn} onClick={path.clearAll} disabled={path.anchors.length === 0}>
Clear
</button>
{path.anchors.length > 0 && (
<span className={styles.countBadge}>
{path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''}
</span>
)}
<div className={styles.divider} />
<button
className={`${styles.simulateBtn}${simulating ? ` ${styles.simulating}` : ''}`}
onClick={handleSimulate}
disabled={path.anchors.length < 2 || simulating}
>
{simulating ? 'Simulating…' : 'Simulate'}
</button>
<div className={styles.divider} />
<button
className={`${styles.toolBtn}${showPath ? ` ${styles.active}` : ''}`}
onClick={() => setShowPath(p => !p)}
>
Path
</button>
<button
className={`${styles.toolBtn}${showAnchors ? ` ${styles.active}` : ''}`}
onClick={() => setShowAnchors(a => !a)}
>
Anchors
</button>
<button
className={`${styles.toolBtn}${showStrips ? ` ${styles.active}` : ''}`}
onClick={() => setShowStrips(s => !s)}
>
Strips
</button>
<button
className={styles.ghostBtn}
onClick={handleSave}
disabled={path.anchors.length < 2}
>
Save
</button>
</div>
{/* ── Simulation error ─────────────────────────────────────────────── */}
{simError && (
<div className={styles.simError}>{simError}</div>
)}
{/* ── Diagnostics strip ────────────────────────────────────────────── */}
{diag && !simError && (
<div className={styles.diagStrip}>
<span>H: {diag.height_range_m[0].toFixed(0)}{diag.height_range_m[1].toFixed(0)} m</span>
<span>V: {diag.velocity_range_ms[0].toFixed(1)}{diag.velocity_range_ms[1].toFixed(1)} m/s</span>
{diag.g_force_range && (
<span>G: {diag.g_force_range[0].toFixed(2)}{diag.g_force_range[1].toFixed(2)} g</span>
)}
{diag.stall_at_pct !== null && (
<span className={styles.diagWarn}> stall at {diag.stall_at_pct.toFixed(0)}%</span>
)}
</div>
)}
{/* ── Hint strip ──────────────────────────────────────────────────── */}
{!selected && !diag && (
<div className={styles.hint}>
{path.mode === 'add'
? 'Left-click terrain to place track points · Right-click to undo'
: path.mode === 'strip'
? 'Click path start of strip · Click path end · Right-click to cancel'
: 'Left-click a point to select · Drag to reposition · Right-click to delete'}
</div>
)}
{/* ── Selected-point panel ─────────────────────────────────────────── */}
{selected && (
<div className={styles.selectedPanel}>
<p className={styles.panelHeading}>
Point {selectedIndex + 1} of {path.anchors.length}
</p>
<div className={styles.heightRow}>
<span className={styles.heightLabel}>Height offset</span>
<span className={styles.heightValue}>{selected.heightOffset.toFixed(1)} m</span>
</div>
{selectedIndex === 0 && (
<div className={styles.heightRow}>
<span className={styles.heightLabel}>Start velocity</span>
<input
type="number"
min={0} max={100} step={0.5}
value={initialVelocity}
onChange={e => setInitialVelocity(Math.max(0, Math.min(100, Number(e.target.value))))}
className={styles.velocityInput}
/>
<span className={styles.heightLabel}>m/s</span>
</div>
)}
<div className={styles.stepGroup}>
<button className={`${styles.stepGroupBtn} ${styles.up}`} onClick={() => path.updateAnchorHeight(selected.id, 5)}>+5 m</button>
<button className={`${styles.stepGroupBtn} ${styles.up}`} onClick={() => path.updateAnchorHeight(selected.id, 1)}>+1 m</button>
<button className={`${styles.stepGroupBtn} ${styles.down}`} onClick={() => path.updateAnchorHeight(selected.id, -1)}>1 m</button>
<button className={`${styles.stepGroupBtn} ${styles.down}`} onClick={() => path.updateAnchorHeight(selected.id, -5)}>5 m</button>
</div>
<button className={styles.removeBtn} onClick={() => path.removeAnchor(selected.id)}>
Remove point
</button>
</div>
)}
{/* ── Simulation plots + strip list (left panel) ──────────────────── */}
{(accel.strips.length > 0 || simResult?.profile) && (
<SimulationPlots
profile={simResult?.profile ?? null}
strips={accel.strips}
onRemoveStrip={accel.removeStrip}
onUpdateStrip={accel.updateStrip}
/>
)}
{/* ── Coaster list panel (right) ───────────────────────────────────── */}
{challengeId && (
<CoasterListPanel
challengeId={challengeId}
currentUsername={currentUsername}
onLoad={handleLoad}
refreshKey={coasterListKey}
/>
)}
{/* ── Loading overlay ──────────────────────────────────────────────── */}
{!challenge && <div className={styles.loading}>Loading challenge</div>}
</>
)
}
// ── Small helper component ─────────────────────────────────────────────────────
function ModeButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button className={`${styles.toolBtn}${active ? ` ${styles.active}` : ''}`} onClick={onClick}>
{label}
</button>
)
}

View File

@ -0,0 +1,107 @@
.panel {
position: fixed;
right: 16px;
top: 60px;
z-index: 200;
width: 240px;
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 {
display: flex;
align-items: center;
justify-content: space-between;
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;
}
.toggle:hover {
color: #fff;
}
.toggleArrow {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
transition: transform 0.15s;
}
.toggleArrow.open {
transform: rotate(90deg);
}
.body {
padding: 4px 12px 10px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.row {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 12px;
}
.row:last-child {
border-bottom: none;
}
.username {
flex: 1;
color: rgba(255, 255, 255, 0.55);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.you {
color: rgba(245, 158, 11, 0.9);
font-weight: 600;
}
.loadBtn {
padding: 3px 9px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 5px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.loadBtn:hover {
background: rgba(255, 255, 255, 0.13);
color: #fff;
}
.deleteBtn {
padding: 3px 7px;
background: rgba(255, 69, 58, 0.08);
border: 1px solid rgba(255, 69, 58, 0.25);
border-radius: 5px;
color: rgba(255, 100, 80, 0.85);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.deleteBtn:hover {
background: rgba(255, 69, 58, 0.2);
}
.empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.25);
padding: 6px 0 2px;
}

View File

@ -0,0 +1,61 @@
import { useState, useEffect } from 'react'
import { listCoasters, deleteCoaster } from '../api/coaster'
import type { SavedCoaster } from '../types/api'
import styles from './CoasterListPanel.module.css'
interface Props {
challengeId: string
currentUsername: string | undefined
onLoad: (coaster: SavedCoaster) => void
refreshKey: number
}
export function CoasterListPanel({ challengeId, currentUsername, onLoad, refreshKey }: Props) {
const [open, setOpen] = useState(false)
const [coasters, setCoasters] = useState<SavedCoaster[]>([])
useEffect(() => {
listCoasters(challengeId).then(setCoasters).catch(console.error)
}, [challengeId, refreshKey])
async function handleDelete(id: string) {
await deleteCoaster(id)
setCoasters(prev => prev.filter(c => c.id !== id))
}
return (
<div className={styles.panel}>
<button className={styles.toggle} onClick={() => setOpen(o => !o)}>
<span>Coasters ({coasters.length})</span>
<span className={`${styles.toggleArrow}${open ? ` ${styles.open}` : ''}`}></span>
</button>
{open && (
<div className={styles.body}>
{coasters.length === 0 ? (
<p className={styles.empty}>No coasters yet.</p>
) : (
coasters.map(c => {
const isOwn = c.creator_username === currentUsername
return (
<div key={c.id} className={styles.row}>
<span className={`${styles.username}${isOwn ? ` ${styles.you}` : ''}`}>
@{c.creator_username}
</span>
<button className={styles.loadBtn} onClick={() => onLoad(c)}>
Load
</button>
{isOwn && (
<button className={styles.deleteBtn} onClick={() => handleDelete(c.id)}>
Del
</button>
)}
</div>
)
})
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,196 @@
.plotsPanel {
position: fixed;
left: 16px;
top: 60px;
z-index: 200;
width: 340px;
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;
}
.plotsToggle {
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;
}
.plotsToggle: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);
}
.plotsBody {
padding: 4px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
/* ── Strip list ─────────────────────────────────────────────────────────────── */
.sectionLabel {
margin: 10px 0 6px;
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.3);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.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: 60px;
}
.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);
}
.noStrips {
font-size: 11px;
color: rgba(255, 255, 255, 0.25);
margin-bottom: 4px;
}
/* ── Charts ─────────────────────────────────────────────────────────────────── */
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.07);
margin: 10px 0 6px;
}
.chartLabel {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.3);
text-transform: uppercase;
letter-spacing: 0.07em;
margin: 8px 0 2px;
}
.noProfile {
font-size: 11px;
color: rgba(255, 255, 255, 0.2);
margin-top: 8px;
text-align: center;
padding: 8px 0;
}
/* ── Ride stats (duration / length) ────────────────────────────────────────── */
.rideStats {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin: 6px 0 4px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 6px 12px;
}
.rideStat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
}
.rideStatLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.3);
}
.rideStatValue {
font-size: 15px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.01em;
}
.rideStatDivider {
width: 1px;
height: 28px;
background: rgba(255, 255, 255, 0.1);
margin: 0 8px;
flex-shrink: 0;
}

View File

@ -0,0 +1,217 @@
import { useState, useEffect } from 'react'
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceArea, ReferenceLine,
} from 'recharts'
import type { SimulationProfile, AccelerationStrip } from '../types/api'
import styles from './SimulationPlots.module.css'
interface Props {
profile: SimulationProfile | null
strips: AccelerationStrip[]
onRemoveStrip: (id: string) => void
onUpdateStrip: (id: string, accel_ms2: number) => void
}
// ── Shared axis / tooltip styles ──────────────────────────────────────────────
const axisStyle = { fill: 'rgba(255,255,255,0.35)', fontSize: 10 }
const gridStyle = { stroke: 'rgba(255,255,255,0.07)' }
const tooltipStyle = {
contentStyle: {
background: 'rgba(8,8,12,0.92)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 6,
fontSize: 11,
padding: '4px 8px',
},
labelStyle: { color: 'rgba(255,255,255,0.45)', marginBottom: 2 },
itemStyle: { color: 'rgba(255,255,255,0.85)', padding: 0 },
}
function pct(v: number) { return `${(v * 100).toFixed(0)}%` }
function formatDuration(s: number) {
if (s < 60) return `${s.toFixed(0)} s`
let m = Math.floor(s / 60)
let sec = Math.round(s % 60)
if (sec === 60) { m++; sec = 0 }
return `${m}m ${sec.toString().padStart(2, '0')}s`
}
// ── Strip ReferenceAreas (shared across all charts) ───────────────────────────
function StripAreas({ strips }: { strips: AccelerationStrip[] }) {
return (
<>
{strips.map(s => (
<ReferenceArea
key={s.id}
x1={s.startFrac}
x2={s.endFrac}
fill="rgba(245,158,11,0.10)"
stroke="rgba(245,158,11,0.28)"
strokeWidth={1}
/>
))}
</>
)
}
// ── Individual chart ──────────────────────────────────────────────────────────
interface ChartProps {
data: object[]
dataKey: string
color: string
unit: string
strips: AccelerationStrip[]
showZero?: boolean
}
function ProfileChart({ data, dataKey, color, unit, strips, showZero }: ChartProps) {
return (
<ResponsiveContainer width="100%" height={108}>
<LineChart data={data} margin={{ top: 4, right: 6, bottom: 0, left: 28 }}>
<CartesianGrid strokeDasharray="3 3" {...gridStyle} />
<XAxis
dataKey="s"
type="number"
domain={[0, 1]}
tickFormatter={pct}
tick={axisStyle}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={axisStyle}
tickLine={false}
axisLine={false}
width={28}
tickFormatter={(v: number) => v.toFixed(1)}
/>
<Tooltip
{...tooltipStyle}
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, dataKey]}
labelFormatter={(l: number) => `s = ${pct(l)}`}
/>
<StripAreas strips={strips} />
{showZero && (
<ReferenceLine y={0} stroke="rgba(255,255,255,0.18)" strokeDasharray="4 3" />
)}
<Line
dataKey={dataKey}
dot={false}
stroke={color}
strokeWidth={1.5}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export function SimulationPlots({ profile, strips, onRemoveStrip, onUpdateStrip }: Props) {
const [open, setOpen] = useState(false)
// Open the panel when a profile arrives; never auto-close it
useEffect(() => { if (profile) setOpen(true) }, [profile])
const data = profile
? profile.s_frac.map((s, i) => ({
s,
velocity: profile.velocity_ms[i] * 3.6, // m/s → km/h
acceleration: profile.accel_ms2[i],
gForce: profile.g_force[i],
}))
: null
return (
<div className={styles.plotsPanel}>
<button className={styles.plotsToggle} onClick={() => setOpen(o => !o)}>
<i className={`${styles.arrow}${open ? ` ${styles.arrowOpen}` : ''}`}></i>
Simulation Profile
</button>
{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>
<span className={styles.rideStatValue}>{(profile!.total_length_m / 1000).toFixed(2)} km</span>
</span>
<span className={styles.rideStatDivider} />
<span className={styles.rideStat}>
<span className={styles.rideStatLabel}>Duration</span>
<span className={styles.rideStatValue}>{formatDuration(profile!.total_duration_s)}</span>
</span>
<span className={styles.rideStatDivider} />
<span className={styles.rideStat}>
<span className={styles.rideStatLabel}>Start speed</span>
<span className={styles.rideStatValue}>{(profile!.velocity_ms[0] * 3.6).toFixed(1)} km/h</span>
</span>
</div>
<div className={styles.divider} />
<p className={styles.chartLabel}>Velocity (km/h)</p>
<ProfileChart
data={data} dataKey="velocity" color="#4ade80"
unit="km/h" strips={strips}
/>
<p className={styles.chartLabel}>Acceleration (m/s²)</p>
<ProfileChart
data={data} dataKey="acceleration" color="#60a5fa"
unit="m/s²" strips={strips} showZero
/>
<p className={styles.chartLabel}>G-force (g)</p>
<ProfileChart
data={data} dataKey="gForce" color="#f87171"
unit="g" strips={strips} showZero
/>
</>
) : (
<p className={styles.noProfile}>Run a simulation to see profile charts.</p>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,136 @@
import * as Cesium from 'cesium'
export interface AnchorPoint {
id: string
position: Cesium.Cartesian3 // base position on terrain
heightOffset: number // meters above terrain surface
}
// ── helpers ──────────────────────────────────────────────────────────────────
function v3add(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
return Cesium.Cartesian3.add(a, b, new Cesium.Cartesian3())
}
function v3sub(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
return Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3())
}
function v3scale(v: Cesium.Cartesian3, s: number): Cesium.Cartesian3 {
return Cesium.Cartesian3.multiplyByScalar(v, s, new Cesium.Cartesian3())
}
function v3norm(v: Cesium.Cartesian3): Cesium.Cartesian3 {
return Cesium.Cartesian3.normalize(v, new Cesium.Cartesian3())
}
function v3cross(a: Cesium.Cartesian3, b: Cesium.Cartesian3): Cesium.Cartesian3 {
return Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3())
}
/** Return the effective 3-D position of an anchor including its height offset. */
export function effectivePosition(anchor: AnchorPoint): Cesium.Cartesian3 {
if (anchor.heightOffset === 0) return anchor.position.clone()
const up = v3norm(anchor.position)
return v3add(anchor.position, v3scale(up, anchor.heightOffset))
}
// ── Catmull-Rom → cubic Bézier ───────────────────────────────────────────────
interface Segment {
p0: Cesium.Cartesian3
c1: Cesium.Cartesian3
c2: Cesium.Cartesian3
p1: Cesium.Cartesian3
}
/**
* Convert anchor points to cubic Bézier segments via Catmull-Rom.
* The curve passes through every anchor point with C1 continuity.
*/
export function computeSegments(anchors: AnchorPoint[]): Segment[] {
if (anchors.length < 2) return []
const pts = anchors.map(effectivePosition)
const n = pts.length
// phantom end-points so the curve starts/ends at the first/last anchor
const ext = [pts[0], ...pts, pts[n - 1]]
const segments: Segment[] = []
for (let i = 0; i < n - 1; i++) {
const pm1 = ext[i]
const p0 = ext[i + 1]
const p1 = ext[i + 2]
const p2 = ext[i + 3]
// Catmull-Rom tangent handles converted to cubic Bézier control points
const c1 = v3add(p0, v3scale(v3sub(p1, pm1), 1 / 6))
const c2 = v3add(p1, v3scale(v3sub(p0, p2), 1 / 6))
segments.push({ p0, c1, c2, p1 })
}
return segments
}
function evalBezier(seg: Segment, t: number): Cesium.Cartesian3 {
const { p0, c1, c2, p1 } = seg
const mt = 1 - t
return new Cesium.Cartesian3(
mt ** 3 * p0.x + 3 * mt ** 2 * t * c1.x + 3 * mt * t ** 2 * c2.x + t ** 3 * p1.x,
mt ** 3 * p0.y + 3 * mt ** 2 * t * c1.y + 3 * mt * t ** 2 * c2.y + t ** 3 * p1.y,
mt ** 3 * p0.z + 3 * mt ** 2 * t * c1.z + 3 * mt * t ** 2 * c2.z + t ** 3 * p1.z,
)
}
/** Sample the full spline as a polyline (all segments concatenated). */
export function samplePath(
anchors: AnchorPoint[],
samplesPerSegment = 40,
): Cesium.Cartesian3[] {
const segs = computeSegments(anchors)
if (segs.length === 0) return anchors.map(effectivePosition)
const pts: Cesium.Cartesian3[] = []
segs.forEach((seg, i) => {
const from = i === 0 ? 0 : 1
for (let s = from; s <= samplesPerSegment; s++) {
pts.push(evalBezier(seg, s / samplesPerSegment))
}
})
return pts
}
// ── Rail geometry ─────────────────────────────────────────────────────────────
export interface RailPositions {
left: Cesium.Cartesian3[]
right: Cesium.Cartesian3[]
}
/**
* Given a centre-line path compute parallel left/right rail positions.
* @param gauge distance between rails in metres (default 2.5 for visual clarity)
*/
export function computeRails(
path: Cesium.Cartesian3[],
gauge = 2.5,
): RailPositions {
const left: Cesium.Cartesian3[] = []
const right: Cesium.Cartesian3[] = []
const half = gauge / 2
const n = path.length
for (let i = 0; i < n; i++) {
const pt = path[i]
// Finite-difference tangent along the curve
let tangent: Cesium.Cartesian3
if (i === 0) tangent = v3sub(path[1], path[0])
else if (i === n - 1) tangent = v3sub(path[n - 1], path[n - 2])
else tangent = v3sub(path[i + 1], path[i - 1])
tangent = v3norm(tangent)
// Local up = radially outward from Earth centre
const up = v3norm(pt)
// Track-right = tangent × up (right-hand rule → points right when facing forward)
const rightDir = v3norm(v3cross(tangent, up))
left.push(v3add(pt, v3scale(rightDir, -half)))
right.push(v3add(pt, v3scale(rightDir, half)))
}
return { left, right }
}

View File

@ -0,0 +1,240 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import * as Cesium from 'cesium'
import type { AccelerationStrip } from '../types/api'
// ── Arc-length utilities ───────────────────────────────────────────────────────
function computeArcLengths(pts: Cesium.Cartesian3[]): number[] {
const s = [0]
for (let i = 1; i < pts.length; i++) {
s.push(s[i - 1] + Cesium.Cartesian3.distance(pts[i - 1], pts[i]))
}
return s
}
function snapToPath(
pos: Cesium.Cartesian3,
pts: Cesium.Cartesian3[],
arcs: number[],
): { frac: number; pt: Cesium.Cartesian3 } {
let minDist = Infinity
let best = 0
for (let i = 0; i < pts.length; i++) {
const d = Cesium.Cartesian3.distance(pos, pts[i])
if (d < minDist) { minDist = d; best = i }
}
const total = arcs[arcs.length - 1]
return { frac: total > 0 ? arcs[best] / total : 0, pt: pts[best] }
}
function fracToIndex(frac: number, arcs: number[]): number {
const target = frac * arcs[arcs.length - 1]
let best = 0, bestDiff = Infinity
for (let i = 0; i < arcs.length; i++) {
const diff = Math.abs(arcs[i] - target)
if (diff < bestDiff) { bestDiff = diff; best = i }
}
return best
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export interface AccelerationStripsHandle {
strips: AccelerationStrip[]
removeStrip: (id: string) => void
updateStrip: (id: string, accel_ms2: number) => void
clearStrips: () => void
loadStrips: (strips: AccelerationStrip[]) => void
}
interface PendingStrip {
startFrac: number
marker: Cesium.Entity
}
export function useAccelerationStrips(
viewer: Cesium.Viewer,
pathPts: Cesium.Cartesian3[],
isActive: boolean,
showStrips = true,
): AccelerationStripsHandle {
const [strips, setStrips] = useState<AccelerationStrip[]>([])
const pendingRef = useRef<PendingStrip | null>(null)
const stripEntities = useRef<Map<string, Cesium.Entity>>(new Map())
const pathPtsRef = useRef(pathPts)
pathPtsRef.current = pathPts
// ── Public callbacks ───────────────────────────────────────────────────────
const removeStrip = useCallback((id: string) => {
setStrips(prev => prev.filter(s => s.id !== id))
}, [])
const updateStrip = useCallback((id: string, accel_ms2: number) => {
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
}, [])
const clearStrips = useCallback(() => {
setStrips([])
}, [])
const loadStrips = useCallback((newStrips: AccelerationStrip[]) => {
setStrips(newStrips)
}, [])
// ── Click handler for strip placement ─────────────────────────────────────
useEffect(() => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null {
const ray = viewer.camera.getPickRay(pos)
if (!ray) return null
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
}
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
if (!isActive) return
const pts = pathPtsRef.current
if (pts.length < 2) return
const worldPos = pickTerrain(e.position)
if (!worldPos) return
const arcs = computeArcLengths(pts)
const { frac, pt } = snapToPath(worldPos, pts, arcs)
if (!pendingRef.current) {
// First click — place start marker
const marker = viewer.entities.add({
position: new Cesium.ConstantPositionProperty(pt),
point: {
pixelSize: 14,
color: Cesium.Color.fromCssColorString('#f59e0b'),
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
text: 'Strip start',
font: '12px sans-serif',
fillColor: Cesium.Color.fromCssColorString('#f59e0b'),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -22),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
})
pendingRef.current = { startFrac: frac, marker }
} else {
// Second click — complete the strip
const { startFrac, marker } = pendingRef.current
viewer.entities.remove(marker)
pendingRef.current = null
let sf = startFrac, ef = frac
if (sf > ef) [sf, ef] = [ef, sf]
if (sf === ef) return // degenerate — same point
const id = crypto.randomUUID()
setStrips(prev => [...prev, { id, startFrac: sf, endFrac: ef, accel_ms2: 5.0 }])
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
// Right-click: cancel pending
handler.setInputAction(() => {
if (!isActive || !pendingRef.current) return
viewer.entities.remove(pendingRef.current.marker)
pendingRef.current = null
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
return () => {
if (!handler.isDestroyed()) handler.destroy()
}
}, [viewer, isActive]) // pathPts via ref — intentional
// ── Cesium polyline entity sync ────────────────────────────────────────────
useEffect(() => {
if (viewer.isDestroyed()) return
const currentIds = new Set(strips.map(s => s.id))
// Remove entities for deleted strips
stripEntities.current.forEach((entity, id) => {
if (!currentIds.has(id)) {
viewer.entities.remove(entity)
stripEntities.current.delete(id)
}
})
if (pathPts.length < 2) return
const arcs = computeArcLengths(pathPts)
// Remove all existing strip entities and rebuild — pathPts may have changed
stripEntities.current.forEach(entity => viewer.entities.remove(entity))
stripEntities.current.clear()
for (const strip of strips) {
const si = fracToIndex(strip.startFrac, arcs)
const ei = fracToIndex(strip.endFrac, arcs)
const sliced = pathPts.slice(Math.min(si, ei), Math.max(si, ei) + 1)
if (sliced.length < 2) continue
const entity = viewer.entities.add({
id: `accel-strip-${strip.id}`,
polyline: {
positions: sliced,
width: 7,
material: Cesium.Color.fromCssColorString('#f59e0b'),
arcType: Cesium.ArcType.NONE,
clampToGround: false,
},
properties: { stripId: strip.id },
})
stripEntities.current.set(strip.id, entity)
}
}, [strips, pathPts, viewer])
// ── Right-click on strip entity to delete ─────────────────────────────────
useEffect(() => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
if (isActive) return // handled by placement handler above
const picked = viewer.scene.pick(e.position)
if (!Cesium.defined(picked)) return
const entity = picked.id as Cesium.Entity | undefined
if (!(entity instanceof Cesium.Entity)) return
const stripId = entity.properties?.stripId?.getValue() as string | undefined
if (stripId) removeStrip(stripId)
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
return () => {
if (!handler.isDestroyed()) handler.destroy()
}
}, [viewer, isActive, removeStrip])
// ── Strip visibility toggle ────────────────────────────────────────────────
useEffect(() => {
stripEntities.current.forEach(e => { e.show = showStrips })
}, [showStrips])
// ── Cleanup on unmount ─────────────────────────────────────────────────────
useEffect(() => {
return () => {
if (viewer.isDestroyed()) return
if (pendingRef.current) viewer.entities.remove(pendingRef.current.marker)
stripEntities.current.forEach(e => viewer.entities.remove(e))
stripEntities.current.clear()
}
}, [viewer])
return { strips, removeStrip, updateStrip, clearStrips, loadStrips }
}

View File

@ -0,0 +1,338 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import * as Cesium from 'cesium'
import type { AnchorPoint } from './bezierUtils'
import { effectivePosition, samplePath, computeRails } from './bezierUtils'
export type EditorMode = 'add' | 'select' | 'strip'
export interface CoasterPathHandle {
anchors: AnchorPoint[]
pathPts: Cesium.Cartesian3[]
selectedId: string | null
mode: EditorMode
setMode: (m: EditorMode) => void
updateAnchorHeight: (id: string, delta: number) => void
removeAnchor: (id: string) => void
loadAnchors: (anchors: AnchorPoint[]) => void
undoLast: () => void
clearAll: () => void
}
let _counter = 0
function genId(): string {
return `a${++_counter}-${Math.random().toString(36).slice(2, 6)}`
}
export function useCoasterPath(viewer: Cesium.Viewer, showPath = true, showAnchors = true): CoasterPathHandle {
const [anchors, setAnchors] = useState<AnchorPoint[]>([])
const [pathPts, setPathPts] = useState<Cesium.Cartesian3[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [mode, setMode] = useState<EditorMode>('add')
// Keep refs in sync so event-handler closures always see current values
const anchorsRef = useRef(anchors); anchorsRef.current = anchors
const modeRef = useRef(mode); modeRef.current = mode
const selectedRef = useRef(selectedId); selectedRef.current = selectedId
// Cesium entity refs
const sphereMapRef = useRef<Map<string, Cesium.Entity>>(new Map())
const pathEntities = useRef<Cesium.Entity[]>([])
const startLabelRef = useRef<Cesium.Entity | null>(null)
// Drag state (refs to avoid triggering re-renders)
const isDragging = useRef(false)
const dragAnchorId = useRef<string | null>(null)
const dragPos = useRef<Cesium.Cartesian3 | null>(null)
const didMoveDuringDrag = useRef(false)
// ── public callbacks ───────────────────────────────────────────────────────
const updateAnchorHeight = useCallback((id: string, delta: number) => {
setAnchors(prev => prev.map(a => a.id === id ? { ...a, heightOffset: Math.max(0, a.heightOffset + delta) } : a))
}, [])
const removeAnchor = useCallback((id: string) => {
setAnchors(prev => prev.filter(a => a.id !== id))
setSelectedId(prev => prev === id ? null : prev)
}, [])
const loadAnchors = useCallback((newAnchors: AnchorPoint[]) => {
setAnchors(newAnchors)
setSelectedId(null)
}, [])
const undoLast = useCallback(() => {
setAnchors(prev => {
if (prev.length === 0) return prev
const removed = prev[prev.length - 1]
setSelectedId(s => s === removed.id ? null : s)
return prev.slice(0, -1)
})
}, [])
const clearAll = useCallback(() => {
setAnchors([])
setSelectedId(null)
}, [])
// ── cursor style ───────────────────────────────────────────────────────────
useEffect(() => {
viewer.scene.canvas.style.cursor =
mode === 'add' ? 'crosshair' :
mode === 'strip' ? 'cell' : 'default'
}, [mode, viewer])
// ── entity sync ────────────────────────────────────────────────────────────
useEffect(() => {
const existingIds = new Set(anchors.map(a => a.id))
// Remove spheres for deleted anchors
sphereMapRef.current.forEach((entity, id) => {
if (!existingIds.has(id)) {
viewer.entities.remove(entity)
sphereMapRef.current.delete(id)
}
})
// Add or update anchor spheres
anchors.forEach((anchor) => {
const pos = effectivePosition(anchor)
const isSelected = anchor.id === selectedId
const color = isSelected ? Cesium.Color.fromCssColorString('#f59e0b') : Cesium.Color.WHITE
const size = isSelected ? 15 : 10
if (!sphereMapRef.current.has(anchor.id)) {
const entity = viewer.entities.add({
id: `coaster-anchor-${anchor.id}`,
position: new Cesium.ConstantPositionProperty(pos),
point: {
pixelSize: size,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: { anchorId: anchor.id },
})
sphereMapRef.current.set(anchor.id, entity)
} else {
const entity = sphereMapRef.current.get(anchor.id)!
entity.position = new Cesium.ConstantPositionProperty(pos)
entity.point!.color = new Cesium.ConstantProperty(color)
entity.point!.pixelSize = new Cesium.ConstantProperty(size)
}
})
// Rebuild path + rails
pathEntities.current.forEach(e => viewer.entities.remove(e))
pathEntities.current = []
if (anchors.length >= 2) {
const pts = samplePath(anchors)
setPathPts(pts)
const { left, right } = computeRails(pts)
const centre = viewer.entities.add({
polyline: {
positions: pts,
width: 2,
material: Cesium.Color.YELLOW.withAlpha(0.55),
arcType: Cesium.ArcType.NONE,
},
})
const leftRail = viewer.entities.add({
polyline: {
positions: left,
width: 3,
material: Cesium.Color.fromCssColorString('#b0b8c1'),
arcType: Cesium.ArcType.NONE,
},
})
const rightRail = viewer.entities.add({
polyline: {
positions: right,
width: 3,
material: Cesium.Color.fromCssColorString('#b0b8c1'),
arcType: Cesium.ArcType.NONE,
},
})
pathEntities.current = [centre, leftRail, rightRail]
} else {
setPathPts([])
}
// Start label — always at first anchor
if (startLabelRef.current) {
viewer.entities.remove(startLabelRef.current)
startLabelRef.current = null
}
if (anchors.length > 0) {
startLabelRef.current = viewer.entities.add({
position: new Cesium.ConstantPositionProperty(effectivePosition(anchors[0])),
label: {
text: '\u25B6 Start',
font: '13px sans-serif',
fillColor: Cesium.Color.fromCssColorString('#4ade80'),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -22),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
showBackground: true,
backgroundColor: Cesium.Color.BLACK.withAlpha(0.45),
backgroundPadding: new Cesium.Cartesian2(6, 4),
},
})
}
}, [anchors, selectedId, viewer])
// ── path visibility toggle ─────────────────────────────────────────────────
useEffect(() => {
pathEntities.current.forEach(e => { e.show = showPath })
}, [showPath])
// ── anchor visibility toggle ───────────────────────────────────────────────
useEffect(() => {
sphereMapRef.current.forEach(e => { e.show = showAnchors })
if (startLabelRef.current) startLabelRef.current.show = showAnchors
}, [showAnchors])
// ── input handling ─────────────────────────────────────────────────────────
useEffect(() => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
const canvas = viewer.scene.canvas
// Prevent browser context-menu in the viewer
const suppressCtx = (e: Event) => e.preventDefault()
canvas.addEventListener('contextmenu', suppressCtx)
const disableCam = () => {
const c = viewer.scene.screenSpaceCameraController
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = false
}
const enableCam = () => {
const c = viewer.scene.screenSpaceCameraController
c.enableRotate = c.enableZoom = c.enableTranslate = c.enableTilt = true
}
function pickAnchorId(pos: Cesium.Cartesian2): string | null {
const picked = viewer.scene.pick(pos)
if (!Cesium.defined(picked)) return null
const entity = picked.id as Cesium.Entity | undefined
if (!(entity instanceof Cesium.Entity)) return null
return entity.properties?.anchorId?.getValue() ?? null
}
function pickTerrain(pos: Cesium.Cartesian2): Cesium.Cartesian3 | null {
const ray = viewer.camera.getPickRay(pos)
if (!ray) return null
return viewer.scene.globe.pick(ray, viewer.scene) ?? null
}
// LEFT_DOWN start drag in select mode
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
if (modeRef.current !== 'select') return
const anchorId = pickAnchorId(e.position)
if (!anchorId) return
isDragging.current = true
dragAnchorId.current = anchorId
dragPos.current = null
didMoveDuringDrag.current = false
disableCam()
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
// MOUSE_MOVE live-drag the sphere
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.MotionEvent) => {
if (!isDragging.current || !dragAnchorId.current) return
const newPos = pickTerrain(e.endPosition)
if (!newPos) return
didMoveDuringDrag.current = true
dragPos.current = newPos
// Move the sphere entity directly (no React re-render during drag)
const entity = sphereMapRef.current.get(dragAnchorId.current)
if (entity) entity.position = new Cesium.ConstantPositionProperty(newPos)
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
// LEFT_UP commit drag
handler.setInputAction(() => {
if (isDragging.current) {
if (didMoveDuringDrag.current && dragAnchorId.current && dragPos.current) {
const id = dragAnchorId.current
const pos = dragPos.current
setAnchors(prev => prev.map(a => a.id === id ? { ...a, position: pos } : a))
}
isDragging.current = false
dragAnchorId.current = null
dragPos.current = null
enableCam()
}
}, Cesium.ScreenSpaceEventType.LEFT_UP)
// LEFT_CLICK add point or select
handler.setInputAction((e: Cesium.ScreenSpaceEventHandler.PositionedEvent) => {
// Suppress click that immediately follows a completed drag
if (didMoveDuringDrag.current) {
didMoveDuringDrag.current = false
return
}
const anchorId = pickAnchorId(e.position)
if (anchorId) {
setSelectedId(prev => prev === anchorId ? null : anchorId)
return
}
if (modeRef.current === 'add') {
const pos = pickTerrain(e.position)
if (!pos) return
const id = genId()
setAnchors(prev => [...prev, { id, position: pos, heightOffset: 0 }])
setSelectedId(null)
} else if (modeRef.current === 'select') {
setSelectedId(null)
}
// 'strip' mode clicks are handled by useAccelerationStrips
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
// RIGHT_CLICK undo / remove selected
handler.setInputAction(() => {
if (selectedRef.current) {
const id = selectedRef.current
setAnchors(prev => prev.filter(a => a.id !== id))
setSelectedId(null)
} else {
setAnchors(prev => prev.slice(0, -1))
}
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
return () => {
if (!handler.isDestroyed()) handler.destroy()
canvas.removeEventListener('contextmenu', suppressCtx)
enableCam()
viewer.scene.canvas.style.cursor = 'default'
}
}, [viewer]) // eslint-disable-line react-hooks/exhaustive-deps
// ── cleanup on unmount ─────────────────────────────────────────────────────
useEffect(() => {
return () => {
if (viewer.isDestroyed()) return
sphereMapRef.current.forEach(e => viewer.entities.remove(e))
sphereMapRef.current.clear()
pathEntities.current.forEach(e => viewer.entities.remove(e))
pathEntities.current = []
if (startLabelRef.current) {
viewer.entities.remove(startLabelRef.current)
startLabelRef.current = null
}
}
}, [viewer])
return { anchors, pathPts, selectedId, mode, setMode, updateAnchorHeight, removeAnchor, loadAnchors, undoLast, clearAll }
}

View File

@ -97,6 +97,7 @@ export function SplatLayer() {
// Clean up all entities on unmount
useEffect(() => {
return () => {
if (viewer.isDestroyed()) return
entityMapRef.current.forEach((e) => viewer.entities.remove(e))
entityMapRef.current.clear()
}

View File

@ -113,6 +113,77 @@ export interface ChallengeCreateBody {
expires_at?: string | null
}
// ---------- Coaster ----------
export interface StoredAnchor {
id: string
lon: number
lat: number
terrainAlt: number // ellipsoid height of terrain click point
heightOffset: number // metres above terrain (user-adjustable)
}
export interface SavedCoaster {
id: string
creator_username: string
challenge: string
name: string
anchors: StoredAnchor[]
acceleration_strips: Array<{ id: string; startFrac: number; endFrac: number; accel_ms2: number }>
created_at: string
updated_at: string
}
export interface AccelerationStrip {
id: string // client-only, not sent to backend
startFrac: number // 01 along total path arc length
endFrac: number
accel_ms2: number
}
export interface SimulationProfile {
s_frac: number[]
velocity_ms: number[]
accel_ms2: number[]
g_force: number[]
total_length_m: number
total_duration_s: number
}
export interface CoasterSimulateBody {
path: [number, number, number][] // [lon_deg, lat_deg, alt_m]
params?: {
rail_spacing?: number
mass?: number
initial_velocity?: number
friction_coeff?: number
junction_window_length?: number
junction_threshold?: number
binormal_smooth_iterations?: number
downsample_factor?: number
internal_steps?: number
}
acceleration_strips?: Array<{ start_frac: number; end_frac: number; accel_ms2: number }>
}
export interface CoasterSimulationResult {
rail_1: [number, number, number][]
rail_2: [number, number, number][]
origin: [number, number, number]
model_url: string | null
diagnostics: {
height_range_m: [number, number]
velocity_range_ms: [number, number]
g_force_range: [number, number] | null
stall_at_pct: number | null
binormal_variation_max: number
binormal_downward_pct: number
}
profile: SimulationProfile
}
// ---------- Users ----------
export interface UserProfile {

View File

@ -1,43 +1,64 @@
import { useState } from 'react'
import { useChallengeStore } from '../store/challengeStore'
import { useAuthStore } from '../store/authStore'
import { MyChallengesPanel } from '../challenges/MyChallengesPanel'
import { SearchBar } from './SearchBar'
import { OverlayControls } from './OverlayControls'
import styles from './MapOverlay.module.css'
export function MapOverlay() {
const { drawingMode, setDrawingMode, setDraftPolygon } = useChallengeStore()
const { logout } = useAuthStore()
const [showMine, setShowMine] = useState(false)
function handleDrawToggle() {
if (drawingMode) {
setDrawingMode(false)
setDraftPolygon(null)
} else {
setShowMine(false)
setDrawingMode(true)
}
}
return (
<div className={styles.toolbar}>
<button
className={`${styles.btn} ${drawingMode ? styles.active : ''}`}
onClick={handleDrawToggle}
title={drawingMode ? 'Cancel drawing' : 'Create challenge (draw region)'}
>
{drawingMode ? '✕ Cancel' : '+ Challenge'}
</button>
<>
<div className={styles.toolbar}>
<SearchBar />
<OverlayControls />
{drawingMode && (
<p className={styles.hint}>
Click to place vertices · Right-click to close polygon
</p>
)}
<button
className={`${styles.btn} ${drawingMode ? styles.active : ''}`}
onClick={handleDrawToggle}
title={drawingMode ? 'Cancel drawing' : 'Create challenge (draw region)'}
>
{drawingMode ? '✕ Cancel' : '+ Challenge'}
</button>
<button
className={`${styles.btn} ${styles.logout}`}
onClick={() => logout()}
title="Sign out"
>
Sign out
</button>
</div>
<button
className={`${styles.btn} ${showMine ? styles.active : ''}`}
onClick={() => setShowMine((v) => !v)}
title="View my challenges"
>
My Challenges
</button>
{drawingMode && (
<p className={styles.hint}>
Click to place vertices · Right-click to close polygon
</p>
)}
<button
className={`${styles.btn} ${styles.logout}`}
onClick={() => logout()}
title="Sign out"
>
Sign out
</button>
</div>
{showMine && <MyChallengesPanel onClose={() => setShowMine(false)} />}
</>
)
}

View File

@ -0,0 +1,35 @@
.group {
display: flex;
flex-direction: row;
gap: 6px;
flex-wrap: wrap;
}
.chip {
padding: 6px 12px;
background: rgba(15, 15, 20, 0.85);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 20px;
color: rgba(255, 255, 255, 0.65);
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:hover {
background: rgba(40, 40, 55, 0.9);
color: #fff;
}
.chip.on {
background: rgba(16, 185, 129, 0.2);
border-color: rgba(16, 185, 129, 0.6);
color: #34d399;
}
.chip.on:hover {
background: rgba(16, 185, 129, 0.3);
}

View File

@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react'
import * as Cesium from 'cesium'
import { useCesiumViewer } from '../cesium/cesiumContext'
import styles from './OverlayControls.module.css'
interface OverlayDef {
id: string
label: string
// UrlTemplateImageryProvider url — uses {z}/{x}/{y} OR {z}/{y}/{x} for ESRI
url: string
alpha: number
}
const OVERLAYS: OverlayDef[] = [
{
id: 'streets',
label: 'Streets',
// ESRI World Transportation — roads, highways, rail; no labels
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
alpha: 0.75,
},
{
id: 'labels',
label: 'City names',
// CartoDB Voyager labels-only — place/city/country labels
url: 'https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}.png',
alpha: 1.0,
},
{
id: 'borders',
label: 'Borders',
// ESRI World Boundaries & Places — country + admin borders + labels
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
alpha: 0.85,
},
]
export function OverlayControls() {
const viewer = useCesiumViewer()
const [active, setActive] = useState<Set<string>>(new Set())
// Keep a ref map from overlay id → the live ImageryLayer so we can remove it
const layerRefs = useRef<Map<string, Cesium.ImageryLayer>>(new Map())
function toggle(overlay: OverlayDef) {
setActive((prev) => {
const next = new Set(prev)
if (next.has(overlay.id)) {
// Remove layer
const layer = layerRefs.current.get(overlay.id)
if (layer && !viewer.isDestroyed()) {
viewer.imageryLayers.remove(layer, true)
}
layerRefs.current.delete(overlay.id)
next.delete(overlay.id)
} else {
// Add layer
const provider = new Cesium.UrlTemplateImageryProvider({ url: overlay.url })
const layer = viewer.imageryLayers.addImageryProvider(provider)
layer.alpha = overlay.alpha
layerRefs.current.set(overlay.id, layer)
next.add(overlay.id)
}
return next
})
}
// Clean up all layers if the component unmounts
useEffect(() => {
const refs = layerRefs.current
return () => {
refs.forEach((layer) => {
if (!viewer.isDestroyed()) viewer.imageryLayers.remove(layer, true)
})
}
}, [viewer])
return (
<div className={styles.group}>
{OVERLAYS.map((o) => (
<button
key={o.id}
className={`${styles.chip} ${active.has(o.id) ? styles.on : ''}`}
onClick={() => toggle(o)}
title={`Toggle ${o.label} overlay`}
>
{o.label}
</button>
))}
</div>
)
}

View File

@ -0,0 +1,83 @@
.container {
position: relative;
width: 260px;
}
.form {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: rgba(15, 15, 20, 0.85);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
height: 38px;
}
.icon {
color: rgba(255, 255, 255, 0.45);
font-size: 18px;
line-height: 1;
user-select: none;
flex-shrink: 0;
}
.input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
font-size: 14px;
min-width: 0;
}
.input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: rgba(255, 255, 255, 0.7);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: rgba(15, 15, 20, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
list-style: none;
margin: 0;
padding: 4px 0;
z-index: 30;
overflow: hidden;
}
.item {
padding: 9px 14px;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item:hover {
background: rgba(99, 102, 241, 0.25);
color: #fff;
}

106
web/src/ui/SearchBar.tsx Normal file
View File

@ -0,0 +1,106 @@
import { useState, useRef, useEffect } from 'react'
import * as Cesium from 'cesium'
import { useCesiumViewer } from '../cesium/cesiumContext'
import styles from './SearchBar.module.css'
interface NominatimResult {
place_id: number
display_name: string
lat: string
lon: string
boundingbox: [string, string, string, string] // [minLat, maxLat, minLon, maxLon]
}
export function SearchBar() {
const viewer = useCesiumViewer()
const [query, setQuery] = useState('')
const [results, setResults] = useState<NominatimResult[]>([])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [])
function handleChange(value: string) {
setQuery(value)
if (debounceRef.current) clearTimeout(debounceRef.current)
if (!value.trim()) {
setResults([])
setOpen(false)
return
}
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(value)}&format=json&limit=6&addressdetails=0`
const res = await fetch(url, { headers: { 'Accept-Language': 'en' } })
const data: NominatimResult[] = await res.json()
setResults(data)
setOpen(data.length > 0)
} catch {
setResults([])
setOpen(false)
} finally {
setLoading(false)
}
}, 300)
}
function flyTo(result: NominatimResult) {
const [minLat, maxLat, minLon, maxLon] = result.boundingbox.map(Number)
const rect = Cesium.Rectangle.fromDegrees(minLon, minLat, maxLon, maxLat)
viewer.camera.flyTo({
destination: rect,
duration: 1.5,
})
setQuery(result.display_name.split(',')[0])
setOpen(false)
setResults([])
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (results.length > 0) flyTo(results[0])
}
return (
<div ref={containerRef} className={styles.container}>
<form onSubmit={handleSubmit} className={styles.form}>
<span className={styles.icon}></span>
<input
className={styles.input}
type="text"
placeholder="Search city…"
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => results.length > 0 && setOpen(true)}
autoComplete="off"
spellCheck={false}
/>
{loading && <span className={styles.spinner} />}
</form>
{open && (
<ul className={styles.dropdown}>
{results.map((r) => (
<li
key={r.place_id}
className={styles.item}
onMouseDown={() => flyTo(r)}
>
{r.display_name}
</li>
))}
</ul>
)}
</div>
)
}