From 22dcc5b6ec720fcbfc828bd77a1c245a9facd863 Mon Sep 17 00:00:00 2001 From: scott Date: Wed, 8 Apr 2026 12:14:37 -0700 Subject: [PATCH] Add Lambert solver, transfer matrix, Dijkstra routing, and route planner UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lambert's problem solver using universal variable method with bisection (handles elliptic, parabolic, hyperbolic transfers + anti-podal cases) - Transfer matrix: precompute pairwise station transfers over time window using Lambert solver with configurable launch velocity - Dijkstra routing on time-expanded graph (station × week nodes, transfer + wait edges) to find minimum-time routes - Route Planner UI: from/to station dropdowns, search window selector (1-10 years), "Find Optimal Route" button with results card - Route visualization: orange dashed trajectory lines with arrow heads and leg numbers on the 2D canvas - Tested: Mercury L1 → Jupiter L1 computes 6-month direct transfer at 30 km/s — physically reasonable Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/mass-driver-core/src/lib.rs | 4 +- crates/mass-driver-core/src/router.rs | 187 +++++++++++ .../mass-driver-core/src/transfer_matrix.rs | 150 +++++++++ crates/mass-driver-wasm/src/api.rs | 59 ++++ crates/orbital-mechanics/src/lambert.rs | 300 ++++++++++++++++++ crates/orbital-mechanics/src/lib.rs | 1 + .../src/lib/render/canvas2d/SolarSystem2D.ts | 62 ++++ frontend/src/lib/stores/routing.svelte.ts | 12 + frontend/src/lib/wasm/bridge.ts | 31 ++ .../components/Canvas2DView.svelte | 11 + .../components/RoutingPanel.svelte | 158 +++++++++ frontend/src/routes/+page.svelte | 4 +- 12 files changed, 977 insertions(+), 2 deletions(-) create mode 100644 crates/mass-driver-core/src/router.rs create mode 100644 crates/mass-driver-core/src/transfer_matrix.rs create mode 100644 crates/orbital-mechanics/src/lambert.rs create mode 100644 frontend/src/lib/stores/routing.svelte.ts create mode 100644 frontend/src/routes/(simulator)/components/RoutingPanel.svelte diff --git a/crates/mass-driver-core/src/lib.rs b/crates/mass-driver-core/src/lib.rs index a96457e..4adbee6 100644 --- a/crates/mass-driver-core/src/lib.rs +++ b/crates/mass-driver-core/src/lib.rs @@ -1,2 +1,4 @@ -pub mod station; pub mod route; +pub mod router; +pub mod station; +pub mod transfer_matrix; diff --git a/crates/mass-driver-core/src/router.rs b/crates/mass-driver-core/src/router.rs new file mode 100644 index 0000000..ec3b43a --- /dev/null +++ b/crates/mass-driver-core/src/router.rs @@ -0,0 +1,187 @@ +use crate::route::{RouteLeg, RouteResult}; +use crate::transfer_matrix::TransferMatrix; +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +/// Find the optimal route between two stations using Dijkstra on +/// the time-expanded graph. +/// +/// Nodes: (station_id, week_index) +/// Transfer edges: based on precomputed transfer matrix +/// Wait edges: (station, t) → (station, t+1) with cost 1 week +/// +/// # Arguments +/// * `matrix` - Precomputed transfer matrix +/// * `from` - Source station index +/// * `to` - Destination station index +/// * `earliest_week` - Earliest allowed departure week +/// * `latest_week` - Latest allowed departure week +/// +/// # Returns +/// The optimal route, or None if no route exists. +pub fn find_optimal_route( + matrix: &TransferMatrix, + from: usize, + to: usize, + earliest_week: usize, + latest_week: usize, +) -> Option { + let n = matrix.station_count; + let t_max = matrix.week_count; + + if from >= n || to >= n || from == to { + return None; + } + + let latest_week = latest_week.min(t_max - 1); + if earliest_week > latest_week { + return None; + } + + // Node encoding: node_id = station * t_max + week + let node_count = n * t_max; + let mut dist = vec![u32::MAX; node_count]; + let mut prev: Vec> = vec![None; node_count]; + + // Min-heap: (cost, node_id) + let mut heap: BinaryHeap> = BinaryHeap::new(); + + // Seed: all departure times from the source station + for week in earliest_week..=latest_week { + let node = from * t_max + week; + let cost = (week - earliest_week) as u32; // Waiting at origin costs time + dist[node] = cost; + heap.push(Reverse((cost, node))); + } + + // Build a lookup for transfer matrix entries by (from, departure_week) + // for faster access during Dijkstra + let mut transfer_lookup: Vec> = vec![Vec::new(); n * t_max]; + for entry in &matrix.entries { + let key = entry.from as usize * t_max + entry.departure_week as usize; + if key < transfer_lookup.len() { + transfer_lookup[key].push((entry.to, entry.travel_weeks)); + } + } + + while let Some(Reverse((cost, u))) = heap.pop() { + let u_station = u / t_max; + let u_week = u % t_max; + + // Found destination — reconstruct path + if u_station == to { + return Some(reconstruct_route(matrix, &prev, u, earliest_week, t_max)); + } + + // Skip stale entries + if cost > dist[u] { + continue; + } + + // Wait edge: stay at this station for one more week + if u_week + 1 < t_max { + let v = u + 1; // Same station, next week + let new_cost = cost + 1; + if new_cost < dist[v] { + dist[v] = new_cost; + prev[v] = Some(u); + heap.push(Reverse((new_cost, v))); + } + } + + // Transfer edges from this station at this week + let key = u_station * t_max + u_week; + if key < transfer_lookup.len() { + for &(to_station, travel_weeks) in &transfer_lookup[key] { + let arrival_week = u_week + travel_weeks as usize; + if arrival_week < t_max { + let v = to_station as usize * t_max + arrival_week; + let new_cost = cost + travel_weeks as u32; + if new_cost < dist[v] { + dist[v] = new_cost; + prev[v] = Some(u); + heap.push(Reverse((new_cost, v))); + } + } + } + } + } + + None // No route found +} + +fn reconstruct_route( + _matrix: &TransferMatrix, + prev: &[Option], + end_node: usize, + earliest_week: usize, + t_max: usize, +) -> RouteResult { + let mut path = Vec::new(); + let mut node = end_node; + + // Trace back through prev pointers + loop { + path.push(node); + match prev[node] { + Some(p) => node = p, + None => break, + } + } + + path.reverse(); + + // Convert path to route legs + let mut legs = Vec::new(); + let mut i = 0; + + while i < path.len() { + let station = path[i] / t_max; + let _week = path[i] % t_max; + + // Find next different station in path + let mut j = i + 1; + while j < path.len() && path[j] / t_max == station { + j += 1; + } + + if j < path.len() { + let next_station = path[j] / t_max; + let departure_week = path[j - 1] % t_max; // Last week at current station + let arrival_week = path[j] % t_max; + let wait_weeks = if i == 0 { + departure_week - (path[0] % t_max) + } else { + (path[j - 1] % t_max) - (path[i] % t_max) + }; + + legs.push(RouteLeg { + from_station: station, + to_station: next_station, + departure_week: departure_week as u32, + arrival_week: arrival_week as u32, + wait_weeks: wait_weeks as u32, + }); + } + + i = j; + } + + let departure_week = if !path.is_empty() { + path[0] % t_max + } else { + earliest_week + }; + + let total_time = if !path.is_empty() { + (path.last().unwrap() % t_max) - departure_week + } else { + 0 + }; + + RouteResult { + legs, + total_time_weeks: total_time as u32, + departure_week: departure_week as u32, + } +} diff --git a/crates/mass-driver-core/src/transfer_matrix.rs b/crates/mass-driver-core/src/transfer_matrix.rs new file mode 100644 index 0000000..eeb582f --- /dev/null +++ b/crates/mass-driver-core/src/transfer_matrix.rs @@ -0,0 +1,150 @@ +use crate::station::{Station, station_position}; +use orbital_mechanics::bodies; +use orbital_mechanics::constants::*; +use orbital_mechanics::lambert; +use nalgebra::Vector3; +use serde::{Deserialize, Serialize}; + +/// Precomputed transfer costs between station pairs over time. +/// +/// Stored as a sparse list of feasible transfers to save memory. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransferMatrix { + pub station_count: usize, + pub week_count: usize, + /// Start Julian Date + pub start_jd: f64, + /// Sparse entries: (from, to, departure_week, travel_weeks) + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransferEntry { + pub from: u16, + pub to: u16, + pub departure_week: u16, + pub travel_weeks: u16, +} + +/// Compute the transfer matrix for a set of stations. +/// +/// For each station pair and each weekly departure time, solve Lambert's problem +/// to find the minimum transfer time achievable at the given launch velocity. +/// +/// # Arguments +/// * `stations` - The station network +/// * `max_launch_v_kms` - Maximum launch velocity in km/s +/// * `start_jd` - Start of the time window (Julian Date) +/// * `week_count` - Number of weeks to compute +/// * `progress_callback` - Called with (completed, total) for progress reporting +pub fn compute_transfer_matrix( + stations: &[Station], + max_launch_v_kms: f64, + start_jd: f64, + week_count: usize, + mut progress_callback: impl FnMut(usize, usize), +) -> TransferMatrix { + let all_bodies = bodies::all_bodies(); + let n = stations.len(); + let total_work = n * (n - 1) * week_count; + let mut completed = 0; + + let mut entries = Vec::new(); + + // Candidate TOFs to try (in days) - logarithmic spacing from weeks to years + let candidate_tofs_days: Vec = { + let mut tofs = Vec::new(); + // From 1 week to 5 years, ~20 samples + let min_days: f64 = 7.0; + let max_days: f64 = 5.0 * 365.25; + for i in 0..20 { + let t: f64 = i as f64 / 19.0; + let days = min_days * (max_days / min_days).powf(t); + tofs.push(days); + } + tofs + }; + + for week in 0..week_count { + let jd = start_jd + (week as f64) * DAYS_PER_WEEK; + + // Precompute all station positions at this epoch + let station_positions: Vec> = stations + .iter() + .map(|s| station_position(s, &all_bodies, jd)) + .collect(); + + for from in 0..n { + for to in 0..n { + if from == to { + continue; + } + + let r1 = station_positions[from] * AU_KM; // Convert AU to km + let r2 = station_positions[to] * AU_KM; + + // Try different TOFs and find the minimum that works + let mut best_travel_weeks: Option = None; + + for &tof_days in &candidate_tofs_days { + let tof_seconds = tof_days * SECONDS_PER_DAY; + + if let Some(sol) = lambert::solve_lambert(r1, r2, tof_seconds, MU_SUN, true) { + // Check if departure delta-v is within launch capability + // For simplicity, we check if the departure velocity magnitude + // is achievable (station is co-moving with its parent body) + let v1_mag = sol.v1.norm(); + + // Station orbital velocity (approximate) + let r1_au = station_positions[from].norm(); + let v_station = if r1_au > 0.01 { + (MU_SUN / (r1_au * AU_KM)).sqrt() // km/s + } else { + 0.0 + }; + + // The departure delta-v is roughly v1 - v_station + // This is simplified — in reality we'd need the vector difference + let dv_approx = (v1_mag - v_station).abs(); + + if dv_approx <= max_launch_v_kms { + let travel_weeks = (tof_days / 7.0).ceil() as u16; + if travel_weeks > 0 && travel_weeks < 5200 { + match best_travel_weeks { + None => best_travel_weeks = Some(travel_weeks), + Some(prev) if travel_weeks < prev => { + best_travel_weeks = Some(travel_weeks); + } + _ => {} + } + } + } + } + } + + if let Some(travel_weeks) = best_travel_weeks { + entries.push(TransferEntry { + from: from as u16, + to: to as u16, + departure_week: week as u16, + travel_weeks, + }); + } + + completed += 1; + if completed % 1000 == 0 { + progress_callback(completed, total_work); + } + } + } + } + + progress_callback(total_work, total_work); + + TransferMatrix { + station_count: n, + week_count, + start_jd, + entries, + } +} diff --git a/crates/mass-driver-wasm/src/api.rs b/crates/mass-driver-wasm/src/api.rs index 725f483..43991dc 100644 --- a/crates/mass-driver-wasm/src/api.rs +++ b/crates/mass-driver-wasm/src/api.rs @@ -1,5 +1,8 @@ +use mass_driver_core::router; use mass_driver_core::station; +use mass_driver_core::transfer_matrix; use orbital_mechanics::bodies; + use orbital_mechanics::orbits; use wasm_bindgen::prelude::*; @@ -98,3 +101,59 @@ pub fn get_station_names(stations_json: &str) -> String { let names: Vec<&str> = stations.iter().map(|s| s.name.as_str()).collect(); serde_json::to_string(&names).unwrap() } + +/// Compute transfer matrix and find optimal route between two stations. +/// +/// This is the main computation function. It: +/// 1. Computes the transfer matrix for the given stations over a time window +/// 2. Runs Dijkstra to find the optimal route +/// +/// Returns JSON-serialized RouteResult, or empty string if no route found. +/// +/// # Arguments (all as JSON) +/// * `stations_json` - Station configuration +/// * `from_station` - Source station index +/// * `to_station` - Destination station index +/// * `launch_velocity_kms` - Max launch velocity in km/s +/// * `start_jd` - Start of search window (Julian Date) +/// * `week_window` - Number of weeks to search +#[wasm_bindgen] +pub fn compute_route( + stations_json: &str, + from_station: usize, + to_station: usize, + launch_velocity_kms: f64, + start_jd: f64, + week_window: usize, +) -> String { + let stations: Vec = match serde_json::from_str(stations_json) { + Ok(s) => s, + Err(_) => return String::new(), + }; + + // Compute transfer matrix (the heavy part) + let matrix = transfer_matrix::compute_transfer_matrix( + &stations, + launch_velocity_kms, + start_jd, + week_window, + |_completed, _total| { + // Progress callback — in WASM we can't easily report progress + // to the main thread without Web Workers. For now, just run. + }, + ); + + // Find optimal route + let route = router::find_optimal_route( + &matrix, + from_station, + to_station, + 0, + week_window.saturating_sub(1), + ); + + match route { + Some(r) => serde_json::to_string(&r).unwrap_or_default(), + None => String::new(), + } +} diff --git a/crates/orbital-mechanics/src/lambert.rs b/crates/orbital-mechanics/src/lambert.rs new file mode 100644 index 0000000..4737c59 --- /dev/null +++ b/crates/orbital-mechanics/src/lambert.rs @@ -0,0 +1,300 @@ +//! Lambert's problem solver using universal variable method. +//! +//! Given two position vectors r1 and r2, and a time of flight tof, +//! find the orbit connecting them. Returns departure and arrival velocities. +//! +//! Uses the algorithm from Curtis, "Orbital Mechanics for Engineering Students", +//! with bisection for robust convergence. + +use nalgebra::Vector3; + +/// Solution to Lambert's problem. +#[derive(Debug, Clone)] +pub struct LambertSolution { + /// Departure velocity (km/s) + pub v1: Vector3, + /// Arrival velocity (km/s) + pub v2: Vector3, +} + +/// Solve Lambert's problem. +/// +/// # Arguments +/// * `r1` - Initial position vector (km) +/// * `r2` - Final position vector (km) +/// * `tof` - Time of flight (seconds) +/// * `mu` - Gravitational parameter (km³/s²) +/// * `prograde` - If true, use prograde (short-way) transfer +/// +/// # Returns +/// `Some(LambertSolution)` if a solution exists, `None` otherwise. +pub fn solve_lambert( + r1: Vector3, + r2: Vector3, + tof: f64, + mu: f64, + prograde: bool, +) -> Option { + if tof <= 0.0 || mu <= 0.0 { + return None; + } + + let r1_norm = r1.norm(); + let r2_norm = r2.norm(); + + if r1_norm < 1e-6 || r2_norm < 1e-6 { + return None; + } + + // Handle near-180° transfers by adding a tiny out-of-plane perturbation + let cos_angle = r1.dot(&r2) / (r1_norm * r2_norm); + let r2 = if cos_angle < -0.99999 { + // Near anti-podal: perturb slightly to break degeneracy + Vector3::new(r2.x, r2.y, r2.z + r2_norm * 1e-6) + } else { + r2 + }; + let r2_norm = r2.norm(); + + // Change in true anomaly + let cos_dnu = r1.dot(&r2) / (r1_norm * r2_norm); + let cos_dnu = cos_dnu.clamp(-1.0, 1.0); + + let cross = r1.cross(&r2); + + let sin_dnu = if prograde { + if cross.z >= 0.0 { (1.0 - cos_dnu * cos_dnu).sqrt() } + else { -(1.0 - cos_dnu * cos_dnu).sqrt() } + } else { + if cross.z < 0.0 { (1.0 - cos_dnu * cos_dnu).sqrt() } + else { -(1.0 - cos_dnu * cos_dnu).sqrt() } + }; + + // Parameter A (from Curtis Eq. 5.35) + let a = sin_dnu * (r1_norm * r2_norm / (1.0 - cos_dnu)).sqrt(); + + if a.abs() < 1e-10 { + return None; + } + + // Function to compute TOF for a given z using Stumpff functions + let tof_from_z = |z: f64| -> Option { + let (c2, c3) = stumpff(z); + if c2.abs() < 1e-20 { + return None; + } + + let y = r1_norm + r2_norm + a * (z * c3 - 1.0) / c2.sqrt(); + if y < 0.0 { + return None; + } + + let chi = (y / c2).sqrt(); + let t = (chi * chi * chi * c3 + a * y.sqrt()) / mu.sqrt(); + Some(t) + }; + + // Find z using bisection + Newton hybrid + // For elliptic orbits: z > 0 + // For hyperbolic orbits: z < 0 + + // Find a bracket [z_low, z_high] containing the solution. + // z_low should give TOF < target, z_high should give TOF > target. + let mut z_low = -4.0 * std::f64::consts::PI * std::f64::consts::PI; + let mut z_high = 4.0 * std::f64::consts::PI * std::f64::consts::PI; + + // Ensure z_high gives TOF >= target (keep doubling if needed) + for _ in 0..50 { + match tof_from_z(z_high) { + Some(t) if t >= tof => break, + _ => z_high *= 2.0, + } + if z_high > 1e10 { return None; } + } + + // Ensure z_low gives TOF < target (keep decreasing if needed) + for _ in 0..50 { + match tof_from_z(z_low) { + Some(t) if t < tof => break, + None => break, // y < 0 means TOF undefined → z is too low, which is fine + _ => z_low -= 10.0, + } + if z_low < -1e10 { return None; } + } + + // Bisection iteration + // Higher z → larger semi-major axis → longer TOF + let mut z = 0.0; + for _ in 0..200 { + z = (z_low + z_high) / 2.0; + + let t = match tof_from_z(z) { + Some(t) => t, + None => { + // y < 0 means z is too low; increase lower bound + z_low = z; + continue; + } + }; + + if (t - tof).abs() < 1e-6 { + break; // Converged + } + + if t < tof { + z_low = z; // Need more time → increase z + } else { + z_high = z; // Need less time → decrease z + } + + if (z_high - z_low).abs() < 1e-12 { + break; + } + } + + // Compute velocities from the converged z + let (c2, c3) = stumpff(z); + if c2.abs() < 1e-20 { + return None; + } + + let y = r1_norm + r2_norm + a * (z * c3 - 1.0) / c2.sqrt(); + if y < 0.0 { + return None; + } + + // Lagrange coefficients + let f = 1.0 - y / r1_norm; + let g = a * (y / mu).sqrt(); + let g_dot = 1.0 - y / r2_norm; + + if g.abs() < 1e-20 { + return None; + } + + let v1 = (r2 - f * r1) / g; + let v2 = (g_dot * r2 - r1) / g; + + Some(LambertSolution { v1, v2 }) +} + +/// Stumpff functions c2(z) and c3(z). +fn stumpff(z: f64) -> (f64, f64) { + if z > 1e-6 { + let sz = z.sqrt(); + let c2 = (1.0 - sz.cos()) / z; + let c3 = (sz - sz.sin()) / (z * sz); + (c2, c3) + } else if z < -1e-6 { + let sz = (-z).sqrt(); + let c2 = (sz.cosh() - 1.0) / (-z); + let c3 = (sz.sinh() - sz) / ((-z) * sz); + (c2, c3) + } else { + // Taylor series near z = 0 + (1.0 / 2.0 - z / 24.0 + z * z / 720.0, + 1.0 / 6.0 - z / 120.0 + z * z / 5040.0) + } +} + +/// Compute the delta-v required for a Lambert transfer. +/// Returns (dv_departure, dv_arrival) in km/s, where dv is relative to +/// the velocity at each position. +pub fn transfer_delta_v( + r1: Vector3, + v1_body: Vector3, + r2: Vector3, + v2_body: Vector3, + tof: f64, + mu: f64, +) -> Option<(f64, f64)> { + let sol = solve_lambert(r1, r2, tof, mu, true)?; + let dv1 = (sol.v1 - v1_body).norm(); + let dv2 = (sol.v2 - v2_body).norm(); + Some((dv1, dv2)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::*; + + #[test] + fn test_hohmann_earth_mars() { + // Earth at 1 AU, Mars at 1.524 AU (opposite side) + let r1 = Vector3::new(AU_KM, 0.0, 0.0); + let r2 = Vector3::new(-1.524 * AU_KM, 0.0, 0.0); + + // Hohmann transfer time ≈ 259 days + let tof = 259.0 * SECONDS_PER_DAY; + + let result = solve_lambert(r1, r2, tof, MU_SUN, true); + assert!(result.is_some(), "Lambert solver should find a solution"); + + let sol = result.unwrap(); + let v1_mag = sol.v1.norm(); + // Departure velocity should be ~30-35 km/s (Earth orbital velocity + transfer excess) + assert!( + v1_mag > 20.0 && v1_mag < 45.0, + "Departure velocity {} km/s should be reasonable", + v1_mag + ); + } + + #[test] + fn test_short_transfer() { + // 30-degree transfer at 1 AU + let r1 = Vector3::new(AU_KM, 0.0, 0.0); + let angle = std::f64::consts::FRAC_PI_6; + let r2 = Vector3::new(AU_KM * angle.cos(), AU_KM * angle.sin(), 0.0); + + let tof = 50.0 * SECONDS_PER_DAY; + + let result = solve_lambert(r1, r2, tof, MU_SUN, true); + assert!(result.is_some(), "Should solve short transfer"); + + let sol = result.unwrap(); + let v1_mag = sol.v1.norm(); + assert!( + v1_mag > 10.0 && v1_mag < 60.0, + "Velocity {} km/s should be reasonable", + v1_mag + ); + } + + #[test] + fn test_circular_orbit_consistency() { + // If we solve Lambert for two points on a circular orbit with + // the correct TOF, the departure velocity should match orbital velocity + let r = AU_KM; // 1 AU + let v_circ = (MU_SUN / r).sqrt(); // ~29.8 km/s + + let angle = std::f64::consts::FRAC_PI_4; // 45 degrees + let r1 = Vector3::new(r, 0.0, 0.0); + let r2 = Vector3::new(r * angle.cos(), r * angle.sin(), 0.0); + + // Time for 45° of circular orbit + let period = 2.0 * std::f64::consts::PI * (r.powi(3) / MU_SUN).sqrt(); + let tof = period * 45.0 / 360.0; + + let result = solve_lambert(r1, r2, tof, MU_SUN, true); + assert!(result.is_some(), "Should solve circular orbit transfer"); + + let sol = result.unwrap(); + let v1_mag = sol.v1.norm(); + // Should be close to circular velocity + assert!( + (v1_mag - v_circ).abs() / v_circ < 0.05, + "Departure velocity {} km/s should match circular velocity {} km/s", + v1_mag, v_circ + ); + } + + #[test] + fn test_degenerate_zero_tof() { + let r1 = Vector3::new(AU_KM, 0.0, 0.0); + let r2 = Vector3::new(0.0, AU_KM, 0.0); + let result = solve_lambert(r1, r2, 0.0, MU_SUN, true); + assert!(result.is_none(), "Zero TOF should return None"); + } +} diff --git a/crates/orbital-mechanics/src/lib.rs b/crates/orbital-mechanics/src/lib.rs index c954742..8295d37 100644 --- a/crates/orbital-mechanics/src/lib.rs +++ b/crates/orbital-mechanics/src/lib.rs @@ -2,6 +2,7 @@ pub mod bodies; pub mod constants; pub mod kepler; pub mod lagrange; +pub mod lambert; pub mod orbits; pub use nalgebra::Vector3; diff --git a/frontend/src/lib/render/canvas2d/SolarSystem2D.ts b/frontend/src/lib/render/canvas2d/SolarSystem2D.ts index 5549dd4..2433cb2 100644 --- a/frontend/src/lib/render/canvas2d/SolarSystem2D.ts +++ b/frontend/src/lib/render/canvas2d/SolarSystem2D.ts @@ -20,6 +20,7 @@ export class SolarSystem2DRenderer { private stationPositions: Float64Array = new Float64Array(0); private stationNames: string[] = []; private showStations: boolean = true; + private routeLegs: { from: number; to: number }[] = []; private isDragging = false; private lastMouse = { x: 0, y: 0 }; private animFrameId: number = 0; @@ -86,6 +87,10 @@ export class SolarSystem2DRenderer { this.showStations = visible; } + updateRoute(legs: { from: number; to: number }[]) { + this.routeLegs = legs; + } + render() { const { canvas, ctx } = this; const dpr = window.devicePixelRatio || 1; @@ -133,6 +138,11 @@ export class SolarSystem2DRenderer { } } + // Draw route legs + if (this.routeLegs.length > 0 && this.stationPositions.length > 0) { + this.drawRoute(); + } + // Scale indicator this.drawScaleBar(rect.width, rect.height); } @@ -287,6 +297,58 @@ export class SolarSystem2DRenderer { ctx.fillText(info.name, sx + size + 4, sy + 3); } + private drawRoute() { + const ctx = this.ctx; + const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33']; + + for (let i = 0; i < this.routeLegs.length; i++) { + const leg = this.routeLegs[i]; + const fromX = this.stationPositions[leg.from * 3]; + const fromY = this.stationPositions[leg.from * 3 + 1]; + const toX = this.stationPositions[leg.to * 3]; + const toY = this.stationPositions[leg.to * 3 + 1]; + + 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]); + 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); + } + } + private drawStation(xAU: number, yAU: number, name: string) { const ctx = this.ctx; const [sx, sy] = this.auToScreen(xAU, yAU); diff --git a/frontend/src/lib/stores/routing.svelte.ts b/frontend/src/lib/stores/routing.svelte.ts new file mode 100644 index 0000000..d336043 --- /dev/null +++ b/frontend/src/lib/stores/routing.svelte.ts @@ -0,0 +1,12 @@ +import type { RouteResult } from '$lib/wasm/bridge'; + +class RoutingState { + fromStation = $state(null); + toStation = $state(null); + route = $state(null); + isComputing = $state(false); + error = $state(''); + weekWindow = $state(260); // ~5 years search window +} + +export const routing = new RoutingState(); diff --git a/frontend/src/lib/wasm/bridge.ts b/frontend/src/lib/wasm/bridge.ts index 05febcd..aedaa26 100644 --- a/frontend/src/lib/wasm/bridge.ts +++ b/frontend/src/lib/wasm/bridge.ts @@ -57,3 +57,34 @@ export function getStationNames(stationsJson: string): string[] { if (!wasm) return []; return JSON.parse(wasm.get_station_names(stationsJson)); } + +export interface RouteResult { + legs: { + from_station: number; + to_station: number; + departure_week: number; + arrival_week: number; + wait_weeks: number; + }[]; + total_time_weeks: number; + departure_week: number; +} + +export function computeRoute( + stationsJson: string, + fromStation: number, + toStation: number, + launchVelocityKms: number, + startJd: number, + weekWindow: number, +): RouteResult | null { + const wasm = getWasm(); + if (!wasm) return null; + const json = wasm.compute_route(stationsJson, fromStation, toStation, launchVelocityKms, startJd, weekWindow); + if (!json) return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} diff --git a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte index 9b4999a..136fd32 100644 --- a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte +++ b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte @@ -3,6 +3,7 @@ import { SolarSystem2DRenderer } from '$lib/render/canvas2d/SolarSystem2D'; import { simulation } from '$lib/stores/simulation.svelte'; import { stations } from '$lib/stores/stations.svelte'; + import { routing } from '$lib/stores/routing.svelte'; import { getBodyPositions, getBodyVelocities, getBodyInfos, getOrbitPoints, getStationPositions } from '$lib/wasm/bridge'; let canvas: HTMLCanvasElement; @@ -48,6 +49,16 @@ renderer.updateStations(stations.stationPositions, stations.stationNames, stations.visible); } + // Update route visualization + if (routing.route) { + renderer.updateRoute(routing.route.legs.map(l => ({ + from: l.from_station, + to: l.to_station, + }))); + } else { + renderer.updateRoute([]); + } + if (!orbitsComputed || frameCount % 600 === 0) { computeOrbits(); } diff --git a/frontend/src/routes/(simulator)/components/RoutingPanel.svelte b/frontend/src/routes/(simulator)/components/RoutingPanel.svelte new file mode 100644 index 0000000..0aa2dad --- /dev/null +++ b/frontend/src/routes/(simulator)/components/RoutingPanel.svelte @@ -0,0 +1,158 @@ + + +
+

+ Route Planner +

+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each windowOptions as opt} + + {/each} +
+
+ + + + + + {#if routing.error} +

{routing.error}

+ {/if} + + + {#if routing.route} +
+
+ Route Found + + {formatWeeks(routing.route.total_time_weeks)} + +
+ +
+ Departs week {routing.route.departure_week} · {routing.route.legs.length} leg{routing.route.legs.length !== 1 ? 's' : ''} +
+ + +
+ {#each routing.route.legs as leg, i} +
+ + {i + 1} + +
+
+ {stations.stationNames[leg.from_station]} → {stations.stationNames[leg.to_station]} +
+
+ {formatWeeks(leg.arrival_week - leg.departure_week)} transit + {#if leg.wait_weeks > 0} + · {formatWeeks(leg.wait_weeks)} wait + {/if} +
+
+
+ {/each} +
+
+ {/if} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6f35278..e91ffe1 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -7,6 +7,7 @@ import TimeControls from './(simulator)/components/TimeControls.svelte'; import ViewToggle from './(simulator)/components/ViewToggle.svelte'; import StationPanel from './(simulator)/components/StationPanel.svelte'; + import RoutingPanel from './(simulator)/components/RoutingPanel.svelte'; let wasmError = $state(''); @@ -58,8 +59,9 @@ -