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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user