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