diff --git a/crates/mass-driver-core/src/station.rs b/crates/mass-driver-core/src/station.rs index 37e6f65..c2293b7 100644 --- a/crates/mass-driver-core/src/station.rs +++ b/crates/mass-driver-core/src/station.rs @@ -1,3 +1,8 @@ +use orbital_mechanics::bodies; +use orbital_mechanics::bodies::CelestialBody; +use orbital_mechanics::lagrange; +use orbital_mechanics::orbits; +use nalgebra::Vector3; use serde::{Deserialize, Serialize}; /// How a station is placed in the solar system. @@ -12,29 +17,13 @@ pub enum StationPlacement { w: f64, // Argument of perihelion (degrees) m0: f64, // Mean anomaly at epoch (degrees) }, - /// Station in orbit around a planet - PlanetaryOrbit { - parent_body_id: usize, - altitude_km: f64, - inclination: f64, - }, /// Station at a Lagrange point LagrangePoint { - primary_body_id: usize, // e.g., Sun - secondary_body_id: usize, // e.g., Earth - point: LagrangePointId, + planet_id: usize, + point: u8, // 1-5 }, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum LagrangePointId { - L1, - L2, - L3, - L4, - L5, -} - /// A mass driver relay station. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Station { @@ -42,3 +31,116 @@ pub struct Station { pub name: String, pub placement: StationPlacement, } + +/// Simulation configuration for mass driver network. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimConfig { + pub stations: Vec, + pub launch_velocity_kms: f64, // km/s +} + +/// Compute position of a station at a given Julian Date. +pub fn station_position( + station: &Station, + all_bodies: &[CelestialBody], + jd: f64, +) -> Vector3 { + match &station.placement { + StationPlacement::SolarOrbit { a, e, i, omega, w, m0 } => { + // Treat station as a body with fixed elements + let body = CelestialBody { + name: "station", + id: 999, + mu: 0.0, + radius_km: 0.0, + elements: orbital_mechanics::bodies::KeplerianElements { + a: *a, + e: *e, + i: *i, + l: *m0 + *w + *omega, // mean longitude = M + w + Omega + w_bar: *w + *omega, // longitude of perihelion + omega: *omega, + }, + rates: orbital_mechanics::bodies::KeplerianRates { + a: 0.0, e: 0.0, i: 0.0, + // Mean longitude rate from Kepler's third law: n = sqrt(mu/a^3) in deg/century + l: (orbital_mechanics::constants::MU_SUN + / (*a * orbital_mechanics::constants::AU_KM).powi(3)) + .sqrt() + * orbital_mechanics::constants::SECONDS_PER_DAY + * orbital_mechanics::constants::DAYS_PER_CENTURY + * (180.0 / std::f64::consts::PI), + w_bar: 0.0, + omega: 0.0, + }, + color: [255, 255, 255], + }; + // Use a temporary slice with just Sun + this station + let temp = vec![all_bodies[0].clone(), body]; + orbits::position_at_epoch(&temp, 1, jd) + } + StationPlacement::LagrangePoint { planet_id, point } => { + lagrange::lagrange_point_position(all_bodies, *planet_id, *point, jd) + } + } +} + +/// Compute positions of all stations. Returns flat [x,y,z, x,y,z, ...] in AU. +pub fn all_station_positions( + stations: &[Station], + all_bodies: &[CelestialBody], + jd: f64, +) -> Vec { + let mut result = Vec::with_capacity(stations.len() * 3); + for station in stations { + let pos = station_position(station, all_bodies, jd); + result.push(pos.x); + result.push(pos.y); + result.push(pos.z); + } + result +} + +/// Generate a default set of stations for a given target count. +/// Places stations at key Lagrange points of the major planets. +pub fn generate_default_stations(target_count: usize) -> Vec { + let mut stations = Vec::new(); + let mut id = 0; + + // Priority order: inner planets first, then outer + let planet_priority = [ + (bodies::id::EARTH, "Earth"), + (bodies::id::MARS, "Mars"), + (bodies::id::VENUS, "Venus"), + (bodies::id::JUPITER, "Jupiter"), + (bodies::id::MERCURY, "Mercury"), + (bodies::id::SATURN, "Saturn"), + (bodies::id::NEPTUNE, "Neptune"), + (bodies::id::URANUS, "Uranus"), + (bodies::id::PLUTO, "Pluto"), + (bodies::id::CERES, "Ceres"), + ]; + + // Lagrange points to place at each planet (in priority order) + let lp_priority: &[u8] = &[1, 2, 4, 5, 3]; + + for &lp in lp_priority { + for &(planet_id, planet_name) in &planet_priority { + if stations.len() >= target_count { + return stations; + } + stations.push(Station { + id, + name: format!("{} L{}", planet_name, lp), + placement: StationPlacement::LagrangePoint { + planet_id, + point: lp, + }, + }); + id += 1; + } + } + + stations.truncate(target_count); + stations +} diff --git a/crates/mass-driver-wasm/src/api.rs b/crates/mass-driver-wasm/src/api.rs index a20ad0b..725f483 100644 --- a/crates/mass-driver-wasm/src/api.rs +++ b/crates/mass-driver-wasm/src/api.rs @@ -1,3 +1,4 @@ +use mass_driver_core::station; use orbital_mechanics::bodies; use orbital_mechanics::orbits; use wasm_bindgen::prelude::*; @@ -66,3 +67,34 @@ pub fn get_orbit_points(body_id: usize, jd: f64, samples: usize) -> Vec { } orbits::orbit_points(&all, body_id, jd, samples) } + +/// Generate default stations and return their configuration as JSON. +#[wasm_bindgen] +pub fn generate_stations(count: usize) -> String { + let stations = station::generate_default_stations(count); + serde_json::to_string(&stations).unwrap() +} + +/// Get station positions at a given Julian Date. +/// Takes a JSON config string (from generate_stations) and returns +/// Float64Array of [x0, y0, z0, x1, y1, z1, ...] in AU. +#[wasm_bindgen] +pub fn get_station_positions(stations_json: &str, jd: f64) -> Vec { + let stations: Vec = match serde_json::from_str(stations_json) { + Ok(s) => s, + Err(_) => return vec![], + }; + let all_bodies = bodies::all_bodies(); + station::all_station_positions(&stations, &all_bodies, jd) +} + +/// Get station names from a config JSON string. +#[wasm_bindgen] +pub fn get_station_names(stations_json: &str) -> String { + let stations: Vec = match serde_json::from_str(stations_json) { + Ok(s) => s, + Err(_) => return "[]".to_string(), + }; + let names: Vec<&str> = stations.iter().map(|s| s.name.as_str()).collect(); + serde_json::to_string(&names).unwrap() +} diff --git a/crates/orbital-mechanics/src/lagrange.rs b/crates/orbital-mechanics/src/lagrange.rs new file mode 100644 index 0000000..52fe7a1 --- /dev/null +++ b/crates/orbital-mechanics/src/lagrange.rs @@ -0,0 +1,105 @@ +use crate::bodies::CelestialBody; +use crate::constants::*; +use crate::orbits::position_at_epoch; +use nalgebra::Vector3; + +/// Approximate position of a Lagrange point for a Sun-planet system. +/// +/// L1: Between Sun and planet (closer to planet) +/// L2: Beyond planet (away from Sun) +/// L3: Opposite side of Sun from planet +/// L4: 60° ahead of planet in its orbit +/// L5: 60° behind of planet in its orbit +/// +/// Returns position in AU, ecliptic coordinates. +pub fn lagrange_point_position( + bodies: &[CelestialBody], + planet_id: usize, + point: u8, // 1-5 + jd: f64, +) -> Vector3 { + let planet_pos = position_at_epoch(bodies, planet_id, jd); + let sun_pos = Vector3::zeros(); + + let r = planet_pos.norm(); // Distance from Sun + if r < 1e-10 { + return sun_pos; + } + + let planet = &bodies[planet_id]; + // Mass ratio: mu = m_planet / (m_sun + m_planet) + let mu = planet.mu / (MU_SUN + planet.mu); + + // Hill sphere radius approximation + let r_hill = r * (mu / 3.0).powf(1.0 / 3.0); + + // Unit vector from Sun to planet + let u = planet_pos / r; + // Perpendicular in ecliptic plane (rotate 90° CCW) + let u_perp = Vector3::new(-u.y, u.x, 0.0); + + match point { + 1 => { + // L1: between Sun and planet, distance r_hill inside planet orbit + planet_pos - u * r_hill + } + 2 => { + // L2: beyond planet, distance r_hill outside planet orbit + planet_pos + u * r_hill + } + 3 => { + // L3: opposite side of Sun, approximately at planet's orbital radius + -planet_pos * (1.0 + 5.0 * mu / 12.0) + } + 4 => { + // L4: 60° ahead of planet (leading) + let angle = std::f64::consts::FRAC_PI_3; // 60° + let cos_a = angle.cos(); + let sin_a = angle.sin(); + (u * cos_a + u_perp * sin_a) * r + } + 5 => { + // L5: 60° behind planet (trailing) + let angle = std::f64::consts::FRAC_PI_3; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + (u * cos_a - u_perp * sin_a) * r + } + _ => sun_pos, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bodies::all_bodies; + + #[test] + fn test_l1_between_sun_and_planet() { + let bodies = all_bodies(); + let earth_pos = position_at_epoch(&bodies, bodies::id::EARTH, J2000_JD); + let l1 = lagrange_point_position(&bodies, bodies::id::EARTH, 1, J2000_JD); + + // L1 should be between Sun (0,0,0) and Earth + let l1_dist = l1.norm(); + let earth_dist = earth_pos.norm(); + assert!(l1_dist < earth_dist, "L1 should be closer to Sun than Earth"); + assert!(l1_dist > 0.9, "L1 should be close to 1 AU"); + } + + #[test] + fn test_l4_l5_equilateral() { + let bodies = all_bodies(); + let earth_pos = position_at_epoch(&bodies, bodies::id::EARTH, J2000_JD); + let l4 = lagrange_point_position(&bodies, bodies::id::EARTH, 4, J2000_JD); + let l5 = lagrange_point_position(&bodies, bodies::id::EARTH, 5, J2000_JD); + + let earth_r = earth_pos.norm(); + let l4_r = l4.norm(); + let l5_r = l5.norm(); + + // L4 and L5 should be at roughly the same distance as the planet + assert!((l4_r - earth_r).abs() < 0.01); + assert!((l5_r - earth_r).abs() < 0.01); + } +} diff --git a/crates/orbital-mechanics/src/lib.rs b/crates/orbital-mechanics/src/lib.rs index 4647b08..c954742 100644 --- a/crates/orbital-mechanics/src/lib.rs +++ b/crates/orbital-mechanics/src/lib.rs @@ -1,6 +1,7 @@ pub mod bodies; pub mod constants; pub mod kepler; +pub mod lagrange; 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 2cf8532..5549dd4 100644 --- a/frontend/src/lib/render/canvas2d/SolarSystem2D.ts +++ b/frontend/src/lib/render/canvas2d/SolarSystem2D.ts @@ -17,6 +17,9 @@ export class SolarSystem2DRenderer { private positions: Float64Array = new Float64Array(0); private velocities: Float64Array = new Float64Array(0); private orbitPoints: Map = new Map(); + private stationPositions: Float64Array = new Float64Array(0); + private stationNames: string[] = []; + private showStations: boolean = true; private isDragging = false; private lastMouse = { x: 0, y: 0 }; private animFrameId: number = 0; @@ -77,6 +80,12 @@ export class SolarSystem2DRenderer { this.orbitPoints.set(bodyId, points); } + updateStations(positions: Float64Array, names: string[], visible: boolean) { + this.stationPositions = positions; + this.stationNames = names; + this.showStations = visible; + } + render() { const { canvas, ctx } = this; const dpr = window.devicePixelRatio || 1; @@ -115,6 +124,15 @@ export class SolarSystem2DRenderer { } } + // Draw stations + if (this.showStations && this.stationPositions.length > 0) { + for (let i = 0; i < this.stationNames.length; i++) { + const x = this.stationPositions[i * 3]; + const y = this.stationPositions[i * 3 + 1]; + this.drawStation(x, y, this.stationNames[i]); + } + } + // Scale indicator this.drawScaleBar(rect.width, rect.height); } @@ -269,6 +287,32 @@ export class SolarSystem2DRenderer { ctx.fillText(info.name, sx + size + 4, sy + 3); } + private drawStation(xAU: number, yAU: number, name: string) { + const ctx = this.ctx; + const [sx, sy] = this.auToScreen(xAU, yAU); + + // Diamond shape + const s = 3; + ctx.fillStyle = 'rgba(74, 158, 255, 0.6)'; + ctx.strokeStyle = 'rgba(74, 158, 255, 0.8)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(sx, sy - s); + ctx.lineTo(sx + s, sy); + ctx.lineTo(sx, sy + s); + ctx.lineTo(sx - s, sy); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Label (only show when zoomed in enough) + if (this.getScale() > 50) { + ctx.fillStyle = 'rgba(74, 158, 255, 0.5)'; + ctx.font = '8px monospace'; + ctx.fillText(name, sx + s + 3, sy + 3); + } + } + private drawScaleBar(w: number, h: number) { const ctx = this.ctx; const scale = this.getScale(); diff --git a/frontend/src/lib/stores/stations.svelte.ts b/frontend/src/lib/stores/stations.svelte.ts new file mode 100644 index 0000000..118516a --- /dev/null +++ b/frontend/src/lib/stores/stations.svelte.ts @@ -0,0 +1,10 @@ +class StationsState { + stationCount = $state(20); + launchVelocityKms = $state(30); // km/s — sci-fi default + stationsJson = $state('[]'); + stationNames = $state([]); + stationPositions = $state(new Float64Array(0)); + visible = $state(true); +} + +export const stations = new StationsState(); diff --git a/frontend/src/lib/wasm/bridge.ts b/frontend/src/lib/wasm/bridge.ts index efc60cf..05febcd 100644 --- a/frontend/src/lib/wasm/bridge.ts +++ b/frontend/src/lib/wasm/bridge.ts @@ -39,3 +39,21 @@ export function getOrbitPoints(bodyId: number, jd: number, samples: number = 180 if (!wasm) return new Float64Array(0); return wasm.get_orbit_points(bodyId, jd, samples); } + +export function generateStations(count: number): string { + const wasm = getWasm(); + if (!wasm) return '[]'; + return wasm.generate_stations(count); +} + +export function getStationPositions(stationsJson: string, jd: number): Float64Array { + const wasm = getWasm(); + if (!wasm) return new Float64Array(0); + return wasm.get_station_positions(stationsJson, jd); +} + +export function getStationNames(stationsJson: string): string[] { + const wasm = getWasm(); + if (!wasm) return []; + return JSON.parse(wasm.get_station_names(stationsJson)); +} diff --git a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte index bbd736a..9b4999a 100644 --- a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte +++ b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte @@ -2,7 +2,8 @@ import { onMount, onDestroy } from 'svelte'; import { SolarSystem2DRenderer } from '$lib/render/canvas2d/SolarSystem2D'; import { simulation } from '$lib/stores/simulation.svelte'; - import { getBodyPositions, getBodyVelocities, getBodyInfos, getOrbitPoints } from '$lib/wasm/bridge'; + import { stations } from '$lib/stores/stations.svelte'; + import { getBodyPositions, getBodyVelocities, getBodyInfos, getOrbitPoints, getStationPositions } from '$lib/wasm/bridge'; let canvas: HTMLCanvasElement; let renderer: SolarSystem2DRenderer; @@ -41,7 +42,12 @@ const velocities = getBodyVelocities(simulation.currentJD); renderer.updateBodies(simulation.bodyInfos, simulation.bodyPositions, velocities); - // Compute orbits on first frame and refresh every 600 frames (~10s) + // Update station positions + if (stations.stationsJson !== '[]') { + stations.stationPositions = getStationPositions(stations.stationsJson, simulation.currentJD); + renderer.updateStations(stations.stationPositions, stations.stationNames, stations.visible); + } + if (!orbitsComputed || frameCount % 600 === 0) { computeOrbits(); } diff --git a/frontend/src/routes/(simulator)/components/StationPanel.svelte b/frontend/src/routes/(simulator)/components/StationPanel.svelte new file mode 100644 index 0000000..5f41e0e --- /dev/null +++ b/frontend/src/routes/(simulator)/components/StationPanel.svelte @@ -0,0 +1,99 @@ + + +
+
+

+ Relay Stations +

+ +
+ + +
+
+ Count + {stations.stationCount} +
+ +
+ + +
+
+ + Launch Velocity + i + + {stations.launchVelocityKms} km/s +
+ +
+ 5 + 50 + 100 +
+
+ + + {#if stations.stationNames.length > 0} +
+
Active stations:
+
+ {#each stations.stationNames as name} + + {name} + + {/each} +
+
+ {/if} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 766dc70..6f35278 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -6,6 +6,7 @@ import Viewport from './(simulator)/components/Viewport.svelte'; import TimeControls from './(simulator)/components/TimeControls.svelte'; import ViewToggle from './(simulator)/components/ViewToggle.svelte'; + import StationPanel from './(simulator)/components/StationPanel.svelte'; let wasmError = $state(''); @@ -14,7 +15,6 @@ await initWasm(); simulation.bodyInfos = getBodyInfos(); simulation.wasmReady = true; - // Start in Jan 2025 for a recognizable view simulation.setDate(2025, 1, 1); simulation.isPlaying = true; } catch (e) { @@ -37,23 +37,30 @@ -
+
{#if wasmError} -
+

Failed to load simulation engine

{wasmError}

{:else if !simulation.wasmReady} -
+

Initializing simulation engine...

{:else} - + +
+ +
+ + {/if}