Widen the star catalogue, and separate what is drawn from what is known

The map held 8750 stars within 50 pc and rendered 371 systems. Both were
lower than they needed to be, for different reasons.

The star catalogue was capped by its own encoding as much as by the
cutoff: one JSON object per star, eight key names repeated each time, 157
bytes a star. At the range HYG actually reaches that is 17 MB to download
and parse before the first frame. So the numbers move into two binary
column stores — positions in stars.bin, which the GPU is handed verbatim,
and id/magnitude/colour/spectral index in stars-meta.bin — and the JSON
keeps only the strings, with 2600 distinct spectral classifications
collapsed to a dictionary. The layout is defined once, in star-catalog.ts,
and the ETL and the app both use it, so the writer and the reader cannot
drift.

The cutoff then goes to 250 pc: 68388 stars, 7.8x as many for 1.7x the
bytes. That is where HYG's measurements stop rather than a round number —
98.6% of its rows are Hipparcos, whose parallaxes are good to about a
milliarcsecond, so beyond 250 pc it would be plotting noise.

Drawing all of them is a separate question from knowing them, and it is
answered separately. The field draws a budget: every star inside 25 pc,
because the nearest are faint red dwarfs and Proxima Centauri is magnitude
11, then the brightest of everything beyond. Search, navigation and the
planet cross-reference still see the whole catalogue. A real GPU would
draw all 68388 without noticing; the budget is for the machines that would
not, and it is one constant.

Systems were limited by something else entirely. The archive data already
shipped named 4735 host stars and only 388 resolved, because the rest lay
outside a 50 pc catalogue — and the cross-reference kept only its own
result, so redoing it meant re-downloading an archive that is not
reachable from here. Host coordinates are now stored with each planet, and
the match is re-resolved at build time against whatever catalogue the run
produced. Even name matching alone, which needs no coordinates and so
works on the records already shipped, rescues 335 planets across 238
systems: 371 renderable systems become 609.

Two selection rules were tuned for a 50 pc bubble and no longer fit.
Tethers followed the Sun's nearest neighbours, which are a speck at this
range, and now follow the brightest; labels were ranked by proximity,
which named whatever sat nearest the middle of the screen, and are now
ranked by brightness — so the view names Canopus, Achernar and Spica
rather than a clump of catalogue designations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-05 08:35:41 +00:00
parent 029162ff52
commit 29fd92d118
20 changed files with 692 additions and 49 deletions
@@ -2,7 +2,7 @@ import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { StarRecord } from '../../shared/models/star.model';
import { colorIndexToRgb, magnitudeToPointSize, StarFieldRenderer } from './star-field-renderer';
import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer';
function star(overrides: Partial<StarRecord> = {}): StarRecord {
return {
@@ -223,3 +223,71 @@ describe('StarFieldRenderer', () => {
});
});
});
/** A star at a given distance along +X, with a given apparent magnitude. */
function catalogueStar(id: number, distancePc: number, magnitude: number): StarRecord {
return { id, name: `star-${id}`, x: distancePc, y: 0, z: 0, magnitude, spectralType: 'G2V', colorIndex: 0.6 };
}
describe('selectDrawnStars', () => {
it('draws everything when the catalogue fits the budget', () => {
const catalogue = [catalogueStar(1, 10, 5), catalogueStar(2, 20, 6)];
expect(Array.from(selectDrawnStars(catalogue, 10))).toEqual([0, 1]);
});
it('never draws more than the budget', () => {
const catalogue = Array.from({ length: 500 }, (_, i) => catalogueStar(i, 200, i));
expect(selectDrawnStars(catalogue, 50)).toHaveLength(50);
});
it('keeps the whole solar neighbourhood, however faint', () => {
// The load-bearing case: the nearest stars are overwhelmingly faint red dwarfs, and Proxima
// Centauri is magnitude 11. A pure brightness cut would delete the part of the map that
// matters most and holds the nearby planets.
const proxima = catalogueStar(999, 1.3, 11.1);
const catalogue = [proxima, ...Array.from({ length: 200 }, (_, i) => catalogueStar(i, 240, 2))];
const drawn = selectDrawnStars(catalogue, 20);
expect(Array.from(drawn)).toContain(0);
expect(drawn).toHaveLength(20);
});
it('spends what is left on the brightest stars beyond the neighbourhood', () => {
const catalogue = [catalogueStar(0, 10, 12), catalogueStar(1, 200, 8), catalogueStar(2, 200, 2), catalogueStar(3, 200, 5)];
const drawn = Array.from(selectDrawnStars(catalogue, 3));
// The nearby faint one, then the two brightest distant ones — not the magnitude-8 straggler.
expect(drawn).toEqual([0, 2, 3]);
});
it('returns catalogue indices in order, so positions can be subset alongside', () => {
const catalogue = Array.from({ length: 100 }, (_, i) => catalogueStar(i, 150, 100 - i));
const drawn = Array.from(selectDrawnStars(catalogue, 10));
expect(drawn).toEqual([...drawn].sort((a, b) => a - b));
});
});
describe('StarFieldRenderer render budget', () => {
it('draws only the budget, and reports how many that was', () => {
const catalogue = Array.from({ length: 300 }, (_, i) => catalogueStar(i, 200, i));
const positions = new Float32Array(catalogue.flatMap((s) => [s.x, s.y, s.z]));
const renderer = new StarFieldRenderer(catalogue, positions, 40);
expect(renderer.drawnCount).toBe(40);
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(40);
renderer.dispose();
});
it('keeps each drawn star with its own position after subsetting', () => {
// The subtle failure this guards: repacking positions for a subset while the colours and
// sizes follow a different order would give every star someone else's place in the sky.
const catalogue = [catalogueStar(0, 5, 9), catalogueStar(1, 200, 1), catalogueStar(2, 200, 7)];
const positions = new Float32Array(catalogue.flatMap((s) => [s.x, s.y, s.z]));
const renderer = new StarFieldRenderer(catalogue, positions, 2);
expect(renderer.drawnCount).toBe(2);
expect(renderer.starIdAt(0)).toBe(0);
expect(renderer.starIdAt(1)).toBe(1);
renderer.dispose();
});
});