Add star-map Angular app, ETL pipeline, and caveman plugin

Angular 3D star map (galaxy/system/body views, Three.js rendering,
navigation store) plus the NASA ETL tooling that builds the star,
exoplanet and solar-system datasets, Playwright e2e suite, and the
cs:caveman Claude Code plugin (command, agent, skill).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
2026-08-03 16:50:10 +02:00
parent 1e1b58b0e9
commit d7e8ea1d4d
99 changed files with 5513 additions and 1 deletions
@@ -0,0 +1,174 @@
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CameraPose, CameraRigController } from './camera-rig-controller';
function createRig(): { camera: THREE.PerspectiveCamera; controls: OrbitControls; rig: CameraRigController } {
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
camera.position.set(0, 0, 5);
const controls = new OrbitControls(camera, document.createElement('canvas'));
controls.target.set(0, 0, 0);
const rig = new CameraRigController(camera, controls);
return { camera, controls, rig };
}
function pose(position: [number, number, number], target: [number, number, number]): CameraPose {
return { position: new THREE.Vector3(...position), target: new THREE.Vector3(...target) };
}
describe('CameraRigController', () => {
let camera: THREE.PerspectiveCamera;
let controls: OrbitControls;
let rig: CameraRigController;
beforeEach(() => {
({ camera, controls, rig } = createRig());
});
it('is not animating and leaves controls enabled before any transition starts', () => {
expect(rig.isAnimating).toBe(false);
expect(controls.enabled).toBe(true);
});
it('flyTo starts an animation and disables the controls for its duration', () => {
rig.flyTo(pose([10, 0, 0], [1, 1, 1]), 2);
expect(rig.isAnimating).toBe(true);
expect(controls.enabled).toBe(false);
});
it('interpolates the camera position/target to the symmetric midpoint at the halfway point of the duration', () => {
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2);
rig.update(1); // halfway through the 2s duration -> t = 0.5, eased(0.5) = 0.5 (symmetric midpoint)
expect(camera.position.x).toBeCloseTo(5, 6);
expect(camera.position.z).toBeCloseTo(2.5, 6);
expect(controls.target.x).toBeCloseTo(1, 6);
expect(rig.isAnimating).toBe(true);
});
it('eases the transition using the cubic in-out curve, not linearly', () => {
rig.flyTo(pose([8, 0, 0], [0, 0, 0]), 2);
rig.update(0.5); // t = 0.25 of the duration elapsed
// eased(0.25) = 4 * 0.25^3 = 0.0625 -> x = 0.5, well under the linear expectation of 25% (= 2).
expect(camera.position.x).toBeCloseTo(0.5, 6);
expect(camera.position.x).toBeLessThan(2);
});
it('calls lookAt toward the interpolated target on every update', () => {
const lookAtSpy = vi.spyOn(camera, 'lookAt');
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2);
rig.update(1);
expect(lookAtSpy).toHaveBeenCalledWith(controls.target);
});
it('reaches the exact target pose, re-enables controls, and fires onComplete exactly once when the duration elapses', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2, onComplete);
rig.update(1); // halfway
expect(rig.isAnimating).toBe(true);
expect(onComplete).not.toHaveBeenCalled();
rig.update(1); // reaches the end exactly
expect(camera.position.x).toBeCloseTo(10, 9);
expect(camera.position.y).toBeCloseTo(0, 9);
expect(camera.position.z).toBeCloseTo(0, 9);
expect(controls.target.x).toBeCloseTo(2, 9);
expect(rig.isAnimating).toBe(false);
expect(controls.enabled).toBe(true);
expect(onComplete).toHaveBeenCalledTimes(1);
rig.update(1); // no active tween anymore -> no further calls, no throw
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('clamps overshooting deltas so the camera never travels past the destination pose', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2, onComplete);
rig.update(100); // way more than the whole duration in a single frame
expect(camera.position.x).toBeCloseTo(10, 9);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('update() is a no-op while no transition is in flight', () => {
expect(() => rig.update(1)).not.toThrow();
expect(camera.position.x).toBeCloseTo(0, 9);
expect(camera.position.y).toBeCloseTo(0, 9);
expect(camera.position.z).toBeCloseTo(5, 9);
expect(controls.target.x).toBeCloseTo(0, 9);
expect(controls.target.y).toBeCloseTo(0, 9);
expect(controls.target.z).toBeCloseTo(0, 9);
expect(rig.isAnimating).toBe(false);
});
it('clones the pose passed to flyTo, so mutating the caller-owned vectors afterward does not affect the transition', () => {
const to = pose([10, 0, 0], [2, 2, 2]);
rig.flyTo(to, 2);
to.position.set(999, 999, 999);
to.target.set(999, 999, 999);
rig.update(2);
expect(camera.position.x).toBeCloseTo(10, 9);
expect(controls.target.x).toBeCloseTo(2, 9);
});
it('starting a new flyTo mid-transition replaces the old one, using the current interpolated pose as the new start', () => {
const firstOnComplete = vi.fn();
const secondOnComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [0, 0, 0]), 2, firstOnComplete);
rig.update(1); // halfway through the first tween: camera.position.x is now 5
const midPositionX = camera.position.x;
expect(midPositionX).toBeCloseTo(5, 6);
rig.flyTo(pose([0, 20, 0], [0, 0, 0]), 1, secondOnComplete);
// The new tween's "from" should be wherever the camera actually was, not the first tween's target.
rig.update(1); // completes the second (1s) tween
expect(camera.position.x).toBeCloseTo(0, 9);
expect(camera.position.y).toBeCloseTo(20, 9);
expect(firstOnComplete).not.toHaveBeenCalled();
expect(secondOnComplete).toHaveBeenCalledTimes(1);
expect(rig.isAnimating).toBe(false);
});
it('setImmediate jumps the camera/target with no easing and without requiring update()', () => {
rig.setImmediate(pose([3, 4, 5], [1, 1, 1]));
expect(camera.position.toArray()).toEqual([3, 4, 5]);
expect(controls.target.toArray()).toEqual([1, 1, 1]);
expect(rig.isAnimating).toBe(false);
});
it('setImmediate points the camera at the new target', () => {
const lookAtSpy = vi.spyOn(camera, 'lookAt');
rig.setImmediate(pose([3, 4, 5], [1, 1, 1]));
expect(lookAtSpy).toHaveBeenCalledWith(new THREE.Vector3(1, 1, 1));
});
it('treats a zero-second duration as effectively instantaneous on the next update, without dividing by zero', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 0, onComplete);
rig.update(0.001);
expect(Number.isFinite(camera.position.x)).toBe(true);
expect(camera.position.x).toBeCloseTo(10, 9);
expect(onComplete).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,81 @@
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export interface CameraPose {
position: THREE.Vector3;
target: THREE.Vector3;
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/**
* Drives smooth camera-position/look-at tweens between two poses. Deliberately unaware of
* parsecs vs. AU: the galaxy-to-system transition is built from two of these tweens (one per
* unit space) with a "floating-origin" recenter — an instantaneous {@link setImmediate} jump
* that swaps which group is visible and which unit scale the camera/controls operate in —
* spliced in between them by the caller (`GalaxySystemSceneComponent`).
*/
export class CameraRigController {
private active?: {
from: CameraPose;
to: CameraPose;
duration: number;
elapsed: number;
onComplete?: () => void;
};
constructor(
private readonly camera: THREE.PerspectiveCamera,
private readonly controls: OrbitControls
) {}
get isAnimating(): boolean {
return !!this.active;
}
/** Starts (replacing any in-flight tween) an eased camera-pose animation. */
flyTo(to: CameraPose, durationSeconds: number, onComplete?: () => void): void {
this.controls.enabled = false;
this.active = {
from: { position: this.camera.position.clone(), target: this.controls.target.clone() },
to: { position: to.position.clone(), target: to.target.clone() },
duration: Math.max(durationSeconds, 0.001),
elapsed: 0,
onComplete
};
}
/**
* Instantly places the camera/controls-target with no animation — the floating-origin
* recenter jump performed right after swapping which unit space/group is visible.
*/
setImmediate(pose: CameraPose): void {
this.camera.position.copy(pose.position);
this.controls.target.copy(pose.target);
this.camera.lookAt(pose.target);
}
/** Advances any in-flight tween. Call once per rendered frame. */
update(deltaSeconds: number): void {
if (!this.active) {
return;
}
this.active.elapsed += deltaSeconds;
const t = Math.min(this.active.elapsed / this.active.duration, 1);
const eased = easeInOutCubic(t);
this.camera.position.lerpVectors(this.active.from.position, this.active.to.position, eased);
this.controls.target.lerpVectors(this.active.from.target, this.active.to.target, eased);
this.camera.lookAt(this.controls.target);
if (t >= 1) {
const { onComplete } = this.active;
this.active = undefined;
this.controls.enabled = true;
onComplete?.();
}
}
}
@@ -0,0 +1,228 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
// jsdom does not implement ResizeObserver; the component only uses it to react to real
// layout changes, which never happen in this headless test.
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
const EARTH: BodyRecord = {
id: 'earth',
systemStarId: SUN.id,
name: 'Earth',
kind: 'planet',
radiusKm: 6371,
orbit: {
semiMajorAxisAu: 1,
eccentricity: 0.0167,
inclinationDeg: 0,
longitudeOfAscendingNodeDeg: 0,
argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0,
epochJd: 2451545.0
}
};
/** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely,
* while exposing the same tick-registration hook so tests can drive the render loop by hand. */
class FakeEngineService {
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
private readonly tickCallbacks = new Set<EngineTickCallback>();
get isInitialized(): boolean {
return true;
}
async init(): Promise<void> {
// no-op: no real renderer/context is created in tests.
}
getScene(): THREE.Scene {
return this.scene;
}
getCamera(): THREE.PerspectiveCamera {
return this.camera;
}
onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback);
}
start(): void {}
stop(): void {}
dispose(): void {}
resize(): void {}
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */
tick(deltaSeconds: number): void {
for (const callback of this.tickCallbacks) {
callback(deltaSeconds, 0);
}
}
}
class FakeDataLoaderService {
loadStars(): Promise<StarField> {
return Promise.resolve({ stars: STARS, positions: STAR_POSITIONS });
}
loadBodies(): Promise<BodyRecord[]> {
return Promise.resolve([EARTH]);
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
return Promise.resolve([]);
}
}
/** Waits out several macrotask turns so chained promises (bootstrap's awaits) settle. */
async function flushAsync(turns = 8): Promise<void> {
for (let i = 0; i < turns; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/** Advances the fake render loop (and therefore any in-flight `CameraRigController` tween)
* by repeatedly ticking a small fixed step, flushing microtasks between frames so any
* `onComplete` callback's own side effects (e.g. starting the next leg of the flight) run. */
async function advanceFrames(engine: FakeEngineService, totalSeconds: number, stepSeconds = 0.05): Promise<void> {
let elapsed = 0;
while (elapsed < totalSeconds) {
engine.tick(stepSeconds);
elapsed += stepSeconds;
await flushAsync(1);
}
}
describe('GalaxySystemSceneComponent camera-flight transitions', () => {
let fixture: ComponentFixture<GalaxySystemSceneComponent>;
let engine: FakeEngineService;
let navigationStore: NavigationStore;
beforeEach(async () => {
engine = new FakeEngineService();
TestBed.configureTestingModule({
imports: [GalaxySystemSceneComponent],
providers: [
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).overrideComponent(GalaxySystemSceneComponent, {
set: { providers: [{ provide: EngineService, useValue: engine }] }
});
navigationStore = TestBed.inject(NavigationStore);
fixture = TestBed.createComponent(GalaxySystemSceneComponent);
fixture.detectChanges(); // triggers ngAfterViewInit -> bootstrap()
await flushAsync();
});
it('starts in the galaxy view with the system group hidden', () => {
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
expect(component.galaxyGroup.visible).toBe(true);
expect(component.systemGroup.visible).toBe(false);
expect(navigationStore.viewLevel()).toBe('galaxy');
});
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
// Approach leg (parsec space) + settle leg (AU space) with margin.
await advanceFrames(engine, 2.5);
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
expect(component.galaxyGroup.visible).toBe(false);
expect(component.systemGroup.visible).toBe(true);
expect(engine.getCamera().near).toBeCloseTo(0.002, 9);
expect(navigationStore.viewLevel()).toBe('system');
});
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 2.5);
// Regardless of how far away (in parsecs) the star was, once we're in system space the
// camera must be within a few thousand AU of the origin -- never still out at the star's
// original parsec-scale distance from the Sun.
const distanceFromOrigin = engine.getCamera().position.length();
expect(distanceFromOrigin).toBeLessThan(1000);
expect(distanceFromOrigin).toBeGreaterThan(0);
});
it('flies back out to the galaxy overview and restores parsec-scale near/far planes when the selection is cleared', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(navigationStore.viewLevel()).toBe('system');
navigationStore.selectStar(null);
await flushAsync();
await advanceFrames(engine, 2.5);
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
expect(component.galaxyGroup.visible).toBe(true);
expect(component.systemGroup.visible).toBe(false);
expect(engine.getCamera().near).toBeCloseTo(0.01, 9);
expect(navigationStore.viewLevel()).toBe('galaxy');
});
it('hopping directly from one system to another exits the first system before entering the second, without settling back in the galaxy view', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(navigationStore.viewLevel()).toBe('system');
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 3.5);
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
expect(navigationStore.viewLevel()).toBe('system');
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
});
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
// Fire a second selection mid-flight, before the first transition has settled.
await advanceFrames(engine, 0.3);
navigationStore.selectStar(PROXIMA.id);
await flushAsync();
await advanceFrames(engine, 6);
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
expect(component.currentStarId).toBe(PROXIMA.id);
expect(navigationStore.viewLevel()).toBe('system');
});
});
@@ -0,0 +1,447 @@
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, viewChild } from '@angular/core';
import { Router } from '@angular/router';
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { dateToJulianDate } from '../../shared/astro/constants';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { CameraRigController } from './camera-rig-controller';
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
import { StarLabelOverlay } from './star-label-overlay';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
const SOL_STAR_ID = 0;
const SUN_GLOW_SCALE = 3.2;
/** Stars closer than this to the camera get a name label (always includes the selection). */
const LABEL_MAX_DISTANCE_PC = 20;
/** Caps how many labels are shown at once, to keep the DOM light. */
const LABEL_MAX_COUNT = 15;
/** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */
const LABEL_UPDATE_INTERVAL_SECONDS = 0.2;
/** Raycast pick tolerance around each star point, in parsecs. */
const PICK_THRESHOLD_PC = 1.2;
const GALAXY_OVERVIEW_POSITION = new THREE.Vector3(0, 15, 30);
const GALAXY_OVERVIEW_TARGET = new THREE.Vector3(0, 0, 0);
const GALAXY_NEAR_PC = 0.01;
const GALAXY_FAR_PC = 5000;
const GALAXY_MIN_DISTANCE_PC = 0.5;
const GALAXY_MAX_DISTANCE_PC = 2000;
/** How close (pc) the camera dives toward a selected star before the unit-space swap. */
const GALAXY_APPROACH_DISTANCE_PC = 0.05;
const SYSTEM_NEAR_AU = 0.002;
const SYSTEM_FAR_AU = 20000;
const SYSTEM_MIN_DISTANCE_AU = 0.05;
const SYSTEM_MAX_DISTANCE_AU = 5000;
/** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */
const SYSTEM_ENTRY_DISTANCE_AU = 200;
/** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */
const SYSTEM_EXIT_DISTANCE_AU = 400;
const MIN_SYSTEM_FRAMING_DISTANCE_AU = 3;
const MAX_SYSTEM_FRAMING_DISTANCE_AU = 80;
const APPROACH_DURATION_SECONDS = 1.0;
const SETTLE_DURATION_SECONDS = 0.9;
const EXIT_DURATION_SECONDS = 0.9;
const RETURN_DURATION_SECONDS = 1.1;
const STAR_MARKER_RADIUS_AU = 0.2;
/**
* Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select
* picking, proximity-based name labels, and — once a star is selected — a camera-flight
* transition into that star's system (real solar-system bodies for the Sun, cross-referenced
* exoplanets for other stars) with orbit ellipses and planet/moon markers. Owns its own
* `EngineService` instance.
*/
@Component({
selector: 'app-galaxy-system-scene',
providers: [EngineService],
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div>
@if (navigationStore.viewLevel() === 'system') {
<button
type="button"
(click)="exitSystem()"
class="absolute top-4 left-4 flex items-center gap-1.5 rounded-md border border-border bg-panel/70 px-3 py-1.5 font-body text-xs tracking-wide text-muted uppercase backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 6l-6 6 6 6" />
</svg>
Galaxy
</button>
}
</div>
`
})
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
private readonly raycaster = new THREE.Raycaster();
private readonly galaxyGroup = new THREE.Group();
private readonly systemGroup = new THREE.Group();
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
private readonly starMarkerGeometry = new THREE.SphereGeometry(STAR_MARKER_RADIUS_AU, 24, 16);
private controls?: OrbitControls;
private rig?: CameraRigController;
private starField?: StarFieldRenderer;
private labelOverlay?: StarLabelOverlay;
private stars: readonly StarRecord[] = [];
private starsById = new Map<number, StarRecord>();
private bodies: readonly BodyRecord[] = [];
private exoplanets: readonly ExoplanetRecord[] = [];
private resizeObserver?: ResizeObserver;
private unsubscribeTick?: () => void;
private labelUpdateAccumulator = 0;
private ready = false;
private busy = false;
/** Id of the star whose system is currently shown (or being flown to/from); null = galaxy view. */
private currentStarId: number | null = null;
private systemRenderer?: SystemOrbitsRenderer;
private starMarker?: THREE.Mesh;
private starGlow?: THREE.Sprite;
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly router: Router,
readonly navigationStore: NavigationStore
) {
effect(() => {
const selectedStarId = this.navigationStore.selectedStarId();
if (this.ready) {
this.reconcileSelection(selectedStarId);
}
});
}
ngAfterViewInit(): void {
void this.bootstrap();
}
ngOnDestroy(): void {
this.unsubscribeTick?.();
this.resizeObserver?.disconnect();
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
this.controls?.dispose();
this.starField?.dispose();
this.labelOverlay?.dispose();
this.systemRenderer?.dispose();
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
(this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose();
this.starMarkerGeometry.dispose();
this.starMarkerMaterial.dispose();
this.engine.dispose();
}
exitSystem(): void {
this.navigationStore.selectStar(null);
}
private async bootstrap(): Promise<void> {
const canvas = this.canvasRef().nativeElement;
try {
await this.engine.init(canvas);
} catch (error) {
console.error('Failed to initialize the 3D engine.', error);
return;
}
const scene = this.engine.getScene();
const camera = this.engine.getCamera();
camera.position.copy(GALAXY_OVERVIEW_POSITION);
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true;
this.controls.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.controls.target.copy(GALAXY_OVERVIEW_TARGET);
this.rig = new CameraRigController(camera, this.controls);
scene.add(this.galaxyGroup, this.systemGroup);
this.systemGroup.visible = false;
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
const [{ stars, positions }, bodies, exoplanets] = await Promise.all([
this.dataLoader.loadStars(),
this.dataLoader.loadBodies(),
this.dataLoader.loadExoplanets()
]);
this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.bodies = bodies;
this.exoplanets = exoplanets;
this.starField = new StarFieldRenderer(stars, positions);
this.galaxyGroup.add(this.starField.object);
this.labelOverlay = new StarLabelOverlay(scene);
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
const { width, height } = canvas.getBoundingClientRect();
this.labelOverlay.setSize(width, height);
this.raycaster.params.Points!.threshold = PICK_THRESHOLD_PC;
canvas.addEventListener('click', this.handleClick);
this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
this.engine.start();
this.ready = true;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private tick(camera: THREE.PerspectiveCamera, deltaSeconds: number): void {
this.rig?.update(deltaSeconds);
this.controls?.update();
if (this.currentStarId === null) {
this.labelUpdateAccumulator += deltaSeconds;
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
this.labelUpdateAccumulator = 0;
this.updateLabels(camera);
}
}
this.systemRenderer?.update(dateToJulianDate());
this.labelOverlay?.render(camera);
}
private updateLabels(camera: THREE.PerspectiveCamera): void {
const selectedId = this.navigationStore.selectedStarId();
const { x: cx, y: cy, z: cz } = camera.position;
const maxDistanceSq = LABEL_MAX_DISTANCE_PC * LABEL_MAX_DISTANCE_PC;
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
for (const star of this.stars) {
const dx = star.x - cx;
const dy = star.y - cy;
const dz = star.z - cz;
const distanceSq = dx * dx + dy * dy + dz * dz;
if (distanceSq <= maxDistanceSq || star.id === selectedId) {
candidates.push({ star, distanceSq });
}
}
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
this.labelOverlay?.update(candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star));
}
private readonly handleClick = (event: MouseEvent): void => {
if (this.rig?.isAnimating) {
return;
}
const canvas = this.canvasRef().nativeElement;
const camera = this.engine.getCamera();
const rect = canvas.getBoundingClientRect();
const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
this.raycaster.setFromCamera(pointerNdc, camera);
if (this.currentStarId === null) {
this.handleGalaxyClick();
} else {
this.handleSystemClick();
}
};
private handleGalaxyClick(): void {
if (!this.starField) {
return;
}
const [hit] = this.raycaster.intersectObject(this.starField.object);
const starId = hit?.index !== undefined ? this.starField.starIdAt(hit.index) : undefined;
if (starId !== undefined) {
this.navigationStore.selectStar(starId);
}
}
private handleSystemClick(): void {
if (!this.systemRenderer) {
return;
}
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined;
if (member) {
this.navigationStore.selectBody(member.id);
void this.router.navigate(['/body', member.id]);
}
}
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
private reconcileSelection(selectedStarId: number | null): void {
if (this.busy || selectedStarId === this.currentStarId) {
return;
}
this.busy = true;
if (selectedStarId === null) {
this.exitToGalaxy(() => this.finishTransition());
} else if (this.currentStarId === null) {
this.enterSystem(selectedStarId, () => this.finishTransition());
} else {
// Star-to-star: exit the current system (short outward hop) then fly into the new one.
this.exitToGalaxy(() => this.enterSystem(selectedStarId, () => this.finishTransition()), true);
}
}
/** Re-checks the store in case the selection changed again while a transition was in flight. */
private finishTransition(): void {
this.busy = false;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private enterSystem(starId: number, onComplete: () => void): void {
const star = this.starsById.get(starId);
if (!star || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const starPc = new THREE.Vector3(star.x, star.y, star.z);
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const approachPosition = starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC));
this.rig.flyTo({ position: approachPosition, target: starPc }, APPROACH_DURATION_SECONDS, () => {
this.swapToSystemSpace(star, direction, onComplete);
});
}
private swapToSystemSpace(star: StarRecord, direction: THREE.Vector3, onComplete: () => void): void {
const camera = this.engine.getCamera();
this.systemRenderer?.dispose();
if (this.starMarker) {
this.systemGroup.remove(this.starMarker);
(this.starMarker.material as THREE.Material).dispose();
}
if (this.starGlow) {
this.systemGroup.remove(this.starGlow);
(this.starGlow.material as THREE.SpriteMaterial).dispose();
this.starGlow = undefined;
}
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets);
this.systemGroup.add(this.systemRenderer.object);
const starMarkerMaterial = this.starMarkerMaterial.clone();
const starColor = colorIndexToRgb(star.colorIndex);
if (star.id === SOL_STAR_ID) {
// The Sun is the only star we have (and could ever have) a real photograph of; every
// other point in the galaxy view is far too distant to be resolved as a disk.
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
starMarkerMaterial.color.set(0xffffff);
this.starGlow = createGlowSprite(0xfff2c0, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE);
} else {
starMarkerMaterial.color.copy(starColor);
this.starGlow = createGlowSprite(starColor, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE * 0.6);
}
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
this.systemGroup.add(this.starMarker, this.starGlow);
this.galaxyGroup.visible = false;
this.systemGroup.visible = true;
camera.near = SYSTEM_NEAR_AU;
camera.far = SYSTEM_FAR_AU;
camera.updateProjectionMatrix();
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
const framingDistance = THREE.MathUtils.clamp(
this.systemRenderer.maxTopLevelSemiMajorAxisAu * 2.4 || MIN_SYSTEM_FRAMING_DISTANCE_AU,
MIN_SYSTEM_FRAMING_DISTANCE_AU,
MAX_SYSTEM_FRAMING_DISTANCE_AU
);
this.rig!.flyTo({ position: direction.clone().multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
this.currentStarId = star.id;
this.navigationStore.setViewLevel('system');
onComplete();
});
}
private exitToGalaxy(onComplete: () => void, isSwitchingSystems = false): void {
if (this.currentStarId === null || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const exitingStarId = this.currentStarId;
this.rig.flyTo({ position: direction.clone().multiplyScalar(SYSTEM_EXIT_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }, EXIT_DURATION_SECONDS, () => {
this.swapToGalaxySpace(exitingStarId, direction, isSwitchingSystems, onComplete);
});
}
private swapToGalaxySpace(exitingStarId: number, direction: THREE.Vector3, isSwitchingSystems: boolean, onComplete: () => void): void {
const camera = this.engine.getCamera();
const star = this.starsById.get(exitingStarId);
const starPc = star ? new THREE.Vector3(star.x, star.y, star.z) : GALAXY_OVERVIEW_TARGET.clone();
this.systemGroup.visible = false;
this.galaxyGroup.visible = true;
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
if (isSwitchingSystems) {
this.currentStarId = null;
onComplete();
return;
}
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
this.currentStarId = null;
this.navigationStore.setViewLevel('galaxy');
onComplete();
});
}
private observeResize(canvas: HTMLCanvasElement): void {
this.resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
this.labelOverlay?.setSize(width, height);
});
this.resizeObserver.observe(canvas);
}
}
@@ -0,0 +1,82 @@
import * as THREE from 'three/webgpu';
import { attribute } from 'three/tsl';
import { StarRecord } from '../../shared/models/star.model';
const MIN_POINT_SIZE = 1.5;
const MAX_POINT_SIZE = 6;
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
/**
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
*/
export function colorIndexToRgb(colorIndex: number): THREE.Color {
const t = THREE.MathUtils.clamp((colorIndex + 0.4) / 2.4, 0, 1);
const color = new THREE.Color();
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
}
/** Brighter stars (lower apparent magnitude) render as bigger points. */
function magnitudeToPointSize(magnitude: number): number {
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
return MIN_POINT_SIZE + t * (MAX_POINT_SIZE - MIN_POINT_SIZE);
}
/**
* Builds a `THREE.Points` field from the ETL-generated star positions/index, using a TSL
* `PointsNodeMaterial` whose color/size are driven by per-vertex attributes derived from
* each star's spectral color index and magnitude.
*
* Note: per the Three.js WebGPU backend, point primitives are capped at 1px on native
* WebGPU — `sizeNode` only has a visible effect when `WebGPURenderer` has fallen back to
* its WebGL2 backend. Color variation works on both backends.
*/
export class StarFieldRenderer {
readonly object: THREE.Points;
private readonly geometry: THREE.BufferGeometry;
private readonly material: THREE.PointsNodeMaterial;
constructor(private readonly stars: readonly StarRecord[], positions: Float32Array) {
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const colors = new Float32Array(stars.length * 3);
const sizes = new Float32Array(stars.length);
stars.forEach((star, index) => {
const color = colorIndexToRgb(star.colorIndex);
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
sizes[index] = magnitudeToPointSize(star.magnitude);
});
this.geometry.setAttribute('starColor', new THREE.BufferAttribute(colors, 3));
this.geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1));
this.material = new THREE.PointsNodeMaterial({
colorNode: attribute('starColor', 'vec3'),
sizeNode: attribute('starSize', 'float'),
sizeAttenuation: true,
transparent: true,
depthWrite: false
});
this.object = new THREE.Points(this.geometry, this.material);
}
/** Looks up the HYG star id for a given geometry vertex index (e.g. from a raycast hit). */
starIdAt(vertexIndex: number): number | undefined {
return this.stars[vertexIndex]?.id;
}
dispose(): void {
this.geometry.dispose();
this.material.dispose();
}
}
@@ -0,0 +1,78 @@
import * as THREE from 'three/webgpu';
import { CSS2DObject, CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
export interface LabeledPoint {
id: number;
name: string;
x: number;
y: number;
z: number;
}
/**
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
* diffed against the previous frame's set so the DOM is only touched when the visible set
* of stars actually changes, not every frame.
*/
export class StarLabelOverlay {
readonly domElement: HTMLElement;
private readonly cssRenderer = new CSS2DRenderer();
private readonly labelObjects = new Map<number, CSS2DObject>();
constructor(private readonly scene: THREE.Scene) {
this.cssRenderer.domElement.classList.add('star-label-layer');
this.domElement = this.cssRenderer.domElement;
}
setSize(width: number, height: number): void {
this.cssRenderer.setSize(width, height);
}
/** Shows exactly these labels, adding/removing DOM elements only for a changed set. */
update(points: readonly LabeledPoint[]): void {
const idsToShow = new Set(points.map((point) => point.id));
for (const [id, object] of this.labelObjects) {
if (!idsToShow.has(id)) {
this.removeLabel(id, object);
}
}
for (const point of points) {
if (!this.labelObjects.has(point.id)) {
this.addLabel(point);
}
}
}
render(camera: THREE.Camera): void {
this.cssRenderer.render(this.scene, camera);
}
dispose(): void {
for (const [id, object] of this.labelObjects) {
this.removeLabel(id, object);
}
}
private addLabel(point: LabeledPoint): void {
const element = document.createElement('div');
// Tailwind utility classes assigned directly since this element lives outside Angular's
// view encapsulation (see the class comment above) rather than through a component template.
element.className = 'translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body text-[11px] text-accent [text-shadow:0_0_4px_rgba(0,0,0,0.9)]';
element.textContent = point.name;
const object = new CSS2DObject(element);
object.position.set(point.x, point.y, point.z);
this.scene.add(object);
this.labelObjects.set(point.id, object);
}
private removeLabel(id: number, object: CSS2DObject): void {
this.scene.remove(object);
object.element.remove();
this.labelObjects.delete(id);
}
}
@@ -0,0 +1,236 @@
import * as THREE from 'three/webgpu';
import { gmForParent } from '../../shared/astro/constants';
import { orbitEllipsePoints, propagateOrbit, resolveOrbitalElements } from '../../shared/astro/kepler';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
/** A pickable marker for one rendered body/exoplanet, keyed by its own record id. */
export interface SystemMember {
id: string;
kind: SystemMemberKind;
marker: THREE.Object3D;
}
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
const DWARF_COLOR = new THREE.Color(0.8, 0.7, 0.55);
const MOON_COLOR = new THREE.Color(0.75, 0.75, 0.75);
const EXOPLANET_COLOR = new THREE.Color(0.85, 0.4, 0.85);
const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
planet: 0.5,
dwarf: 0.4,
moon: 0.35,
exoplanet: 0.35
};
const EARTH_RADIUS_KM = 6371;
const MIN_MARKER_RADIUS_AU = 0.012;
const MAX_MARKER_RADIUS_AU = 0.09;
/** Exaggerated (non-physical) marker radius so planets stay visible at AU scale. */
function markerRadiusAu(radiusKm: number | undefined): number {
if (!radiusKm) {
return MIN_MARKER_RADIUS_AU;
}
return THREE.MathUtils.clamp(radiusKm / 18000, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU);
}
function colorForKind(kind: SystemMemberKind): THREE.Color {
switch (kind) {
case 'planet':
return PLANET_COLOR;
case 'dwarf':
return DWARF_COLOR;
case 'moon':
return MOON_COLOR;
case 'exoplanet':
return EXOPLANET_COLOR;
}
}
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THREE.Line {
const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3);
points.forEach((point, index) => {
positions[index * 3] = point.x;
positions[index * 3 + 1] = point.z; // AU "up" (ecliptic normal) maps to scene Y.
positions[index * 3 + 2] = point.y;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: colorForKind(kind),
transparent: true,
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
});
return new THREE.Line(geometry, material);
}
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined): THREE.Mesh {
const geometry = new THREE.SphereGeometry(markerRadiusAu(radiusKm), 16, 12);
const material = new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
return new THREE.Mesh(geometry, material);
}
interface TrackedTopLevelBody {
id: string;
kind: SystemMemberKind;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
/** AU position last computed for this body; moons read their parent's here. */
position: THREE.Vector3;
}
interface TrackedMoon {
id: string;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
pivot: THREE.Group;
parentId: string;
}
/**
* Builds and animates the orbit ellipses + planet/moon/exoplanet markers for one star system,
* in AU, with the star itself at the origin. Moons are parented to a pivot group that tracks
* their planet's live position each tick, so their (small, planet-relative) orbit ellipse and
* marker never need to be rebuilt.
*/
export class SystemOrbitsRenderer {
readonly object = new THREE.Group();
readonly members: readonly SystemMember[];
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
readonly maxTopLevelSemiMajorAxisAu: number;
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
private readonly moons: TrackedMoon[] = [];
private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = [];
constructor(bodies: readonly BodyRecord[], exoplanets: readonly ExoplanetRecord[]) {
const members: SystemMember[] = [];
const topLevelBodiesById = new Map<string, BodyRecord>();
for (const body of bodies) {
if (!body.parentBodyId) {
topLevelBodiesById.set(body.id, body);
}
}
for (const body of bodies) {
if (body.parentBodyId) {
continue;
}
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
const kind: SystemMemberKind = body.kind;
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm);
members.push({ id: body.id, kind, marker: tracked.marker });
}
for (const body of bodies) {
if (!body.parentBodyId) {
continue;
}
const parent = topLevelBodiesById.get(body.parentBodyId);
const parentTracked = parent && this.topLevelBodies.find((tracked) => tracked.id === parent.id);
if (!parentTracked) {
continue; // orphaned moon reference; skip rather than crash.
}
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked);
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
}
for (const exoplanet of exoplanets) {
if (!exoplanet.orbit.semiMajorAxisAu || exoplanet.orbit.eccentricity === undefined) {
continue; // not enough data to place on an orbit.
}
const elements = resolveOrbitalElements({
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
eccentricity: exoplanet.orbit.eccentricity,
inclinationDeg: exoplanet.orbit.inclinationDeg,
longitudeOfAscendingNodeDeg: exoplanet.orbit.longitudeOfAscendingNodeDeg,
argumentOfPeriapsisDeg: exoplanet.orbit.argumentOfPeriapsisDeg,
meanAnomalyAtEpochDeg: exoplanet.orbit.meanAnomalyAtEpochDeg,
epochJd: exoplanet.orbit.epochJd
});
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gmForParent(undefined), radiusKm);
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
}
this.members = members;
this.maxTopLevelSemiMajorAxisAu = this.topLevelBodies.reduce((max, body) => Math.max(max, body.elements.semiMajorAxisAu), 0);
}
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
update(epochJd: number): void {
for (const body of this.topLevelBodies) {
const { x, y, z } = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
body.position.set(x, z, y); // AU "up" maps to scene Y, matching buildOrbitLine.
body.marker.position.copy(body.position);
}
for (const moon of this.moons) {
const parent = this.topLevelBodies.find((body) => body.id === moon.parentId);
if (!parent) {
continue;
}
moon.pivot.position.copy(parent.position);
const { x, y, z } = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
moon.marker.position.set(x, z, y);
}
}
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
memberForObject(object: THREE.Object3D): SystemMember | undefined {
return this.members.find((member) => member.marker === object);
}
/** All marker objects, for raycasting. */
get pickableObjects(): THREE.Object3D[] {
return this.members.map((member) => member.marker);
}
dispose(): void {
for (const { geometry, material } of this.disposables) {
geometry.dispose();
material.dispose();
}
}
private addTopLevelBody(id: string, kind: SystemMemberKind, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind);
const marker = buildMarker(kind, radiusKm);
this.object.add(orbitLine, marker);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, position: new THREE.Vector3() };
this.topLevelBodies.push(tracked);
return tracked;
}
private addMoon(id: string, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody): TrackedMoon {
const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon');
const marker = buildMarker('moon', radiusKm);
pivot.add(orbitLine, marker);
this.object.add(pivot);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, pivot, parentId: parent.id };
this.moons.push(moon);
return moon;
}
private trackDisposable(geometry: THREE.BufferGeometry, material: THREE.Material): void {
this.disposables.push({ geometry, material });
}
}