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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:54:31 -07:00
parent 5efe0736ac
commit 067ef1f557
8 changed files with 258 additions and 10 deletions

View File

@@ -46,6 +46,15 @@ pub fn get_body_radii() -> Vec<f64> {
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<f64> {
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.

View File

@@ -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<f64> {
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<f64> {
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<f64> {

View File

@@ -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<number, Float64Array> = 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();

View File

@@ -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);

View File

@@ -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) {

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { T, useTask } from '@threlte/core';
import { simulation } from '$lib/stores/simulation.svelte';
import { getBodyPositions, getOrbitPoints } from '$lib/wasm/bridge';
import { rgbToHex } from '$lib/utils/colors';
import * as THREE from 'three';
const AU_SCALE = 50;
interface BodyRenderData {
x: number;
y: number;
z: number;
color: string;
emissive: string;
radius: number;
name: string;
isSun: boolean;
}
let bodyData = $state<BodyRenderData[]>([]);
let orbitLines = $state<{ points: THREE.Vector3[]; color: string }[]>([]);
let orbitsBuilt = false;
function bodyDisplayRadius(radiusKm: number, isSun: boolean): number {
if (isSun) return 2.5;
if (radiusKm > 50000) return 1.2;
if (radiusKm > 5000) return 0.5;
if (radiusKm > 2000) return 0.35;
return 0.2;
}
function buildOrbitLines() {
const lines: typeof orbitLines = [];
for (let i = 1; i < simulation.bodyInfos.length; i++) {
const points = getOrbitPoints(i, simulation.currentJD, 256);
if (points.length < 6) continue;
const [r, g, b] = simulation.bodyInfos[i].color;
const vec3s: THREE.Vector3[] = [];
for (let j = 0; j < points.length; j += 3) {
// Ecliptic X -> Three X, Ecliptic Y -> Three Z (negated), Ecliptic Z -> Three Y
vec3s.push(new THREE.Vector3(
points[j] * AU_SCALE,
points[j + 2] * AU_SCALE,
-points[j + 1] * AU_SCALE,
));
}
// Close the loop
vec3s.push(vec3s[0].clone());
lines.push({ points: vec3s, color: rgbToHex(r, g, b) });
}
orbitLines = lines;
orbitsBuilt = true;
}
let lastTime = performance.now();
let frameCount = 0;
useTask(() => {
const now = performance.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
simulation.advanceTime(dt);
if (!simulation.wasmReady) return;
const positions = getBodyPositions(simulation.currentJD);
if (positions.length === 0) return;
const newData: BodyRenderData[] = [];
for (let i = 0; i < simulation.bodyInfos.length; i++) {
const info = simulation.bodyInfos[i];
const [r, g, b] = info.color;
const isSun = i === 0;
newData.push({
// Ecliptic -> Three.js coordinate mapping
x: positions[i * 3] * AU_SCALE,
y: positions[i * 3 + 2] * AU_SCALE,
z: -positions[i * 3 + 1] * AU_SCALE,
color: rgbToHex(r, g, b),
emissive: isSun ? rgbToHex(r, g, b) : '#000000',
radius: bodyDisplayRadius(info.radius_km, isSun),
name: info.name,
isSun,
});
}
bodyData = newData;
// Build orbits once, refresh periodically
if (!orbitsBuilt || frameCount % 600 === 0) {
buildOrbitLines();
}
frameCount++;
});
</script>
<!-- Orbit lines -->
{#each orbitLines as orbit}
{@const geo = new THREE.BufferGeometry().setFromPoints(orbit.points)}
<T.Line>
<T is={geo} attach="geometry" />
<T.LineBasicMaterial color={orbit.color} transparent opacity={0.25} />
</T.Line>
{/each}
<!-- Bodies -->
{#each bodyData as body}
{#if body.isSun}
<!-- Sun: emissive sphere + point light glow -->
<T.Mesh position={[body.x, body.y, body.z]}>
<T.SphereGeometry args={[body.radius, 32, 32]} />
<T.MeshBasicMaterial color={body.color} />
</T.Mesh>
<!-- Sun glow sprite -->
<T.Sprite position={[body.x, body.y, body.z]} scale={[12, 12, 1]}>
<T.SpriteMaterial
color="#ffcc00"
transparent
opacity={0.15}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</T.Sprite>
{:else}
<T.Mesh position={[body.x, body.y, body.z]}>
<T.SphereGeometry args={[body.radius, 16, 16]} />
<T.MeshStandardMaterial color={body.color} emissive={body.emissive} emissiveIntensity={0.1} roughness={0.8} />
</T.Mesh>
{/if}
{/each}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { Canvas, T } from '@threlte/core';
import { OrbitControls } from '@threlte/extras';
import SolarBodiesInner from './SolarBodiesInner.svelte';
import * as THREE from 'three';
// Generate starfield geometry once
const starPositions = new Float32Array(5000 * 3);
for (let i = 0; i < 5000; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 2000 + Math.random() * 2000;
starPositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
starPositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
starPositions[i * 3 + 2] = r * Math.cos(phi);
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
</script>
<div class="w-full h-full">
<Canvas renderMode="always">
<!-- Camera -->
<T.PerspectiveCamera
makeDefault
position={[0, 80, 120]}
fov={60}
near={0.1}
far={10000}
/>
<!-- Controls -->
<OrbitControls
enableDamping
dampingFactor={0.05}
minDistance={5}
maxDistance={3000}
/>
<!-- Ambient light for minimum visibility -->
<T.AmbientLight intensity={0.15} color="#4466aa" />
<!-- Sun point light at origin -->
<T.PointLight position={[0, 0, 0]} intensity={3} distance={0} decay={0.1} color="#fff5e0" />
<!-- Starfield background -->
<T.Points>
<T is={starGeo} attach="geometry" />
<T.PointsMaterial color="#ffffff" size={0.8} sizeAttenuation transparent opacity={0.7} />
</T.Points>
<!-- Solar system bodies + orbits -->
<SolarBodiesInner />
</Canvas>
</div>

View File

@@ -1,16 +1,13 @@
<script lang="ts">
import { simulation } from '$lib/stores/simulation.svelte';
import Canvas2DView from './Canvas2DView.svelte';
// 3D view will be added in Phase 3
import ThreeScene from './ThreeScene.svelte';
</script>
<div class="relative w-full h-full">
{#if simulation.viewMode === '2d'}
<Canvas2DView />
{:else}
<div class="w-full h-full flex items-center justify-center text-[var(--text-secondary)]">
<p class="text-sm">3D view coming soon...</p>
</div>
<ThreeScene />
{/if}
</div>