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