Add the deep-sky backdrop, the last unbuilt piece of the plan
The design doc scopes deep-sky objects as a galaxy-view backdrop and lists fetchDeepSky.ts, deepsky.json and deepsky.model.ts, but none of it existed — it was the only part of the plan with no implementation behind it. ETL: fetchDeepSky.ts pulls the OpenNGC catalog, classifies each object as a galaxy/nebula/cluster, and keeps the ~460 worth drawing (everything Messier, everything with a common name, and anything brighter than magnitude 9) out of ~12,000 mostly-anonymous rows. build.ts runs it and validates the output. Distances are the hard part: OpenNGC has no distance column, and both fallbacks fail for the best-known objects. M31, M33 and M42 are Local Group members whose redshift is negative or absent, and a galaxy's catalog parallax comes from a cross-matched foreground star — 6 mas for M31 would put a 780 kpc galaxy at 167 pc. So records store a unit direction on the celestial sphere rather than a position (the line of sight is always known precisely, and the objects are drawn on a fixed backdrop shell where true distance is unusable anyway), and distance is optional metadata carrying its own provenance. Parallax is trusted only for galactic objects, redshift only above z=0.003 where expansion outweighs peculiar velocity. 330 of 463 get a distance; the rest honestly report none. Rendering: DeepSkyRenderer paints the objects as soft additive billboards on a 2500 pc shell — clear of the 50 pc star field, beyond the camera's 2000 pc orbit limit, and inside its 5000 pc far plane. Size comes from real angular extent, so Andromeda is six times wider than the full Moon, clamped at both ends. Sprites rather than points because the WebGPU backend caps point primitives at one pixel; materials are shared per kind and brightness band, so 460 objects cost nine of them. The brightest dozen get permanent labels, which needed the label overlay to accept string ids alongside numeric star ids. The backdrop is decorative, so a failure to load its dataset is logged and the star field comes up regardless. Also documents the app in the README, which until now covered only the plugin marketplace. Tests: 112 passing, up from 54. Build, both typechecks and the Playwright suite are green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { backdropPosition, backdropSpriteSizePc, BACKDROP_RADIUS_PC, brightnessBandIndex, deepSkyLabelPoints, DeepSkyRenderer } from './deep-sky-renderer';
|
||||
|
||||
function record(overrides: Partial<DeepSkyRecord> = {}): DeepSkyRecord {
|
||||
return {
|
||||
id: 'NGC0224',
|
||||
name: 'Andromeda Galaxy',
|
||||
kind: 'galaxy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
angularSizeDeg: 2.96,
|
||||
magnitude: 3.44,
|
||||
distancePc: null,
|
||||
distanceMethod: null,
|
||||
constellation: 'And',
|
||||
messier: 'M31',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('backdropPosition', () => {
|
||||
it('pushes the direction out to the shell radius', () => {
|
||||
const position = backdropPosition(record({ x: 0, y: 0, z: 1 }));
|
||||
expect(position.z).toBeCloseTo(BACKDROP_RADIUS_PC, 9);
|
||||
expect(position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 9);
|
||||
});
|
||||
|
||||
it('preserves direction for an off-axis object', () => {
|
||||
const direction = new THREE.Vector3(0.3, -0.5, 0.81).normalize();
|
||||
const position = backdropPosition(record({ x: direction.x, y: direction.y, z: direction.z }));
|
||||
|
||||
expect(position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
expect(position.clone().normalize().dot(direction)).toBeCloseTo(1, 9);
|
||||
});
|
||||
|
||||
it('honours an explicit radius', () => {
|
||||
expect(backdropPosition(record(), 100).length()).toBeCloseTo(100, 9);
|
||||
});
|
||||
|
||||
it('lands inside the galaxy camera frustum from anywhere on its orbit', () => {
|
||||
// The camera orbits at most 2000 pc out and its far plane is 5000 pc, so the far side of
|
||||
// the shell has to stay within reach or the backdrop would be clipped away.
|
||||
expect(BACKDROP_RADIUS_PC).toBeGreaterThan(2000);
|
||||
expect(BACKDROP_RADIUS_PC + 2000).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backdropSpriteSizePc', () => {
|
||||
it('scales with true angular size', () => {
|
||||
const small = backdropSpriteSizePc(record({ angularSizeDeg: 1 }));
|
||||
const large = backdropSpriteSizePc(record({ angularSizeDeg: 2 }));
|
||||
expect(large).toBeGreaterThan(small);
|
||||
});
|
||||
|
||||
it('reproduces the real angular size in the unclamped range', () => {
|
||||
// 2 degrees at the shell radius: r * theta.
|
||||
const expected = BACKDROP_RADIUS_PC * 2 * (Math.PI / 180);
|
||||
expect(backdropSpriteSizePc(record({ angularSizeDeg: 2 }))).toBeCloseTo(expected, 6);
|
||||
});
|
||||
|
||||
it('floors sub-arcminute objects so they stay visible', () => {
|
||||
const tiny = backdropSpriteSizePc(record({ angularSizeDeg: 0 }));
|
||||
expect(tiny).toBeGreaterThan(0);
|
||||
expect(tiny).toBe(backdropSpriteSizePc(record({ angularSizeDeg: 0.001 })));
|
||||
});
|
||||
|
||||
it('caps very extended objects so they cannot blanket the view', () => {
|
||||
const huge = backdropSpriteSizePc(record({ angularSizeDeg: 90 }));
|
||||
const larger = backdropSpriteSizePc(record({ angularSizeDeg: 180 }));
|
||||
expect(huge).toBe(larger);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brightnessBandIndex', () => {
|
||||
it('puts the brightest objects in the most opaque band', () => {
|
||||
expect(brightnessBandIndex(3.44)).toBe(0);
|
||||
});
|
||||
|
||||
it('separates mid and faint objects into later bands', () => {
|
||||
expect(brightnessBandIndex(6)).toBe(1);
|
||||
expect(brightnessBandIndex(9)).toBe(2);
|
||||
});
|
||||
|
||||
it('is monotonic in magnitude', () => {
|
||||
const bands = [0, 3, 5, 6, 7.5, 9, 14].map(brightnessBandIndex);
|
||||
expect([...bands].sort((a, b) => a - b)).toEqual(bands);
|
||||
});
|
||||
|
||||
it('treats an unphotometered object as faintest rather than brightest', () => {
|
||||
expect(brightnessBandIndex(null)).toBe(brightnessBandIndex(99));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepSkyLabelPoints', () => {
|
||||
const records = [record({ id: 'a', name: 'A' }), record({ id: 'b', name: 'B' }), record({ id: 'c', name: 'C' })];
|
||||
|
||||
it('takes a prefix of the (magnitude-sorted) records', () => {
|
||||
expect(deepSkyLabelPoints(records, 2).map((point) => point.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('anchors each label on the backdrop shell', () => {
|
||||
const [point] = deepSkyLabelPoints(records, 1);
|
||||
expect(Math.hypot(point.x, point.y, point.z)).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
});
|
||||
|
||||
it('carries the display name and the catalog id', () => {
|
||||
const [point] = deepSkyLabelPoints(records, 1);
|
||||
expect(point).toMatchObject({ id: 'a', name: 'A' });
|
||||
});
|
||||
|
||||
it('never returns more labels than there are records', () => {
|
||||
expect(deepSkyLabelPoints(records, 99)).toHaveLength(3);
|
||||
expect(deepSkyLabelPoints([], 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSkyRenderer', () => {
|
||||
it('adds one sprite per record', () => {
|
||||
const renderer = new DeepSkyRenderer([record({ id: 'a' }), record({ id: 'b' })]);
|
||||
expect(renderer.object.children).toHaveLength(2);
|
||||
expect(renderer.object.children.every((child) => child instanceof THREE.Sprite)).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('positions and scales each sprite from its record', () => {
|
||||
const only = record({ angularSizeDeg: 2 });
|
||||
const renderer = new DeepSkyRenderer([only]);
|
||||
const sprite = renderer.object.children[0] as THREE.Sprite;
|
||||
|
||||
expect(sprite.position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
expect(sprite.scale.x).toBeCloseTo(backdropSpriteSizePc(only), 6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('shares one material across objects of the same kind and brightness', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'galaxy', magnitude: 4 })
|
||||
]);
|
||||
const [first, second] = renderer.object.children as THREE.Sprite[];
|
||||
|
||||
expect(first.material).toBe(second.material);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives different kinds different materials', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'nebula', magnitude: 3 }),
|
||||
record({ id: 'c', kind: 'cluster', magnitude: 3 })
|
||||
]);
|
||||
const materials = new Set((renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material));
|
||||
|
||||
expect(materials.size).toBe(3);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives different brightness bands different materials', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'galaxy', magnitude: 9 })
|
||||
]);
|
||||
const [bright, faint] = renderer.object.children as THREE.Sprite[];
|
||||
|
||||
expect(bright.material).not.toBe(faint.material);
|
||||
expect(bright.material.opacity).toBeGreaterThan(faint.material.opacity);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps the material count bounded no matter how many objects there are', () => {
|
||||
const many = Array.from({ length: 200 }, (_, index) =>
|
||||
record({ id: `obj-${index}`, kind: (['galaxy', 'nebula', 'cluster'] as const)[index % 3], magnitude: index % 12 })
|
||||
);
|
||||
const renderer = new DeepSkyRenderer(many);
|
||||
const materials = new Set((renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material));
|
||||
|
||||
expect(renderer.object.children).toHaveLength(200);
|
||||
// Three kinds x three brightness bands.
|
||||
expect(materials.size).toBeLessThanOrEqual(9);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('renders behind the star field', () => {
|
||||
const renderer = new DeepSkyRenderer([record()]);
|
||||
expect(renderer.object.renderOrder).toBeLessThan(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('disposes its materials and empties the group', () => {
|
||||
const renderer = new DeepSkyRenderer([record({ id: 'a' }), record({ id: 'b', kind: 'nebula' })]);
|
||||
const materials = (renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material);
|
||||
const disposed = materials.map((material) => {
|
||||
let seen = false;
|
||||
material.addEventListener('dispose', () => (seen = true));
|
||||
return () => seen;
|
||||
});
|
||||
|
||||
renderer.dispose();
|
||||
|
||||
expect(renderer.object.children).toHaveLength(0);
|
||||
expect(disposed.every((wasDisposed) => wasDisposed())).toBe(true);
|
||||
});
|
||||
|
||||
it('handles an empty catalog', () => {
|
||||
const renderer = new DeepSkyRenderer([]);
|
||||
expect(renderer.object.children).toHaveLength(0);
|
||||
expect(renderer.labelPoints(5)).toEqual([]);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { DeepSkyKind, DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { createGlowTexture } from '../../shared/rendering/skybox';
|
||||
import { LabeledPoint } from './star-label-overlay';
|
||||
|
||||
/**
|
||||
* Radius (parsecs) of the shell the backdrop is painted on.
|
||||
*
|
||||
* Chosen to sit clear of everything else the galaxy camera deals with: well outside the 50 pc
|
||||
* star field, beyond the camera's 2000 pc orbit limit so it can never be flown through, and
|
||||
* close enough that even the far side of the shell (2000 + 2500 = 4500 pc) stays inside the
|
||||
* 5000 pc far plane rather than being clipped away.
|
||||
*/
|
||||
export const BACKDROP_RADIUS_PC = 2500;
|
||||
|
||||
/**
|
||||
* Apparent-size clamps (parsecs at {@link BACKDROP_RADIUS_PC}) for a backdrop sprite. The floor
|
||||
* is generous — most catalog objects are a few arcminutes across, and at this shell radius that
|
||||
* is a pixel or two — so they read as haze rather than as another star.
|
||||
*/
|
||||
const MIN_SPRITE_SIZE_PC = 70;
|
||||
const MAX_SPRITE_SIZE_PC = 340;
|
||||
|
||||
const DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
|
||||
/** Loosely evocative of each class's real appearance in long-exposure photography. */
|
||||
const KIND_COLORS: Readonly<Record<DeepSkyKind, number>> = {
|
||||
galaxy: 0xffd9a0,
|
||||
nebula: 0xff86b0,
|
||||
cluster: 0xa8c8ff
|
||||
};
|
||||
|
||||
/**
|
||||
* Opacity bands by apparent magnitude. Sprites share a material per (kind, band), so
|
||||
* brightness is quantised rather than continuous — nine materials instead of one per object,
|
||||
* which keeps 400-odd backdrop sprites cheap to build and dispose.
|
||||
*/
|
||||
const BRIGHTNESS_BANDS: readonly { maxMagnitude: number; opacity: number }[] = [
|
||||
{ maxMagnitude: 5, opacity: 0.5 },
|
||||
{ maxMagnitude: 7.5, opacity: 0.3 },
|
||||
{ maxMagnitude: Infinity, opacity: 0.16 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Where a deep-sky object lands on the backdrop shell. The record stores a unit direction,
|
||||
* so this is just that direction pushed out to the shell radius.
|
||||
*/
|
||||
export function backdropPosition(record: DeepSkyRecord, radiusPc = BACKDROP_RADIUS_PC): THREE.Vector3 {
|
||||
return new THREE.Vector3(record.x, record.y, record.z).multiplyScalar(radiusPc);
|
||||
}
|
||||
|
||||
/**
|
||||
* On-shell size for an object, from its true angular size — so the backdrop reproduces the
|
||||
* real sky, where the Andromeda Galaxy is six times wider than the full Moon.
|
||||
*
|
||||
* Clamped at both ends: without a floor, the many sub-arcminute objects would be invisible
|
||||
* specks, and without a ceiling a handful of very extended objects would blanket the view.
|
||||
*/
|
||||
export function backdropSpriteSizePc(record: DeepSkyRecord, radiusPc = BACKDROP_RADIUS_PC): number {
|
||||
const trueSize = radiusPc * record.angularSizeDeg * DEGREES_TO_RADIANS;
|
||||
return THREE.MathUtils.clamp(trueSize, MIN_SPRITE_SIZE_PC, MAX_SPRITE_SIZE_PC);
|
||||
}
|
||||
|
||||
/** Index into {@link BRIGHTNESS_BANDS}; unphotometered objects fall into the faintest band. */
|
||||
export function brightnessBandIndex(magnitude: number | null): number {
|
||||
if (magnitude === null) {
|
||||
return BRIGHTNESS_BANDS.length - 1;
|
||||
}
|
||||
const index = BRIGHTNESS_BANDS.findIndex((band) => magnitude <= band.maxMagnitude);
|
||||
return index === -1 ? BRIGHTNESS_BANDS.length - 1 : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `limit` most prominent objects, as label anchors on the backdrop shell. Prominence is
|
||||
* apparent magnitude, which is the order the ETL already writes, so this is a prefix of the
|
||||
* records that actually have a name worth showing.
|
||||
*/
|
||||
export function deepSkyLabelPoints(
|
||||
records: readonly DeepSkyRecord[],
|
||||
limit: number,
|
||||
radiusPc = BACKDROP_RADIUS_PC
|
||||
): LabeledPoint[] {
|
||||
return records.slice(0, limit).map((record) => {
|
||||
const position = backdropPosition(record, radiusPc);
|
||||
return { id: record.id, name: record.name, x: position.x, y: position.y, z: position.z };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the notable deep-sky objects from `deepsky.json` onto a fixed shell around the star
|
||||
* field, as soft additive billboards coloured by kind and sized by real angular extent.
|
||||
*
|
||||
* Billboards rather than a single `THREE.Points` cloud: the WebGPU backend caps point
|
||||
* primitives at one pixel (see `StarFieldRenderer`), which would reduce the Orion Nebula to a
|
||||
* dot. Sprites cost one draw call each, so materials are shared across all of them and the
|
||||
* catalog is pre-filtered by the ETL to the few hundred objects actually worth drawing.
|
||||
*/
|
||||
export class DeepSkyRenderer {
|
||||
readonly object = new THREE.Group();
|
||||
|
||||
private readonly materials = new Map<string, THREE.SpriteMaterial>();
|
||||
|
||||
constructor(
|
||||
private readonly records: readonly DeepSkyRecord[],
|
||||
private readonly radiusPc = BACKDROP_RADIUS_PC
|
||||
) {
|
||||
// Drawn before the star field so the stars composite on top of the glow.
|
||||
this.object.renderOrder = -1;
|
||||
|
||||
for (const record of records) {
|
||||
const sprite = new THREE.Sprite(this.materialFor(record));
|
||||
sprite.position.copy(backdropPosition(record, this.radiusPc));
|
||||
sprite.scale.setScalar(backdropSpriteSizePc(record, this.radiusPc));
|
||||
this.object.add(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
/** Label anchors for the brightest `limit` objects on this backdrop. */
|
||||
labelPoints(limit: number): LabeledPoint[] {
|
||||
return deepSkyLabelPoints(this.records, limit, this.radiusPc);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const material of this.materials.values()) {
|
||||
material.dispose();
|
||||
}
|
||||
this.materials.clear();
|
||||
this.object.clear();
|
||||
}
|
||||
|
||||
private materialFor(record: DeepSkyRecord): THREE.SpriteMaterial {
|
||||
const band = brightnessBandIndex(record.magnitude);
|
||||
const key = `${record.kind}:${band}`;
|
||||
|
||||
let material = this.materials.get(key);
|
||||
if (!material) {
|
||||
const color = KIND_COLORS[record.kind];
|
||||
material = new THREE.SpriteMaterial({
|
||||
map: createGlowTexture(color, 'diffuse'),
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: BRIGHTNESS_BANDS[band].opacity,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.materials.set(key, material);
|
||||
}
|
||||
return material;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
@@ -26,6 +27,21 @@ const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z:
|
||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||
|
||||
const DEEP_SKY_OBJECT: DeepSkyRecord = {
|
||||
id: 'NGC0224',
|
||||
name: 'Andromeda Galaxy',
|
||||
kind: 'galaxy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
angularSizeDeg: 2.96,
|
||||
magnitude: 3.44,
|
||||
distancePc: null,
|
||||
distanceMethod: null,
|
||||
constellation: 'And',
|
||||
messier: 'M31'
|
||||
};
|
||||
|
||||
const EARTH: BodyRecord = {
|
||||
id: 'earth',
|
||||
systemStarId: SUN.id,
|
||||
@@ -99,6 +115,10 @@ class FakeDataLoaderService {
|
||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
return Promise.resolve([DEEP_SKY_OBJECT]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits out several macrotask turns so chained promises (bootstrap's awaits) settle. */
|
||||
|
||||
@@ -7,14 +7,16 @@ 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 { DeepSkyRecord } from '../../shared/models/deepsky.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 { DeepSkyRenderer } from './deep-sky-renderer';
|
||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||
import { StarLabelOverlay } from './star-label-overlay';
|
||||
import { LabeledPoint, 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. */
|
||||
@@ -25,6 +27,12 @@ const SUN_GLOW_SCALE = 3.2;
|
||||
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 many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
|
||||
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
|
||||
* always named.
|
||||
*/
|
||||
const DEEP_SKY_LABEL_COUNT = 12;
|
||||
/** 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. */
|
||||
@@ -99,6 +107,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private controls?: OrbitControls;
|
||||
private rig?: CameraRigController;
|
||||
private starField?: StarFieldRenderer;
|
||||
private deepSky?: DeepSkyRenderer;
|
||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||
private labelOverlay?: StarLabelOverlay;
|
||||
private stars: readonly StarRecord[] = [];
|
||||
private starsById = new Map<number, StarRecord>();
|
||||
@@ -140,6 +150,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
|
||||
this.controls?.dispose();
|
||||
this.starField?.dispose();
|
||||
this.deepSky?.dispose();
|
||||
this.labelOverlay?.dispose();
|
||||
this.systemRenderer?.dispose();
|
||||
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
|
||||
@@ -182,10 +193,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.systemGroup.visible = false;
|
||||
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
|
||||
|
||||
const [{ stars, positions }, bodies, exoplanets] = await Promise.all([
|
||||
const [{ stars, positions }, bodies, exoplanets, deepSky] = await Promise.all([
|
||||
this.dataLoader.loadStars(),
|
||||
this.dataLoader.loadBodies(),
|
||||
this.dataLoader.loadExoplanets()
|
||||
this.dataLoader.loadExoplanets(),
|
||||
// The backdrop is decorative — if its dataset is missing or malformed the star field
|
||||
// should still come up, so this one failure is swallowed rather than aborting bootstrap.
|
||||
this.dataLoader.loadDeepSky().catch((error) => {
|
||||
console.error('Failed to load the deep-sky backdrop; continuing without it.', error);
|
||||
return [] as DeepSkyRecord[];
|
||||
})
|
||||
]);
|
||||
this.stars = stars;
|
||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||
@@ -195,6 +212,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.starField = new StarFieldRenderer(stars, positions);
|
||||
this.galaxyGroup.add(this.starField.object);
|
||||
|
||||
if (deepSky.length > 0) {
|
||||
this.deepSky = new DeepSkyRenderer(deepSky);
|
||||
this.galaxyGroup.add(this.deepSky.object);
|
||||
this.deepSkyLabels = this.deepSky.labelPoints(DEEP_SKY_LABEL_COUNT);
|
||||
}
|
||||
|
||||
this.labelOverlay = new StarLabelOverlay(scene);
|
||||
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
@@ -244,7 +267,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
|
||||
this.labelOverlay?.update(candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star));
|
||||
const starLabels = candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star);
|
||||
this.labelOverlay?.update([...starLabels, ...this.deepSkyLabels]);
|
||||
}
|
||||
|
||||
private readonly handleClick = (event: MouseEvent): void => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import * as THREE from 'three/webgpu';
|
||||
import { CSS2DObject, CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
|
||||
|
||||
export interface LabeledPoint {
|
||||
id: number;
|
||||
/** Numeric for HYG stars, string for catalog designations such as deep-sky objects. */
|
||||
id: number | string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -19,7 +20,7 @@ export class StarLabelOverlay {
|
||||
readonly domElement: HTMLElement;
|
||||
|
||||
private readonly cssRenderer = new CSS2DRenderer();
|
||||
private readonly labelObjects = new Map<number, CSS2DObject>();
|
||||
private readonly labelObjects = new Map<number | string, CSS2DObject>();
|
||||
|
||||
constructor(private readonly scene: THREE.Scene) {
|
||||
this.cssRenderer.domElement.classList.add('star-label-layer');
|
||||
@@ -70,7 +71,7 @@ export class StarLabelOverlay {
|
||||
this.labelObjects.set(point.id, object);
|
||||
}
|
||||
|
||||
private removeLabel(id: number, object: CSS2DObject): void {
|
||||
private removeLabel(id: number | string, object: CSS2DObject): void {
|
||||
this.scene.remove(object);
|
||||
object.element.remove();
|
||||
this.labelObjects.delete(id);
|
||||
|
||||
Reference in New Issue
Block a user