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:
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
55
frontend/src/routes/(simulator)/components/ThreeScene.svelte
Normal file
55
frontend/src/routes/(simulator)/components/ThreeScene.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user