From 21c842acdc6822910737e83115c16d3c6f41f432 Mon Sep 17 00:00:00 2001 From: scott Date: Wed, 8 Apr 2026 12:21:58 -0700 Subject: [PATCH] Add animated package along trajectory with Kepler propagation - Trajectory sampling: propagate Lambert transfer orbits using universal variable Kepler propagation, sample 60 points per leg - Smooth curved trajectory lines on 2D canvas (real orbital arcs, not straight lines) - Animated green package dot with radial glow traveling along trajectory - Route progress slider (0-100%) with play/pause animation toggle - Auto-animating ~20-second cycle through the full route - WASM API: sample_route_trajectory() with per-leg NaN separators Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/mass-driver-wasm/src/api.rs | 70 +++++++++++ crates/orbital-mechanics/src/lagrange.rs | 2 +- crates/orbital-mechanics/src/lambert.rs | 108 +++++++++++++++++ .../src/lib/render/canvas2d/SolarSystem2D.ts | 114 +++++++++++++----- frontend/src/lib/stores/routing.svelte.ts | 5 + frontend/src/lib/wasm/bridge.ts | 11 ++ .../components/Canvas2DView.svelte | 18 ++- .../components/RoutingPanel.svelte | 39 +++++- 8 files changed, 329 insertions(+), 38 deletions(-) diff --git a/crates/mass-driver-wasm/src/api.rs b/crates/mass-driver-wasm/src/api.rs index 43991dc..0831092 100644 --- a/crates/mass-driver-wasm/src/api.rs +++ b/crates/mass-driver-wasm/src/api.rs @@ -102,6 +102,76 @@ pub fn get_station_names(stations_json: &str) -> String { serde_json::to_string(&names).unwrap() } +/// Sample trajectory points along a route for visualization. +/// Takes the stations JSON, route JSON (from compute_route), and current JD. +/// Returns flat Float64Array of [x,y,z, ...] in AU for all legs concatenated, +/// with a separator of NaN,NaN,NaN between legs. +#[wasm_bindgen] +pub fn sample_route_trajectory( + stations_json: &str, + route_json: &str, + jd: f64, + samples_per_leg: usize, +) -> Vec { + use mass_driver_core::route::RouteResult; + use orbital_mechanics::lambert; + + let stations: Vec = match serde_json::from_str(stations_json) { + Ok(s) => s, + Err(_) => return vec![], + }; + let route: RouteResult = match serde_json::from_str(route_json) { + Ok(r) => r, + Err(_) => return vec![], + }; + + let all_bodies = bodies::all_bodies(); + let mut result = Vec::new(); + + for (i, leg) in route.legs.iter().enumerate() { + if i > 0 { + // Separator between legs + result.push(f64::NAN); + result.push(f64::NAN); + result.push(f64::NAN); + } + + // Get station positions at departure/arrival times + let dep_jd = jd + (leg.departure_week as f64) * 7.0; + let arr_jd = jd + (leg.arrival_week as f64) * 7.0; + let tof_seconds = (arr_jd - dep_jd) * orbital_mechanics::constants::SECONDS_PER_DAY; + + let r1_au = station::station_position( + &stations[leg.from_station], &all_bodies, dep_jd, + ); + let r2_au = station::station_position( + &stations[leg.to_station], &all_bodies, arr_jd, + ); + + // Convert to km for Lambert solver + let au_km = orbital_mechanics::constants::AU_KM; + let r1_km = r1_au * au_km; + let r2_km = r2_au * au_km; + + let points = lambert::sample_trajectory( + r1_km, r2_km, tof_seconds, + orbital_mechanics::constants::MU_SUN, + samples_per_leg, + ); + + // Convert back to AU + for chunk in points.chunks(3) { + if chunk.len() == 3 { + result.push(chunk[0] / au_km); + result.push(chunk[1] / au_km); + result.push(chunk[2] / au_km); + } + } + } + + result +} + /// Compute transfer matrix and find optimal route between two stations. /// /// This is the main computation function. It: diff --git a/crates/orbital-mechanics/src/lagrange.rs b/crates/orbital-mechanics/src/lagrange.rs index 52fe7a1..6539352 100644 --- a/crates/orbital-mechanics/src/lagrange.rs +++ b/crates/orbital-mechanics/src/lagrange.rs @@ -72,7 +72,7 @@ pub fn lagrange_point_position( #[cfg(test)] mod tests { use super::*; - use crate::bodies::all_bodies; + use crate::bodies::{self, all_bodies}; #[test] fn test_l1_between_sun_and_planet() { diff --git a/crates/orbital-mechanics/src/lambert.rs b/crates/orbital-mechanics/src/lambert.rs index 4737c59..1712bf3 100644 --- a/crates/orbital-mechanics/src/lambert.rs +++ b/crates/orbital-mechanics/src/lambert.rs @@ -214,6 +214,114 @@ pub fn transfer_delta_v( Some((dv1, dv2)) } +/// Sample points along a Lambert transfer trajectory. +/// Returns positions at evenly spaced times between departure and arrival. +/// +/// # Arguments +/// * `r1`, `r2` - Start/end positions (km) +/// * `tof` - Time of flight (seconds) +/// * `mu` - Gravitational parameter (km³/s²) +/// * `num_samples` - Number of sample points (including start and end) +/// +/// Returns flat [x,y,z, x,y,z, ...] in km, or empty if Lambert fails. +pub fn sample_trajectory( + r1: Vector3, + r2: Vector3, + tof: f64, + mu: f64, + num_samples: usize, +) -> Vec { + let sol = match solve_lambert(r1, r2, tof, mu, true) { + Some(s) => s, + None => return vec![], + }; + + if num_samples < 2 { + return vec![r1.x, r1.y, r1.z]; + } + + let mut result = Vec::with_capacity(num_samples * 3); + + for i in 0..num_samples { + let t_frac = i as f64 / (num_samples - 1) as f64; + let dt = t_frac * tof; + + if i == 0 { + result.push(r1.x); + result.push(r1.y); + result.push(r1.z); + continue; + } + if i == num_samples - 1 { + result.push(r2.x); + result.push(r2.y); + result.push(r2.z); + continue; + } + + // Propagate from r1 with v1 using the universal variable + let pos = propagate_kepler(r1, sol.v1, dt, mu); + result.push(pos.x); + result.push(pos.y); + result.push(pos.z); + } + + result +} + +/// Propagate a Keplerian orbit forward by time dt from given state (r, v). +fn propagate_kepler(r0: Vector3, v0: Vector3, dt: f64, mu: f64) -> Vector3 { + let r0_mag = r0.norm(); + let v0_mag = v0.norm(); + let vr0 = r0.dot(&v0) / r0_mag; // radial velocity + let alpha = 2.0 / r0_mag - v0_mag * v0_mag / mu; // 1/a + + // Solve for universal variable chi using Newton-Raphson + let sqrt_mu = mu.sqrt(); + // Initial guess for universal variable + let mut chi = if alpha > 1e-10 { + sqrt_mu * dt * alpha + } else { + sqrt_mu * dt / r0_mag + }; + + for _ in 0..50 { + let chi2 = chi * chi; + let z = alpha * chi2; + let (c2, c3) = super::lambert::stumpff(z); + + let _r = chi2 * c2 + vr0 / sqrt_mu * chi * (1.0 - z * c3) + r0_mag * (1.0 - z * c2); + + // f(chi) = r0 * chi * (1 - z*c3) + vr0/sqrt_mu * chi^2 * c2 + r0_mag * chi^3 * c3 - sqrt_mu * dt + let f_chi = vr0 / sqrt_mu * chi2 * c2 + + (1.0 - r0_mag * alpha) * chi * chi2 * c3 + + r0_mag * chi + - sqrt_mu * dt; + + let f_prime = chi2 * c2 + vr0 / sqrt_mu * chi * (1.0 - z * c3) + r0_mag * (1.0 - z * c2); + + if f_prime.abs() < 1e-20 { + break; + } + + chi -= f_chi / f_prime; + + if (f_chi / f_prime).abs() < 1e-10 { + break; + } + } + + let chi2 = chi * chi; + let z = alpha * chi2; + let (c2, c3) = stumpff(z); + + // Lagrange coefficients + let f = 1.0 - chi2 / r0_mag * c2; + let g = dt - chi * chi2 / sqrt_mu * c3; + + f * r0 + g * v0 +} + #[cfg(test)] mod tests { use super::*; diff --git a/frontend/src/lib/render/canvas2d/SolarSystem2D.ts b/frontend/src/lib/render/canvas2d/SolarSystem2D.ts index 2433cb2..7aedcca 100644 --- a/frontend/src/lib/render/canvas2d/SolarSystem2D.ts +++ b/frontend/src/lib/render/canvas2d/SolarSystem2D.ts @@ -21,6 +21,8 @@ export class SolarSystem2DRenderer { private stationNames: string[] = []; private showStations: boolean = true; private routeLegs: { from: number; to: number }[] = []; + private trajectoryPoints: Float64Array = new Float64Array(0); + private routeProgress: number = 0; private isDragging = false; private lastMouse = { x: 0, y: 0 }; private animFrameId: number = 0; @@ -87,8 +89,14 @@ export class SolarSystem2DRenderer { this.showStations = visible; } - updateRoute(legs: { from: number; to: number }[]) { + updateRoute( + legs: { from: number; to: number }[], + trajectoryPoints?: Float64Array, + progress?: number, + ) { this.routeLegs = legs; + if (trajectoryPoints) this.trajectoryPoints = trajectoryPoints; + if (progress !== undefined) this.routeProgress = progress; } render() { @@ -299,10 +307,79 @@ export class SolarSystem2DRenderer { private drawRoute() { const ctx = this.ctx; - const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33']; + // Draw smooth trajectory curve from sampled points + if (this.trajectoryPoints.length >= 6) { + ctx.strokeStyle = 'rgba(255, 106, 51, 0.5)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + let moved = false; + + for (let i = 0; i < this.trajectoryPoints.length; i += 3) { + const x = this.trajectoryPoints[i]; + const y = this.trajectoryPoints[i + 1]; + + if (isNaN(x) || isNaN(y)) { + // Leg separator — start new sub-path + moved = false; + continue; + } + + const [sx, sy] = this.auToScreen(x, y); + if (!moved) { + ctx.moveTo(sx, sy); + moved = true; + } else { + ctx.lineTo(sx, sy); + } + } + ctx.stroke(); + + // Draw animated package dot along trajectory + const totalPoints = Math.floor(this.trajectoryPoints.length / 3); + // Filter out NaN separators for interpolation + const validPoints: number[] = []; + for (let i = 0; i < this.trajectoryPoints.length; i += 3) { + if (!isNaN(this.trajectoryPoints[i])) { + validPoints.push(i); + } + } + + if (validPoints.length > 1) { + const idx = Math.min( + Math.floor(this.routeProgress * (validPoints.length - 1)), + validPoints.length - 1, + ); + const pIdx = validPoints[idx]; + const px = this.trajectoryPoints[pIdx]; + const py = this.trajectoryPoints[pIdx + 1]; + const [sx, sy] = this.auToScreen(px, py); + + // Package glow + const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, 12); + gradient.addColorStop(0, 'rgba(51, 255, 136, 0.8)'); + gradient.addColorStop(0.5, 'rgba(51, 255, 136, 0.2)'); + gradient.addColorStop(1, 'rgba(51, 255, 136, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(sx, sy, 12, 0, Math.PI * 2); + ctx.fill(); + + // Package core + ctx.fillStyle = '#33ff88'; + ctx.beginPath(); + ctx.arc(sx, sy, 3, 0, Math.PI * 2); + ctx.fill(); + } + } + + // Draw station-to-station connection lines (faded, for reference) + const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33']; for (let i = 0; i < this.routeLegs.length; i++) { const leg = this.routeLegs[i]; + if (leg.from * 3 + 1 >= this.stationPositions.length) continue; + if (leg.to * 3 + 1 >= this.stationPositions.length) continue; + const fromX = this.stationPositions[leg.from * 3]; const fromY = this.stationPositions[leg.from * 3 + 1]; const toX = this.stationPositions[leg.to * 3]; @@ -310,42 +387,17 @@ export class SolarSystem2DRenderer { const [sx1, sy1] = this.auToScreen(fromX, fromY); const [sx2, sy2] = this.auToScreen(toX, toY); - const color = colors[i % colors.length]; - // Draw line - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.setLineDash([6, 4]); + // Faint dashed reference line + ctx.strokeStyle = color + '40'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(sx1, sy1); ctx.lineTo(sx2, sy2); ctx.stroke(); ctx.setLineDash([]); - - // Arrow head - const angle = Math.atan2(sy2 - sy1, sx2 - sx1); - const arrowLen = 8; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.moveTo(sx2, sy2); - ctx.lineTo( - sx2 - arrowLen * Math.cos(angle - 0.3), - sy2 - arrowLen * Math.sin(angle - 0.3), - ); - ctx.lineTo( - sx2 - arrowLen * Math.cos(angle + 0.3), - sy2 - arrowLen * Math.sin(angle + 0.3), - ); - ctx.closePath(); - ctx.fill(); - - // Leg number - const mx = (sx1 + sx2) / 2; - const my = (sy1 + sy2) / 2; - ctx.fillStyle = color; - ctx.font = 'bold 10px monospace'; - ctx.fillText(`${i + 1}`, mx + 5, my - 5); } } diff --git a/frontend/src/lib/stores/routing.svelte.ts b/frontend/src/lib/stores/routing.svelte.ts index d336043..f9186f1 100644 --- a/frontend/src/lib/stores/routing.svelte.ts +++ b/frontend/src/lib/stores/routing.svelte.ts @@ -7,6 +7,11 @@ class RoutingState { isComputing = $state(false); error = $state(''); weekWindow = $state(260); // ~5 years search window + + // Route animation + trajectoryPoints = $state(new Float64Array(0)); + routeProgress = $state(0); // 0..1 along the full route + isAnimating = $state(false); } export const routing = new RoutingState(); diff --git a/frontend/src/lib/wasm/bridge.ts b/frontend/src/lib/wasm/bridge.ts index aedaa26..64abefe 100644 --- a/frontend/src/lib/wasm/bridge.ts +++ b/frontend/src/lib/wasm/bridge.ts @@ -70,6 +70,17 @@ export interface RouteResult { departure_week: number; } +export function sampleRouteTrajectory( + stationsJson: string, + routeJson: string, + jd: number, + samplesPerLeg: number = 50, +): Float64Array { + const wasm = getWasm(); + if (!wasm) return new Float64Array(0); + return wasm.sample_route_trajectory(stationsJson, routeJson, jd, samplesPerLeg); +} + export function computeRoute( stationsJson: string, fromStation: number, diff --git a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte index 136fd32..a2c904b 100644 --- a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte +++ b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte @@ -51,10 +51,20 @@ // Update route visualization if (routing.route) { - renderer.updateRoute(routing.route.legs.map(l => ({ - from: l.from_station, - to: l.to_station, - }))); + // Advance animation + if (routing.isAnimating) { + routing.routeProgress += dt * 0.05; // ~20 seconds for full route + if (routing.routeProgress > 1) routing.routeProgress = 0; + } + + renderer.updateRoute( + routing.route.legs.map(l => ({ + from: l.from_station, + to: l.to_station, + })), + routing.trajectoryPoints, + routing.routeProgress, + ); } else { renderer.updateRoute([]); } diff --git a/frontend/src/routes/(simulator)/components/RoutingPanel.svelte b/frontend/src/routes/(simulator)/components/RoutingPanel.svelte index 0aa2dad..fbd3a0d 100644 --- a/frontend/src/routes/(simulator)/components/RoutingPanel.svelte +++ b/frontend/src/routes/(simulator)/components/RoutingPanel.svelte @@ -2,7 +2,7 @@ import { simulation } from '$lib/stores/simulation.svelte'; import { stations } from '$lib/stores/stations.svelte'; import { routing } from '$lib/stores/routing.svelte'; - import { computeRoute } from '$lib/wasm/bridge'; + import { computeRoute, sampleRouteTrajectory } from '$lib/wasm/bridge'; import { formatWeeks } from '$lib/utils/format'; function handleCompute() { @@ -18,8 +18,10 @@ routing.isComputing = true; routing.error = ''; routing.route = null; + routing.trajectoryPoints = new Float64Array(0); + routing.routeProgress = 0; + routing.isAnimating = false; - // Use setTimeout to let the UI update before blocking computation setTimeout(() => { const result = computeRoute( stations.stationsJson, @@ -32,6 +34,13 @@ if (result && result.legs.length > 0) { routing.route = result; + + // Sample trajectory for visualization + const routeJson = JSON.stringify(result); + routing.trajectoryPoints = sampleRouteTrajectory( + stations.stationsJson, routeJson, simulation.currentJD, 60, + ); + routing.isAnimating = true; } else { routing.error = 'No route found. Try increasing velocity or search window.'; } @@ -153,6 +162,32 @@ {/each} + + + {#if routing.trajectoryPoints.length > 0} +
+
+ Package Progress + +
+ +
+ {Math.round(routing.routeProgress * 100)}% complete +
+
+ {/if} {/if}