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
141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { DISC_RADIUS_PC, equatorialToGalactic, SUN_GALACTOCENTRIC_RADIUS_PC, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
|
import { createRandom, DEFAULT_PARTICLE_COUNTS, generateMilkyWayParticles } from './milky-way-model';
|
|
|
|
/** A small, fast budget — the shape of the model does not depend on how many particles trace it. */
|
|
const TEST_COUNTS = { arms: 3000, disc: 1500, bulge: 1200, halo: 200 };
|
|
const TEST_BUDGET = TEST_COUNTS.arms + TEST_COUNTS.disc + TEST_COUNTS.bulge + TEST_COUNTS.halo;
|
|
|
|
/** Galactocentric radius and height above the midplane of the i-th particle, in parsecs. */
|
|
function galactocentric(positions: Float32Array, index: number): { radiusPc: number; heightPc: number } {
|
|
const galactic = equatorialToGalactic({ x: positions[index * 3], y: positions[index * 3 + 1], z: positions[index * 3 + 2] });
|
|
return {
|
|
radiusPc: Math.hypot(SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x, galactic.y),
|
|
heightPc: galactic.z + SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
|
};
|
|
}
|
|
|
|
function median(values: number[]): number {
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return sorted[Math.floor(sorted.length / 2)];
|
|
}
|
|
|
|
describe('createRandom', () => {
|
|
it('is deterministic for a given seed', () => {
|
|
const a = createRandom(7);
|
|
const b = createRandom(7);
|
|
for (let i = 0; i < 50; i++) {
|
|
expect(a()).toBe(b());
|
|
}
|
|
});
|
|
|
|
it('stays inside the unit interval', () => {
|
|
const random = createRandom(99);
|
|
for (let i = 0; i < 5000; i++) {
|
|
const value = random();
|
|
expect(value).toBeGreaterThanOrEqual(0);
|
|
expect(value).toBeLessThan(1);
|
|
}
|
|
});
|
|
|
|
it('produces different streams for different seeds', () => {
|
|
expect(createRandom(1)()).not.toBe(createRandom(2)());
|
|
});
|
|
});
|
|
|
|
describe('generateMilkyWayParticles', () => {
|
|
const particles = generateMilkyWayParticles(1234, TEST_COUNTS);
|
|
|
|
it('is the same Galaxy on every run, so the map does not reshuffle on reload', () => {
|
|
const again = generateMilkyWayParticles(1234, TEST_COUNTS);
|
|
expect(again.count).toBe(particles.count);
|
|
expect(Array.from(again.positions.slice(0, 300))).toEqual(Array.from(particles.positions.slice(0, 300)));
|
|
});
|
|
|
|
it('places most of the requested budget, rejecting only the samples that miss', () => {
|
|
expect(particles.count).toBeLessThanOrEqual(TEST_BUDGET);
|
|
expect(particles.count).toBeGreaterThan(TEST_BUDGET * 0.75);
|
|
});
|
|
|
|
it('emits finite positions, sizes and alphas throughout', () => {
|
|
for (let index = 0; index < particles.count; index++) {
|
|
expect(Number.isFinite(particles.positions[index * 3])).toBe(true);
|
|
expect(Number.isFinite(particles.positions[index * 3 + 1])).toBe(true);
|
|
expect(Number.isFinite(particles.positions[index * 3 + 2])).toBe(true);
|
|
expect(particles.sizes[index]).toBeGreaterThan(0);
|
|
expect(particles.alphas[index]).toBeGreaterThan(0);
|
|
expect(particles.alphas[index]).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
|
|
it('keeps every colour channel inside the displayable range', () => {
|
|
for (let index = 0; index < particles.count * 3; index++) {
|
|
expect(particles.colors[index]).toBeGreaterThanOrEqual(0);
|
|
expect(particles.colors[index]).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
|
|
it('keeps every particle inside the modelled galaxy, halo included', () => {
|
|
for (let index = 0; index < particles.count; index++) {
|
|
expect(galactocentric(particles.positions, index).radiusPc).toBeLessThan(DISC_RADIUS_PC * 1.3);
|
|
}
|
|
});
|
|
|
|
it('builds a disc rather than a ball: half the particles sit within 300 pc of the midplane', () => {
|
|
const heights: number[] = [];
|
|
for (let index = 0; index < particles.count; index++) {
|
|
heights.push(Math.abs(galactocentric(particles.positions, index).heightPc));
|
|
}
|
|
expect(median(heights)).toBeLessThan(300);
|
|
});
|
|
|
|
it('leaves the centre denser than the outskirts', () => {
|
|
let inner = 0;
|
|
let outer = 0;
|
|
for (let index = 0; index < particles.count; index++) {
|
|
const { radiusPc } = galactocentric(particles.positions, index);
|
|
if (radiusPc < 4000) {
|
|
inner++;
|
|
} else if (radiusPc > 12000) {
|
|
outer++;
|
|
}
|
|
}
|
|
expect(inner).toBeGreaterThan(outer);
|
|
});
|
|
|
|
it('puts the Sun in the disc, not off its edge', () => {
|
|
// The whole point of the placement: the local star field has to sit inside the model, about
|
|
// half way out, rather than floating beside it.
|
|
let neighbours = 0;
|
|
for (let index = 0; index < particles.count; index++) {
|
|
const x = particles.positions[index * 3];
|
|
const y = particles.positions[index * 3 + 1];
|
|
const z = particles.positions[index * 3 + 2];
|
|
if (Math.hypot(x, y, z) < 2000) {
|
|
neighbours++;
|
|
}
|
|
}
|
|
expect(neighbours).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('spans a full turn in azimuth, so the arms wrap rather than forming a fan', () => {
|
|
const quadrants = new Set<number>();
|
|
for (let index = 0; index < particles.count; index++) {
|
|
const galactic = equatorialToGalactic({
|
|
x: particles.positions[index * 3],
|
|
y: particles.positions[index * 3 + 1],
|
|
z: particles.positions[index * 3 + 2]
|
|
});
|
|
const angle = Math.atan2(galactic.y, SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x);
|
|
quadrants.add(Math.floor(((angle + Math.PI) / (Math.PI / 2)) % 4));
|
|
}
|
|
expect(quadrants.size).toBe(4);
|
|
});
|
|
|
|
it('defaults to a budget big enough to read as a galaxy', () => {
|
|
expect(DEFAULT_PARTICLE_COUNTS.arms).toBeGreaterThan(DEFAULT_PARTICLE_COUNTS.disc);
|
|
expect(DEFAULT_PARTICLE_COUNTS.halo).toBeLessThan(DEFAULT_PARTICLE_COUNTS.bulge);
|
|
});
|
|
});
|