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

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