Open the map out to the whole Milky Way

The map stopped at the catalogued 50 pc around the Sun — 0.33% of the
Galaxy's width — and looked like a point cloud with a search box.

Adds the galactic scale above it and the heads-up display the reference
map is built from.

The Galaxy is not a third coordinate space. It is the same parsec space
four orders of magnitude further out, so the model and the star field
crossfade against camera distance instead of switching, and the Sun stays
where it really is: 8.18 kpc out, on the Orion Spur, between the
Sagittarius and Perseus arms. The depth range scales with that distance —
one fixed near/far pair cannot both fly into a star and hold the Galaxy.

The structure in shared/astro/galaxy.ts is measured: the directions of the
centre and the north galactic pole, which fix the disc's 63 degree tilt
against the celestial equator; the Sun's galactocentric distance; and a
radius, azimuth and pitch angle per arm. The particles scattered around it
are not, and cannot be — dust hides the disc, so no catalogue holds the
Galaxy's stars. The view says so, and the model fades out before the
camera reaches the 50 pc where the real stars are.

The rest is the look: polar grids lying in the galactic plane with drop
lines from the Sun's neighbours, a scale ladder, a readout panel, range,
reticle and frame brackets. Two things had to give way for it. The
deep-sky shell is the sky as seen from here, so it dissolves rather than
letting the camera fly through a wall of nebulae, and so does the skybox,
which is a photograph taken from inside the thing now being viewed from
outside. Labels are picked by screen separation rather than distance
alone: the Sun's fifteen nearest neighbours are all inside four parsecs
and printed as one unreadable clump.

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-04 19:59:01 +00:00
parent 2f45fa7fef
commit 2e525fb5c3
19 changed files with 2166 additions and 46 deletions
@@ -0,0 +1,162 @@
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { galacticCentrePositionPc, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
import { galacticFrameQuaternion, galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
const SEGMENTS_PER_RING = 180;
function vertexAt(geometry: THREE.BufferGeometry, index: number): THREE.Vector3 {
const position = geometry.getAttribute('position');
return new THREE.Vector3(position.getX(index), position.getY(index), position.getZ(index));
}
describe('galacticFrameQuaternion', () => {
it('carries the local +Z onto the galactic normal, so a flat grid lands in the galactic plane', () => {
const rotated = new THREE.Vector3(0, 0, 1).applyQuaternion(galacticFrameQuaternion());
const normal = galacticNormal();
expect(rotated.x).toBeCloseTo(normal.x, 9);
expect(rotated.y).toBeCloseTo(normal.y, 9);
expect(rotated.z).toBeCloseTo(normal.z, 9);
});
it('tilts that plane the real angle away from the celestial equator', () => {
// The galactic and celestial poles are 62.9 degrees apart, so the planes are too.
const normal = galacticNormal();
expect((Math.acos(Math.abs(normal.z)) * 180) / Math.PI).toBeCloseTo(62.87, 1);
});
});
describe('PolarGridPlane', () => {
const rings = [10, 20, 50];
const spokes = 8;
const grid = new PolarGridPlane({ ringRadiiPc: rings, spokeCount: spokes, emphasisRadiiPc: [50] });
it('draws every ring segment and every spoke', () => {
expect(grid.object.geometry.getAttribute('position').count).toBe(rings.length * SEGMENTS_PER_RING * 2 + spokes * 2);
});
it('starts hidden, so a view that never zooms out never draws it', () => {
expect(grid.object.visible).toBe(false);
});
it('fades in and out with strength, and disappears outright at zero', () => {
grid.setStrength(1);
expect(grid.object.visible).toBe(true);
const full = (grid.object.material as THREE.LineBasicMaterial).opacity;
grid.setStrength(0.5);
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(full / 2, 6);
grid.setStrength(0);
expect(grid.object.visible).toBe(false);
});
it('clamps strength rather than letting opacity run past one', () => {
grid.setStrength(4);
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeLessThanOrEqual(1);
grid.setStrength(-1);
expect(grid.object.visible).toBe(false);
});
it('lies in the galactic plane through its centre once placed in the scene', () => {
grid.object.updateMatrixWorld(true);
const normal = galacticNormal();
for (const index of [0, 100, 1000, grid.object.geometry.getAttribute('position').count - 1]) {
const world = vertexAt(grid.object.geometry, index).applyMatrix4(grid.object.matrixWorld);
expect(world.dot(normal)).toBeCloseTo(0, 6);
}
});
it('sits on the galactic centre when given it, still in the plane', () => {
const centre = galacticCentrePositionPc();
const galacticGrid = new PolarGridPlane({
ringRadiiPc: [2500, 8178],
spokeCount: 4,
centrePc: new THREE.Vector3(centre.x, centre.y, centre.z)
});
galacticGrid.object.updateMatrixWorld(true);
const normal = galacticNormal();
const world = vertexAt(galacticGrid.object.geometry, 0).applyMatrix4(galacticGrid.object.matrixWorld);
// The centre is one Sun-height below the Sun's own plane, and the grid follows it there.
expect(world.dot(normal)).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
galacticGrid.dispose();
});
it('keeps the emphasised ring brighter than the rest', () => {
const colors = grid.object.geometry.getAttribute('color');
// Vertices are written ring by ring, in the order they were listed: 10 pc first, 50 pc last.
const innerBrightness = colors.getX(0) + colors.getY(0) + colors.getZ(0);
const emphasisIndex = 2 * SEGMENTS_PER_RING * 2;
const emphasisBrightness = colors.getX(emphasisIndex) + colors.getY(emphasisIndex) + colors.getZ(emphasisIndex);
expect(emphasisBrightness).toBeGreaterThan(innerBrightness);
});
});
describe('TetherField', () => {
it('drops each point onto the plane, straight down the galactic normal', () => {
const field = new TetherField(4);
const point = new THREE.Vector3(12, -7, 30);
field.setTargets([point]);
const geometry = field.object.geometry;
const top = vertexAt(geometry, 0);
const foot = vertexAt(geometry, 1);
const normal = galacticNormal();
expect(top.distanceTo(point)).toBeCloseTo(0, 4);
// The foot is in the plane...
expect(foot.dot(normal)).toBeCloseTo(0, 4);
// ...and directly below the point: the drop has no sideways component.
const drop = top.clone().sub(foot);
expect(drop.clone().cross(normal).length()).toBeCloseTo(0, 4);
field.dispose();
});
it('drops onto an offset plane when asked, for a grid on the true midplane', () => {
const field = new TetherField(2);
field.setTargets([new THREE.Vector3(0, 0, 100)], -SUN_HEIGHT_ABOVE_MIDPLANE_PC);
const foot = vertexAt(field.object.geometry, 1);
expect(foot.dot(galacticNormal())).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
field.dispose();
});
it('draws two vertices per tether and nothing for the ones it was not given', () => {
const field = new TetherField(8);
field.setTargets([new THREE.Vector3(1, 2, 3), new THREE.Vector3(4, 5, 6)]);
expect(field.object.geometry.drawRange.count).toBe(4);
field.setTargets([]);
expect(field.object.geometry.drawRange.count).toBe(0);
field.dispose();
});
it('drops points past its capacity rather than overrunning the buffer', () => {
const field = new TetherField(2);
const points = [new THREE.Vector3(1, 0, 5), new THREE.Vector3(2, 0, 5), new THREE.Vector3(3, 0, 5), new THREE.Vector3(4, 0, 5)];
expect(() => field.setTargets(points)).not.toThrow();
expect(field.object.geometry.drawRange.count).toBe(4);
expect(field.object.geometry.getAttribute('position').count).toBe(4);
field.dispose();
});
it('stays hidden until it is given a strength', () => {
const field = new TetherField(2);
expect(field.object.visible).toBe(false);
field.setStrength(1);
expect(field.object.visible).toBe(true);
field.setStrength(0);
expect(field.object.visible).toBe(false);
field.dispose();
});
});