Add stations, route trajectory, animated package, and POV camera to 3D view

- Blue octahedron station markers with additive glow sprites in 3D scene
- Orange trajectory line showing actual Lambert transfer orbit in 3D
- Green animated package sphere with glow following the trajectory
- Package POV camera: first-person view from the package looking forward
  along the trajectory (toggle button appears when route is computed)
- Station positions update every frame synchronized with 2D view
- Route animation shared between 2D and 3D views via routing store

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 12:26:32 -07:00
parent 47ef19d76c
commit 6c69d7cf89
2 changed files with 170 additions and 39 deletions

View File

@@ -1,27 +1,55 @@
<script lang="ts">
import { T, useTask } from '@threlte/core';
import { T, useTask, useThrelte } from '@threlte/core';
import { simulation } from '$lib/stores/simulation.svelte';
import { getBodyPositions, getOrbitPoints } from '$lib/wasm/bridge';
import { stations } from '$lib/stores/stations.svelte';
import { routing } from '$lib/stores/routing.svelte';
import { getBodyPositions, getOrbitPoints, getStationPositions } 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;
x: number; y: number; z: number;
color: string; emissive: string;
radius: number; name: string; isSun: boolean;
}
interface StationRenderData {
x: number; y: number; z: number; name: string;
}
let bodyData = $state<BodyRenderData[]>([]);
let stationData = $state<StationRenderData[]>([]);
let orbitLines = $state<{ points: THREE.Vector3[]; color: string }[]>([]);
let trajectoryLine = $state<THREE.Vector3[]>([]);
let packagePos = $state<[number, number, number]>([0, 0, 0]);
let packageVisible = $state(false);
let orbitsBuilt = false;
// POV camera state
let povActive = $state(false);
const { camera } = useThrelte();
let savedCameraPos = new THREE.Vector3();
let savedCameraTarget = new THREE.Vector3();
export function togglePOV() {
povActive = !povActive;
if (povActive) {
// Save current camera state
savedCameraPos.copy(camera.current.position);
} else {
// Restore camera
camera.current.position.copy(savedCameraPos);
}
}
export function isPOVActive() { return povActive; }
function eclipticToThree(x: number, y: number, z: number): [number, number, number] {
return [x * AU_SCALE, z * AU_SCALE, -y * AU_SCALE];
}
function bodyDisplayRadius(radiusKm: number, isSun: boolean): number {
if (isSun) return 2.5;
if (radiusKm > 50000) return 1.2;
@@ -35,18 +63,12 @@
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,
));
const [tx, ty, tz] = eclipticToThree(points[j], points[j+1], points[j+2]);
vec3s.push(new THREE.Vector3(tx, ty, tz));
}
// Close the loop
vec3s.push(vec3s[0].clone());
lines.push({ points: vec3s, color: rgbToHex(r, g, b) });
}
@@ -54,10 +76,22 @@
orbitsBuilt = true;
}
function buildTrajectoryLine() {
const pts = routing.trajectoryPoints;
if (pts.length < 6) { trajectoryLine = []; return; }
const vec3s: THREE.Vector3[] = [];
for (let i = 0; i < pts.length; i += 3) {
if (isNaN(pts[i])) continue; // Skip leg separators
const [tx, ty, tz] = eclipticToThree(pts[i], pts[i+1], pts[i+2]);
vec3s.push(new THREE.Vector3(tx, ty, tz));
}
trajectoryLine = vec3s;
}
let lastTime = performance.now();
let frameCount = 0;
useTask(() => {
useTask((delta) => {
const now = performance.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
@@ -65,6 +99,7 @@
if (!simulation.wasmReady) return;
// Update body positions
const positions = getBodyPositions(simulation.currentJD);
if (positions.length === 0) return;
@@ -73,21 +108,74 @@
const info = simulation.bodyInfos[i];
const [r, g, b] = info.color;
const isSun = i === 0;
const [tx, ty, tz] = eclipticToThree(positions[i*3], positions[i*3+1], positions[i*3+2]);
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,
x: tx, y: ty, z: tz,
color: rgbToHex(r, g, b),
emissive: isSun ? rgbToHex(r, g, b) : '#000000',
radius: bodyDisplayRadius(info.radius_km, isSun),
name: info.name,
isSun,
name: info.name, isSun,
});
}
bodyData = newData;
// Build orbits once, refresh periodically
// Update station positions
if (stations.stationsJson !== '[]' && stations.visible) {
const spos = getStationPositions(stations.stationsJson, simulation.currentJD);
const newStations: StationRenderData[] = [];
for (let i = 0; i < stations.stationNames.length; i++) {
if (i * 3 + 2 >= spos.length) break;
const [tx, ty, tz] = eclipticToThree(spos[i*3], spos[i*3+1], spos[i*3+2]);
newStations.push({ x: tx, y: ty, z: tz, name: stations.stationNames[i] });
}
stationData = newStations;
} else {
stationData = [];
}
// Update route animation
if (routing.route && routing.trajectoryPoints.length > 0) {
if (routing.isAnimating) {
routing.routeProgress += dt * 0.05;
if (routing.routeProgress > 1) routing.routeProgress = 0;
}
// Build trajectory line geometry (only when route changes)
if (frameCount % 30 === 0 || trajectoryLine.length === 0) {
buildTrajectoryLine();
}
// Compute package position
const pts = routing.trajectoryPoints;
const validIndices: number[] = [];
for (let i = 0; i < pts.length; i += 3) {
if (!isNaN(pts[i])) validIndices.push(i);
}
if (validIndices.length > 1) {
const idx = Math.min(
Math.floor(routing.routeProgress * (validIndices.length - 1)),
validIndices.length - 1,
);
const pIdx = validIndices[idx];
const [tx, ty, tz] = eclipticToThree(pts[pIdx], pts[pIdx+1], pts[pIdx+2]);
packagePos = [tx, ty, tz];
packageVisible = true;
// POV camera: follow package
if (povActive) {
camera.current.position.set(tx, ty + 3, tz + 5);
// Look ahead along trajectory
const nextIdx = Math.min(idx + 3, validIndices.length - 1);
const nIdx = validIndices[nextIdx];
const [nx, ny, nz] = eclipticToThree(pts[nIdx], pts[nIdx+1], pts[nIdx+2]);
camera.current.lookAt(nx, ny, nz);
}
}
} else {
packageVisible = false;
trajectoryLine = [];
}
if (!orbitsBuilt || frameCount % 600 === 0) {
buildOrbitLines();
}
@@ -107,20 +195,12 @@
<!-- 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.SpriteMaterial color="#ffcc00" transparent opacity={0.15} blending={THREE.AdditiveBlending} depthWrite={false} />
</T.Sprite>
{:else}
<T.Mesh position={[body.x, body.y, body.z]}>
@@ -129,3 +209,35 @@
</T.Mesh>
{/if}
{/each}
<!-- Stations -->
{#each stationData as station}
<T.Mesh position={[station.x, station.y, station.z]}>
<T.OctahedronGeometry args={[0.4, 0]} />
<T.MeshBasicMaterial color="#4a9eff" transparent opacity={0.7} />
</T.Mesh>
<!-- Station glow -->
<T.Sprite position={[station.x, station.y, station.z]} scale={[2, 2, 1]}>
<T.SpriteMaterial color="#4a9eff" transparent opacity={0.1} blending={THREE.AdditiveBlending} depthWrite={false} />
</T.Sprite>
{/each}
<!-- Route trajectory line -->
{#if trajectoryLine.length > 1}
{@const trajGeo = new THREE.BufferGeometry().setFromPoints(trajectoryLine)}
<T.Line>
<T is={trajGeo} attach="geometry" />
<T.LineBasicMaterial color="#ff6a33" transparent opacity={0.7} linewidth={2} />
</T.Line>
{/if}
<!-- Animated package -->
{#if packageVisible}
<T.Mesh position={packagePos}>
<T.SphereGeometry args={[0.5, 16, 16]} />
<T.MeshBasicMaterial color="#33ff88" />
</T.Mesh>
<T.Sprite position={packagePos} scale={[4, 4, 1]}>
<T.SpriteMaterial color="#33ff88" transparent opacity={0.2} blending={THREE.AdditiveBlending} depthWrite={false} />
</T.Sprite>
{/if}

View File

@@ -2,8 +2,11 @@
import { Canvas, T } from '@threlte/core';
import { OrbitControls } from '@threlte/extras';
import SolarBodiesInner from './SolarBodiesInner.svelte';
import { routing } from '$lib/stores/routing.svelte';
import * as THREE from 'three';
let bodiesRef: SolarBodiesInner;
// Generate starfield geometry once
const starPositions = new Float32Array(5000 * 3);
for (let i = 0; i < 5000; i++) {
@@ -16,9 +19,13 @@
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
function handlePOVToggle() {
bodiesRef?.togglePOV();
}
</script>
<div class="w-full h-full">
<div class="w-full h-full relative">
<Canvas renderMode="always">
<!-- Camera -->
<T.PerspectiveCamera
@@ -29,7 +36,7 @@
far={10000}
/>
<!-- Controls -->
<!-- Controls (disabled during POV mode) -->
<OrbitControls
enableDamping
dampingFactor={0.05}
@@ -49,7 +56,19 @@
<T.PointsMaterial color="#ffffff" size={0.8} sizeAttenuation transparent opacity={0.7} />
</T.Points>
<!-- Solar system bodies + orbits -->
<SolarBodiesInner />
<!-- Solar system bodies + orbits + stations + routes -->
<SolarBodiesInner bind:this={bodiesRef} />
</Canvas>
<!-- POV Camera toggle (overlay) -->
{#if routing.route && routing.trajectoryPoints.length > 0}
<button
onclick={handlePOVToggle}
class="absolute bottom-3 left-3 px-3 py-1.5 text-xs font-medium rounded
bg-[var(--bg-panel)] border border-white/10 hover:border-[var(--accent-green)]/50
text-[var(--text-secondary)] hover:text-[var(--accent-green)] transition-colors"
>
Package POV
</button>
{/if}
</div>