From 067ef1f5571d0c46bc263a7b013f5f08274ce6b3 Mon Sep 17 00:00:00 2001 From: scott Date: Wed, 8 Apr 2026 11:54:31 -0700 Subject: [PATCH] Add 3D solar system view with Threlte and velocity tails in 2D - 3D scene: Threlte/Three.js with starfield, orbit lines, planet meshes, sun glow, OrbitControls (orbit/zoom/pan), point lighting - 2D velocity tails: gradient lines showing planet velocity direction/magnitude - New WASM API: get_body_velocities_at_epoch(), get_orbit_points() - Orbit ellipses computed in Rust, rendered in both 2D and 3D views - Ecliptic-to-Three.js coordinate mapping (Y-up convention) - View toggle now switches between working 2D and 3D renderers Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/mass-driver-wasm/src/api.rs | 9 ++ crates/orbital-mechanics/src/orbits.rs | 21 +++ .../src/lib/render/canvas2d/SolarSystem2D.ts | 34 ++++- frontend/src/lib/wasm/bridge.ts | 6 + .../components/Canvas2DView.svelte | 5 +- .../components/SolarBodiesInner.svelte | 131 ++++++++++++++++++ .../(simulator)/components/ThreeScene.svelte | 55 ++++++++ .../(simulator)/components/Viewport.svelte | 7 +- 8 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 frontend/src/routes/(simulator)/components/SolarBodiesInner.svelte create mode 100644 frontend/src/routes/(simulator)/components/ThreeScene.svelte diff --git a/crates/mass-driver-wasm/src/api.rs b/crates/mass-driver-wasm/src/api.rs index e7a7b18..a20ad0b 100644 --- a/crates/mass-driver-wasm/src/api.rs +++ b/crates/mass-driver-wasm/src/api.rs @@ -46,6 +46,15 @@ pub fn get_body_radii() -> Vec { all.iter().map(|b| b.radius_km).collect() } +/// Get velocities of all celestial bodies at a given Julian Date. +/// +/// Returns a Float64Array of [vx0, vy0, vz0, vx1, vy1, vz1, ...] in AU/day. +#[wasm_bindgen] +pub fn get_body_velocities_at_epoch(jd: f64) -> Vec { + let all = bodies::all_bodies(); + orbits::all_velocities_at_epoch(&all, jd) +} + /// Get orbit points for a given body, sampled around its full orbit. /// Returns a Float64Array of [x0, y0, z0, x1, y1, z1, ...] in AU. /// `samples` is the number of points around the orbit. diff --git a/crates/orbital-mechanics/src/orbits.rs b/crates/orbital-mechanics/src/orbits.rs index 2b23b73..ba9456b 100644 --- a/crates/orbital-mechanics/src/orbits.rs +++ b/crates/orbital-mechanics/src/orbits.rs @@ -75,6 +75,27 @@ pub fn position_at_epoch(bodies: &[CelestialBody], body_id: usize, jd: f64) -> V } } +/// Compute velocity of a body via finite differencing (AU/day). +pub fn velocity_at_epoch(bodies: &[CelestialBody], body_id: usize, jd: f64) -> Vector3 { + let dt = 0.01; // days + let p1 = position_at_epoch(bodies, body_id, jd - dt); + let p2 = position_at_epoch(bodies, body_id, jd + dt); + (p2 - p1) / (2.0 * dt) +} + +/// Compute velocities of all bodies at a given epoch. +/// Returns a flat Vec of [vx, vy, vz, ...] in AU/day. +pub fn all_velocities_at_epoch(bodies: &[CelestialBody], jd: f64) -> Vec { + let mut result = Vec::with_capacity(bodies.len() * 3); + for i in 0..bodies.len() { + let vel = velocity_at_epoch(bodies, i, jd); + result.push(vel.x); + result.push(vel.y); + result.push(vel.z); + } + result +} + /// Compute positions of all bodies at a given epoch. /// Returns a flat Vec of [x, y, z, x, y, z, ...] in AU. pub fn all_positions_at_epoch(bodies: &[CelestialBody], jd: f64) -> Vec { diff --git a/frontend/src/lib/render/canvas2d/SolarSystem2D.ts b/frontend/src/lib/render/canvas2d/SolarSystem2D.ts index 6aed18b..2cf8532 100644 --- a/frontend/src/lib/render/canvas2d/SolarSystem2D.ts +++ b/frontend/src/lib/render/canvas2d/SolarSystem2D.ts @@ -15,6 +15,7 @@ export class SolarSystem2DRenderer { private camera: Camera2D = { x: 0, y: 0, zoom: 1 }; private bodyInfos: BodyInfo[] = []; private positions: Float64Array = new Float64Array(0); + private velocities: Float64Array = new Float64Array(0); private orbitPoints: Map = new Map(); private isDragging = false; private lastMouse = { x: 0, y: 0 }; @@ -66,9 +67,10 @@ export class SolarSystem2DRenderer { ]; } - updateBodies(infos: BodyInfo[], positions: Float64Array) { + updateBodies(infos: BodyInfo[], positions: Float64Array, velocities?: Float64Array) { this.bodyInfos = infos; this.positions = positions; + if (velocities) this.velocities = velocities; } updateOrbit(bodyId: number, points: Float64Array) { @@ -108,7 +110,7 @@ export class SolarSystem2DRenderer { if (i === 0) { this.drawSun(x, y, info); } else { - this.drawBody(x, y, info); + this.drawBody(i, x, y, info); } } } @@ -213,7 +215,7 @@ export class SolarSystem2DRenderer { ctx.fillText(info.name, sx + 10, sy + 4); } - private drawBody(xAU: number, yAU: number, info: BodyInfo) { + private drawBody(bodyIndex: number, xAU: number, yAU: number, info: BodyInfo) { const ctx = this.ctx; const [sx, sy] = this.auToScreen(xAU, yAU); const [r, g, b] = info.color; @@ -223,6 +225,32 @@ export class SolarSystem2DRenderer { // Moons are smaller if (info.radius_km < 3000) size = 1.5; + // Velocity tail + if (this.velocities.length > bodyIndex * 3 + 2) { + const vx = this.velocities[bodyIndex * 3]; + const vy = this.velocities[bodyIndex * 3 + 1]; + const speed = Math.sqrt(vx * vx + vy * vy); + if (speed > 1e-10) { + // Scale tail length: normalize velocity and multiply by a visual factor + const tailScale = this.getScale() * 0.15; // pixels per (AU/day) + const tx = sx + vx * tailScale; + const ty = sy - vy * tailScale; // flip Y + + // Gradient tail from body color to transparent + const gradient = ctx.createLinearGradient(sx, sy, tx, ty); + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.6)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); + + ctx.strokeStyle = gradient; + ctx.lineWidth = size * 0.8; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(tx, ty); + ctx.stroke(); + } + } + // Body dot ctx.fillStyle = rgbToCss(r, g, b); ctx.beginPath(); diff --git a/frontend/src/lib/wasm/bridge.ts b/frontend/src/lib/wasm/bridge.ts index 3066122..efc60cf 100644 --- a/frontend/src/lib/wasm/bridge.ts +++ b/frontend/src/lib/wasm/bridge.ts @@ -28,6 +28,12 @@ export function getBodyInfos(): BodyInfo[] { })); } +export function getBodyVelocities(jd: number): Float64Array { + const wasm = getWasm(); + if (!wasm) return new Float64Array(0); + return wasm.get_body_velocities_at_epoch(jd); +} + export function getOrbitPoints(bodyId: number, jd: number, samples: number = 180): Float64Array { const wasm = getWasm(); if (!wasm) return new Float64Array(0); diff --git a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte index 1f13740..bbd736a 100644 --- a/frontend/src/routes/(simulator)/components/Canvas2DView.svelte +++ b/frontend/src/routes/(simulator)/components/Canvas2DView.svelte @@ -2,7 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { SolarSystem2DRenderer } from '$lib/render/canvas2d/SolarSystem2D'; import { simulation } from '$lib/stores/simulation.svelte'; - import { getBodyPositions, getBodyInfos, getOrbitPoints } from '$lib/wasm/bridge'; + import { getBodyPositions, getBodyVelocities, getBodyInfos, getOrbitPoints } from '$lib/wasm/bridge'; let canvas: HTMLCanvasElement; let renderer: SolarSystem2DRenderer; @@ -38,7 +38,8 @@ if (simulation.wasmReady) { simulation.bodyPositions = getBodyPositions(simulation.currentJD); - renderer.updateBodies(simulation.bodyInfos, simulation.bodyPositions); + const velocities = getBodyVelocities(simulation.currentJD); + renderer.updateBodies(simulation.bodyInfos, simulation.bodyPositions, velocities); // Compute orbits on first frame and refresh every 600 frames (~10s) if (!orbitsComputed || frameCount % 600 === 0) { diff --git a/frontend/src/routes/(simulator)/components/SolarBodiesInner.svelte b/frontend/src/routes/(simulator)/components/SolarBodiesInner.svelte new file mode 100644 index 0000000..51e25e7 --- /dev/null +++ b/frontend/src/routes/(simulator)/components/SolarBodiesInner.svelte @@ -0,0 +1,131 @@ + + + +{#each orbitLines as orbit} + {@const geo = new THREE.BufferGeometry().setFromPoints(orbit.points)} + + + + +{/each} + + +{#each bodyData as body} + {#if body.isSun} + + + + + + + + + + {:else} + + + + + {/if} +{/each} diff --git a/frontend/src/routes/(simulator)/components/ThreeScene.svelte b/frontend/src/routes/(simulator)/components/ThreeScene.svelte new file mode 100644 index 0000000..6ee2a73 --- /dev/null +++ b/frontend/src/routes/(simulator)/components/ThreeScene.svelte @@ -0,0 +1,55 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + +
diff --git a/frontend/src/routes/(simulator)/components/Viewport.svelte b/frontend/src/routes/(simulator)/components/Viewport.svelte index cb48ddd..72a5f08 100644 --- a/frontend/src/routes/(simulator)/components/Viewport.svelte +++ b/frontend/src/routes/(simulator)/components/Viewport.svelte @@ -1,16 +1,13 @@
{#if simulation.viewMode === '2d'} {:else} -
-

3D view coming soon...

-
+ {/if}