diff --git a/backend/Dockerfile b/backend/Dockerfile index d881cf1..ee092fb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/apps/challenges/serializers.py b/backend/apps/challenges/serializers.py index 35b88d9..7c5076e 100644 --- a/backend/apps/challenges/serializers.py +++ b/backend/apps/challenges/serializers.py @@ -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() diff --git a/backend/apps/challenges/views.py b/backend/apps/challenges/views.py index 640df06..352bb63 100644 --- a/backend/apps/challenges/views.py +++ b/backend/apps/challenges/views.py @@ -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) diff --git a/backend/apps/coaster/__init__.py b/backend/apps/coaster/__init__.py new file mode 100644 index 0000000..2f62069 --- /dev/null +++ b/backend/apps/coaster/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.coaster.apps.CoasterConfig' diff --git a/backend/apps/coaster/apps.py b/backend/apps/coaster/apps.py new file mode 100644 index 0000000..e6f5cb5 --- /dev/null +++ b/backend/apps/coaster/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoasterConfig(AppConfig): + name = 'apps.coaster' + default_auto_field = 'django.db.models.BigAutoField' diff --git a/backend/apps/coaster/mesh.py b/backend/apps/coaster/mesh.py new file mode 100644 index 0000000..da51e7f --- /dev/null +++ b/backend/apps/coaster/mesh.py @@ -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 diff --git a/backend/apps/coaster/migrations/0001_initial.py b/backend/apps/coaster/migrations/0001_initial.py new file mode 100644 index 0000000..3e88b45 --- /dev/null +++ b/backend/apps/coaster/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/backend/apps/coaster/migrations/__init__.py b/backend/apps/coaster/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/coaster/models.py b/backend/apps/coaster/models.py new file mode 100644 index 0000000..ec7f1d5 --- /dev/null +++ b/backend/apps/coaster/models.py @@ -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}' diff --git a/backend/apps/coaster/physics.py b/backend/apps/coaster/physics.py new file mode 100644 index 0000000..d62a41d --- /dev/null +++ b/backend/apps/coaster/physics.py @@ -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 (85–95). + 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 diff --git a/backend/apps/coaster/serializers.py b/backend/apps/coaster/serializers.py new file mode 100644 index 0000000..f1b0851 --- /dev/null +++ b/backend/apps/coaster/serializers.py @@ -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'] diff --git a/backend/apps/coaster/urls.py b/backend/apps/coaster/urls.py new file mode 100644 index 0000000..80a41e0 --- /dev/null +++ b/backend/apps/coaster/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import CoasterSimulateView, CoasterListCreateView, CoasterDeleteView + +urlpatterns = [ + path("simulate/", CoasterSimulateView.as_view()), + path("challenges//coasters/", CoasterListCreateView.as_view()), + path("/", CoasterDeleteView.as_view()), +] diff --git a/backend/apps/coaster/views.py b/backend/apps/coaster/views.py new file mode 100644 index 0000000..fbf790c --- /dev/null +++ b/backend/apps/coaster/views.py @@ -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": "", + "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) diff --git a/backend/config/api_urls.py b/backend/config/api_urls.py index b00b308..57ddd25 100644 --- a/backend/config/api_urls.py +++ b/backend/config/api_urls.py @@ -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")), ] diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 10cd45c..214e14d 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -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 diff --git a/backend/config/settings/development.py b/backend/config/settings/development.py index ec062a6..111772f 100644 --- a/backend/config/settings/development.py +++ b/backend/config/settings/development.py @@ -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"}, + }, +} diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index 09bd231..866e0c8 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -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 diff --git a/web/package-lock.json b/web/package-lock.json index 8c708a0..dc8c0e1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 8d25d76..509934b 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index c09d308..0add695 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> + } /> diff --git a/web/src/api/challenges.ts b/web/src/api/challenges.ts index 922d25e..cfce7f6 100644 --- a/web/src/api/challenges.ts +++ b/web/src/api/challenges.ts @@ -22,6 +22,11 @@ export async function fetchChallenges( return data } +export async function fetchMyChallenges(): Promise { + const { data } = await apiClient.get('/challenges/', { params: { mine: 'true' } }) + return data +} + export async function fetchChallengeDetail(id: string): Promise { const { data } = await apiClient.get(`/challenges/${id}/`) return data diff --git a/web/src/api/coaster.ts b/web/src/api/coaster.ts new file mode 100644 index 0000000..6294572 --- /dev/null +++ b/web/src/api/coaster.ts @@ -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 { + const res = await apiClient.post('/coaster/simulate/', body) + return res.data +} + +export async function listCoasters(challengeId: string): Promise { + const res = await apiClient.get( + `/coaster/challenges/${challengeId}/coasters/`, + ) + return res.data +} + +export async function saveCoaster( + challengeId: string, + body: { + name?: string + anchors: StoredAnchor[] + acceleration_strips: Array> + }, +): Promise { + const res = await apiClient.post( + `/coaster/challenges/${challengeId}/coasters/`, + body, + ) + return res.data +} + +export async function deleteCoaster(id: string): Promise { + await apiClient.delete(`/coaster/${id}/`) +} diff --git a/web/src/auth/CallbackPage.tsx b/web/src/auth/CallbackPage.tsx index 8826722..eacd6ab 100644 --- a/web/src/auth/CallbackPage.tsx +++ b/web/src/auth/CallbackPage.tsx @@ -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 diff --git a/web/src/auth/userManager.ts b/web/src/auth/userManager.ts index 69bb623..c415a3d 100644 --- a/web/src/auth/userManager.ts +++ b/web/src/auth/userManager.ts @@ -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, }) diff --git a/web/src/cesium/CesiumViewer.tsx b/web/src/cesium/CesiumViewer.tsx index 05b0037..c5c43b6 100644 --- a/web/src/cesium/CesiumViewer.tsx +++ b/web/src/cesium/CesiumViewer.tsx @@ -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) diff --git a/web/src/challenges/ChallengeCreator.module.css b/web/src/challenges/ChallengeCreator.module.css index 4bd3548..0ab3d89 100644 --- a/web/src/challenges/ChallengeCreator.module.css +++ b/web/src/challenges/ChallengeCreator.module.css @@ -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 { diff --git a/web/src/challenges/ChallengeLayer.tsx b/web/src/challenges/ChallengeLayer.tsx index e56f0f6..acab8bb 100644 --- a/web/src/challenges/ChallengeLayer.tsx +++ b/web/src/challenges/ChallengeLayer.tsx @@ -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) diff --git a/web/src/challenges/ChallengePanel.module.css b/web/src/challenges/ChallengePanel.module.css index 7306b5c..e7d1fd6 100644 --- a/web/src/challenges/ChallengePanel.module.css +++ b/web/src/challenges/ChallengePanel.module.css @@ -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; diff --git a/web/src/challenges/ChallengePanel.tsx b/web/src/challenges/ChallengePanel.tsx index c45519f..b2193d4 100644 --- a/web/src/challenges/ChallengePanel.tsx +++ b/web/src/challenges/ChallengePanel.tsx @@ -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(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() { )} + + + + {detail.status === 'active' && !participating && ( + + + {loading &&

Loading…

} + {error &&

{error}

} + {!loading && !error && challenges.length === 0 && ( +

No challenges yet.

+ )} + +
    + {challenges.map((c) => ( +
  • handleSelect(c.id)}> +
    {c.title}
    +
    + {c.status} + {c.submission_count} submission{c.submission_count !== 1 ? 's' : ''} +
    +
  • + ))} +
+ + ) +} diff --git a/web/src/challenges/usePolygonDraw.ts b/web/src/challenges/usePolygonDraw.ts index 53aa572..2961e90 100644 --- a/web/src/challenges/usePolygonDraw.ts +++ b/web/src/challenges/usePolygonDraw.ts @@ -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([]) - const previewEntityRef = useRef(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([]) + + // ── 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]) } diff --git a/web/src/coaster/CoasterEditorPage.module.css b/web/src/coaster/CoasterEditorPage.module.css new file mode 100644 index 0000000..cf1422d --- /dev/null +++ b/web/src/coaster/CoasterEditorPage.module.css @@ -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; +} diff --git a/web/src/coaster/CoasterEditorPage.tsx b/web/src/coaster/CoasterEditorPage.tsx new file mode 100644 index 0000000..34fde03 --- /dev/null +++ b/web/src/coaster/CoasterEditorPage.tsx @@ -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(null) + + useEffect(() => { + if (!id) return + fetchChallengeDetail(id).then(setChallenge).catch(console.error) + }, [id]) + + return ( + + + + ) +} + +// ── 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(null) + const [simulating, setSimulating] = useState(false) + const [simError, setSimError] = useState(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([]) + const simPrimitivesRef = useRef([]) + + // ── 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(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 ─────────────────────────────────────────────────────── */} +
+ +

+ {challenge ? challenge.title : 'Loading…'} +

+ Coaster Editor +
+ + {/* ── Mode toolbar ────────────────────────────────────────────────── */} +
+ path.setMode('add')} /> + path.setMode('select')} /> + path.setMode('strip')} /> + +
+ + + + + {path.anchors.length > 0 && ( + + {path.anchors.length} pt{path.anchors.length !== 1 ? 's' : ''} + + )} + +
+ + + +
+ + + + +
+ + {/* ── Simulation error ─────────────────────────────────────────────── */} + {simError && ( +
{simError}
+ )} + + {/* ── Diagnostics strip ────────────────────────────────────────────── */} + {diag && !simError && ( +
+ H: {diag.height_range_m[0].toFixed(0)}–{diag.height_range_m[1].toFixed(0)} m + V: {diag.velocity_range_ms[0].toFixed(1)}–{diag.velocity_range_ms[1].toFixed(1)} m/s + {diag.g_force_range && ( + G: {diag.g_force_range[0].toFixed(2)}–{diag.g_force_range[1].toFixed(2)} g + )} + {diag.stall_at_pct !== null && ( + ⚠ stall at {diag.stall_at_pct.toFixed(0)}% + )} +
+ )} + + {/* ── Hint strip ──────────────────────────────────────────────────── */} + {!selected && !diag && ( +
+ {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'} +
+ )} + + {/* ── Selected-point panel ─────────────────────────────────────────── */} + {selected && ( +
+

+ Point {selectedIndex + 1} of {path.anchors.length} +

+
+ Height offset + {selected.heightOffset.toFixed(1)} m +
+ {selectedIndex === 0 && ( +
+ Start velocity + setInitialVelocity(Math.max(0, Math.min(100, Number(e.target.value))))} + className={styles.velocityInput} + /> + m/s +
+ )} +
+ + + + +
+ +
+ )} + + {/* ── Simulation plots + strip list (left panel) ──────────────────── */} + {(accel.strips.length > 0 || simResult?.profile) && ( + + )} + + {/* ── Coaster list panel (right) ───────────────────────────────────── */} + {challengeId && ( + + )} + + {/* ── Loading overlay ──────────────────────────────────────────────── */} + {!challenge &&
Loading challenge…
} + + ) +} + +// ── Small helper component ───────────────────────────────────────────────────── + +function ModeButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ) +} diff --git a/web/src/coaster/CoasterListPanel.module.css b/web/src/coaster/CoasterListPanel.module.css new file mode 100644 index 0000000..fbe7b19 --- /dev/null +++ b/web/src/coaster/CoasterListPanel.module.css @@ -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; +} diff --git a/web/src/coaster/CoasterListPanel.tsx b/web/src/coaster/CoasterListPanel.tsx new file mode 100644 index 0000000..45ffac8 --- /dev/null +++ b/web/src/coaster/CoasterListPanel.tsx @@ -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([]) + + 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 ( +
+ + + {open && ( +
+ {coasters.length === 0 ? ( +

No coasters yet.

+ ) : ( + coasters.map(c => { + const isOwn = c.creator_username === currentUsername + return ( +
+ + @{c.creator_username} + + + {isOwn && ( + + )} +
+ ) + }) + )} +
+ )} +
+ ) +} diff --git a/web/src/coaster/SimulationPlots.module.css b/web/src/coaster/SimulationPlots.module.css new file mode 100644 index 0000000..68210dd --- /dev/null +++ b/web/src/coaster/SimulationPlots.module.css @@ -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; +} diff --git a/web/src/coaster/SimulationPlots.tsx b/web/src/coaster/SimulationPlots.tsx new file mode 100644 index 0000000..fa3b406 --- /dev/null +++ b/web/src/coaster/SimulationPlots.tsx @@ -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 => ( + + ))} + + ) +} + +// ── 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 ( + + + + + v.toFixed(1)} + /> + [`${v.toFixed(2)} ${unit}`, dataKey]} + labelFormatter={(l: number) => `s = ${pct(l)}`} + /> + + {showZero && ( + + )} + + + + ) +} + +// ── 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 ( +
+ + + {open && ( +
+ + {/* ── Strip list ─────────────────────────────────────────────────── */} +

Acceleration Strips

+ {strips.length === 0 + ?

No strips placed. Switch to Strip mode to add.

+ : strips.map((s, i) => ( +
+ Strip {i + 1} + + {pct(s.startFrac)}–{pct(s.endFrac)} + + onUpdateStrip(s.id, Number(e.target.value))} + className={styles.accelInput} + /> + m/s² + +
+ )) + } + + {/* ── Charts ─────────────────────────────────────────────────────── */} + {data ? ( + <> +
+
+ + Length + {(profile!.total_length_m / 1000).toFixed(2)} km + + + + Duration + {formatDuration(profile!.total_duration_s)} + + + + Start speed + {(profile!.velocity_ms[0] * 3.6).toFixed(1)} km/h + +
+
+

Velocity (km/h)

+ +

Acceleration (m/s²)

+ +

G-force (g)

+ + + ) : ( +

Run a simulation to see profile charts.

+ )} +
+ )} +
+ ) +} diff --git a/web/src/coaster/bezierUtils.ts b/web/src/coaster/bezierUtils.ts new file mode 100644 index 0000000..43c4fc6 --- /dev/null +++ b/web/src/coaster/bezierUtils.ts @@ -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 } +} diff --git a/web/src/coaster/useAccelerationStrips.ts b/web/src/coaster/useAccelerationStrips.ts new file mode 100644 index 0000000..be48ca4 --- /dev/null +++ b/web/src/coaster/useAccelerationStrips.ts @@ -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([]) + + const pendingRef = useRef(null) + const stripEntities = useRef>(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 } +} diff --git a/web/src/coaster/useCoasterPath.ts b/web/src/coaster/useCoasterPath.ts new file mode 100644 index 0000000..015a7fa --- /dev/null +++ b/web/src/coaster/useCoasterPath.ts @@ -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([]) + const [pathPts, setPathPts] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [mode, setMode] = useState('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>(new Map()) + const pathEntities = useRef([]) + const startLabelRef = useRef(null) + + // Drag state (refs to avoid triggering re-renders) + const isDragging = useRef(false) + const dragAnchorId = useRef(null) + const dragPos = useRef(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 } +} diff --git a/web/src/splat/SplatLayer.tsx b/web/src/splat/SplatLayer.tsx index bb039b0..c18d9a5 100644 --- a/web/src/splat/SplatLayer.tsx +++ b/web/src/splat/SplatLayer.tsx @@ -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() } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0461443..c58c099 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -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 // 0–1 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 { diff --git a/web/src/ui/MapOverlay.tsx b/web/src/ui/MapOverlay.tsx index 4efcc5c..a73cd2e 100644 --- a/web/src/ui/MapOverlay.tsx +++ b/web/src/ui/MapOverlay.tsx @@ -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 ( -
- + <> +
+ + - {drawingMode && ( -

- Click to place vertices · Right-click to close polygon -

- )} + - -
+ + + {drawingMode && ( +

+ Click to place vertices · Right-click to close polygon +

+ )} + + +
+ + {showMine && setShowMine(false)} />} + ) } diff --git a/web/src/ui/OverlayControls.module.css b/web/src/ui/OverlayControls.module.css new file mode 100644 index 0000000..64d8390 --- /dev/null +++ b/web/src/ui/OverlayControls.module.css @@ -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); +} diff --git a/web/src/ui/OverlayControls.tsx b/web/src/ui/OverlayControls.tsx new file mode 100644 index 0000000..17d3406 --- /dev/null +++ b/web/src/ui/OverlayControls.tsx @@ -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>(new Set()) + // Keep a ref map from overlay id → the live ImageryLayer so we can remove it + const layerRefs = useRef>(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 ( +
+ {OVERLAYS.map((o) => ( + + ))} +
+ ) +} diff --git a/web/src/ui/SearchBar.module.css b/web/src/ui/SearchBar.module.css new file mode 100644 index 0000000..6e4db49 --- /dev/null +++ b/web/src/ui/SearchBar.module.css @@ -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; +} diff --git a/web/src/ui/SearchBar.tsx b/web/src/ui/SearchBar.tsx new file mode 100644 index 0000000..b492cb5 --- /dev/null +++ b/web/src/ui/SearchBar.tsx @@ -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([]) + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const debounceRef = useRef | null>(null) + const containerRef = useRef(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 ( +
+
+ + handleChange(e.target.value)} + onFocus={() => results.length > 0 && setOpen(true)} + autoComplete="off" + spellCheck={false} + /> + {loading && } + + {open && ( +
    + {results.map((r) => ( +
  • flyTo(r)} + > + {r.display_name} +
  • + ))} +
+ )} +
+ ) +}