Files
star-map/src/app/features/galaxy-system/system-framing.spec.ts
T
Claude 029162ff52 Lower the star halo floor so the inner orbits stay legible
The floor that stopped the Sun disappearing reached past Venus and up to
Earth, covering the two orbits it most needed to leave alone.

Halved, from 3.5% of the framed radius to 2%. The halo's visual radius is
half its extent, so that puts its edge at 1% of the framed radius, and the
orbits it has to clear sit at their own fraction of the same radius: in
the solar system, framed to hold Pluto, Venus is at 1.3% and Earth at
1.8%. Both are now outside it, and the star still reads at about nine
pixels across on a typical window.

Mercury, at 0.7%, is still inside — and would be at any halo large enough
to see, since its orbit is only three pixels wide at that range. That is
now a pinned test rather than an oversight.

The floor was only ever the lower bound; the tests now state the upper one
too, in the terms the trade is actually made in — pixels on screen for
visibility, AU against real orbits for clearance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-05 08:01:19 +00:00

428 lines
18 KiB
TypeScript

import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import {
bodyMarkerRadiusAu,
DEFAULT_STAR_MARKER_RADIUS_AU,
starGlowExtentAu,
starMarkerRadiusAu,
systemFrameRadiusAu,
systemFramingDistanceAu,
systemGridRingsAu,
SystemViewport,
SYSTEM_VIEW_DIRECTION_IN_PLANE,
systemViewDirection
} from './system-framing';
/** Real systems spanning the range the view has to cope with. */
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
const GL_357 = { innermost: 0.035, outermost: 0.204 };
const SOLAR = { innermost: 0.387, outermost: 30.07 };
describe('starMarkerRadiusAu', () => {
it('never reaches the innermost orbit', () => {
for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) {
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
}
});
it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => {
// Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system.
expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost);
expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost);
});
it('never grows beyond the default, however wide the system', () => {
expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU);
expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
});
it('scales in proportion to the innermost orbit', () => {
expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9);
});
it('falls back to the default when there are no planets to scale against', () => {
for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
}
});
it('stays positive for an extremely tight orbit', () => {
expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0);
});
});
describe('systemFramingDistanceAu', () => {
it('fits the radius it is given in view, with room around it', () => {
for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) {
expect(systemFrameRadiusAu(systemFramingDistanceAu(radius))).toBeGreaterThan(radius);
}
});
it('closes right in on a compact system instead of hanging back at a fixed floor', () => {
// The old floor was 3 AU — some 48x the width of the entire TRAPPIST-1 system.
expect(systemFramingDistanceAu(TRAPPIST_1.outermost)).toBeLessThan(1);
expect(systemFramingDistanceAu(GL_357.outermost)).toBeLessThan(1);
});
it('scales in proportion to the radius it has to frame', () => {
expect(systemFramingDistanceAu(0.2) / systemFramingDistanceAu(0.1)).toBeCloseTo(2, 9);
});
it('backs off further for a narrower field of view, which a fixed multiple could not', () => {
// The bug this replaced: the multiple was tuned by eye against a 55-degree field and the
// engine's camera is 50, so everything sat that much too close.
const wide = systemFramingDistanceAu(1, { fovDegrees: 70, aspect: 1.78 });
const narrow = systemFramingDistanceAu(1, { fovDegrees: 30, aspect: 1.78 });
expect(narrow).toBeGreaterThan(wide);
});
it('backs off further for a portrait window, where the horizontal axis is the tighter one', () => {
const landscape = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1.78 });
const portrait = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 0.6 });
expect(portrait).toBeCloseTo(landscape / 0.6, 6);
});
it('ignores aspect once the window is landscape, since the vertical binds there', () => {
const square = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1 });
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
});
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
});
it('reaches far enough to frame the solar system out to Pluto', () => {
// The old 80 AU ceiling could not: at the camera's real field of view this needs 120.
const rings = systemGridRingsAu(39.288);
const distance = systemFramingDistanceAu(rings[rings.length - 1]);
expect(distance).toBeLessThan(200);
expect(systemFrameRadiusAu(distance)).toBeGreaterThan(39.288);
});
it('stays outside the orbit controls minimum distance', () => {
// Framing closer than the controls allow would be clamped straight back out again.
expect(systemFramingDistanceAu(0.00001)).toBeGreaterThanOrEqual(0.05);
});
it('uses a sensible default for a star with no known planets', () => {
for (const radius of [0, -1, Number.NaN]) {
expect(systemFramingDistanceAu(radius)).toBe(3);
}
});
});
describe('starGlowExtentAu', () => {
/** A typical viewport, so a screen-space claim can be made in pixels rather than in ratios. */
const REFERENCE_VIEWPORT_HALF_HEIGHT_PX = 450;
/** The halo's visual radius, in AU, at the distance this system is framed from. */
function haloRadiusAu(innermostAu: number, outermostAu: number, glowScale = 1): number {
// The sprite's extent is its full width, so half of it is what reaches out from the star.
return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frameRadiusFor(outermostAu), glowScale) / 2;
}
function frameRadiusFor(outermostAu: number): number {
const rings = systemGridRingsAu(outermostAu);
return systemFrameRadiusAu(systemFramingDistanceAu(rings[rings.length - 1]));
}
/** Apparent size on screen, as a fraction of the frame's half-height. */
function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number {
return haloRadiusAu(innermostAu, outermostAu, glowScale) / frameRadiusFor(outermostAu);
}
function apparentPixels(innermostAu: number, outermostAu: number): number {
return apparentFraction(innermostAu, outermostAu) * REFERENCE_VIEWPORT_HALF_HEIGHT_PX;
}
it('scales with the star for a compact system, where the star is already big enough', () => {
// A tight frame relative to the star, so the star's own multiple is what decides.
const marker = 0.02;
const tightFrame = 0.5;
expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9);
expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9);
});
it('floors against the frame once the star would otherwise vanish into it', () => {
// A star sized against a close-in orbit, framed from far enough out to hold a wide system:
// the multiple of the star is nothing, so the frame decides instead.
const tinyStar = 0.001;
const wideFrame = 56;
expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100);
});
it('keeps the Sun visible at the distance that frames the solar system', () => {
// The case that prompted this: the solar system spans a factor of a hundred from Mercury to
// Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view.
expect(apparentPixels(0.387, 39.288)).toBeGreaterThan(4);
});
it('leaves the inner orbits clear of the halo', () => {
// The other half of the same trade. Venus and Earth have to stay legible as rings around the
// star, which bounds the halo from above just as visibility bounds it from below.
const halo = haloRadiusAu(0.387, 39.288);
const VENUS_AU = 0.723;
const EARTH_AU = 1;
expect(halo).toBeLessThan(VENUS_AU);
expect(halo).toBeLessThan(EARTH_AU);
});
it('cannot clear Mercury as well, and does not pretend to', () => {
// Mercury's orbit is 0.7% of the framed radius — about three pixels — so it is inside any
// halo big enough to see. Pinned so the trade is a decision rather than an oversight.
expect(haloRadiusAu(0.387, 39.288)).toBeGreaterThan(0.387);
});
it('holds the floor across every system scale the datasets contain', () => {
// A compact system's star is genuinely large relative to its own system and keeps the bigger
// halo; the floor is not there to equalise them, only to stop the wide ones disappearing.
for (const [innermost, outermost] of [
[0.387, 39.288],
[0.035, 0.204],
[0.01154, 0.06189],
[1.2, 12.4]
]) {
expect(apparentPixels(innermost, outermost)).toBeGreaterThan(4);
}
});
it('does not blot out the system it sits in', () => {
for (const [innermost, outermost] of [
[0.387, 39.288],
[0.035, 0.204],
[0.01154, 0.06189]
]) {
expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2);
}
});
it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => {
// Above the floor the multiplier applies...
expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1));
// ...and at the floor it cannot dim a star into invisibility.
expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1));
});
it('falls back to the star alone when there is no frame to measure against', () => {
for (const frame of [0, -1, Number.NaN]) {
expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9);
}
});
});
describe('the grid and the framing together', () => {
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
const rings = systemGridRingsAu(outermostOrbitAu);
const ring = rings[rings.length - 1];
return { ring, frame: systemFrameRadiusAu(systemFramingDistanceAu(ring, viewport), viewport) };
}
const VIEWPORTS: SystemViewport[] = [
{ fovDegrees: 50, aspect: 1.78 },
{ fovDegrees: 50, aspect: 1 },
{ fovDegrees: 50, aspect: 0.6 }
];
it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => {
// The whole point of framing against the grid rather than the orbits: before this, 368 of
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
for (const viewport of VIEWPORTS) {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
const { ring, frame } = fit(outermost, viewport);
expect(ring).toBeLessThan(frame);
expect(ring / frame).toBeLessThan(0.93);
}
}
});
it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
expect(fit(outermost).ring).toBeGreaterThan(outermost);
}
});
it('does not overshoot either: the grid still fills most of the frame', () => {
// A margin is not the same as framing a system from orbit. Half the frame empty would be as
// wrong as none of it.
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) {
const { ring, frame } = fit(outermost);
expect(ring / frame).toBeGreaterThan(0.6);
}
});
});
describe('star and framing together', () => {
it('gives compact and wide systems a comparable apparent star size', () => {
// Both scale with the system, so the star subtends a similar angle either way — the point
// of deriving them from the same measurements rather than fixing them.
const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) =>
starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost);
const compact = apparent(TRAPPIST_1);
const midRange = apparent(GL_357);
expect(compact).toBeGreaterThan(0);
expect(compact / midRange).toBeGreaterThan(0.25);
expect(compact / midRange).toBeLessThan(4);
});
it('always leaves the innermost orbit outside the star, at every scale', () => {
for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) {
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
}
});
});
describe('bodyMarkerRadiusAu', () => {
const EARTH_RADIUS_KM = 6371;
const SOLAR_SPAN_AU = 30.07;
it('scales in proportion to the system span', () => {
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU);
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100);
expect(compact / wide).toBeCloseTo(0.01, 6);
});
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => {
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one
// planet swallowed the whole view.
for (const span of [0.06, 0.204, 1, 30.07, 800]) {
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5);
}
});
it('gives compact and wide systems the same apparent marker size', () => {
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span);
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6);
});
it('still renders a bigger body as a bigger marker', () => {
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU);
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
expect(jupiter).toBeGreaterThan(pluto);
});
it('falls back to the smallest marker for a body with no known radius', () => {
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU);
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
expect(unknown).toBeGreaterThan(0);
expect(unknown).toBeLessThanOrEqual(pluto);
});
it('treats a missing span as the reference scale rather than collapsing to zero', () => {
for (const span of [0, -5, Number.NaN]) {
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0);
}
});
it('leaves the solar system essentially as it was before scaling', () => {
// The constants were tuned at this span, so the scale factor here is ~1.
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2);
});
});
describe('systemGridRingsAu', () => {
it('reaches past the outermost orbit, so no planet sits off the edge of the grid', () => {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) {
const rings = systemGridRingsAu(outermost);
expect(rings.length).toBeGreaterThan(0);
expect(rings[rings.length - 1]).toBeGreaterThan(outermost);
}
});
it('gives a legible handful of rings at every scale, four orders of magnitude apart', () => {
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 650 }]) {
const rings = systemGridRingsAu(outermost);
expect(rings.length).toBeGreaterThanOrEqual(3);
expect(rings.length).toBeLessThanOrEqual(10);
}
});
it('spaces them evenly, on a round number', () => {
const rings = systemGridRingsAu(SOLAR.outermost);
// The solar system reads in 5 AU steps: 5, 10, ... out past Neptune at 30.07.
expect(rings).toEqual([5, 10, 15, 20, 25, 30, 35]);
});
it('scales the step down to the system rather than defaulting to whole AU', () => {
// TRAPPIST-1's outermost planet orbits at 0.062 AU. Whole-AU rings would put the entire
// system inside the first one.
const rings = systemGridRingsAu(TRAPPIST_1.outermost);
expect(rings[0]).toBeLessThan(TRAPPIST_1.outermost / 2);
for (const radius of rings) {
expect(Number.isFinite(radius)).toBe(true);
expect(radius).toBeGreaterThan(0);
}
});
it('keeps the step free of floating-point drift, so labels would read cleanly', () => {
for (const radius of systemGridRingsAu(TRAPPIST_1.outermost)) {
// Multiplying the step out rather than accumulating it keeps these exact to 1e-12.
expect(Math.abs(radius * 1000 - Math.round(radius * 1000))).toBeLessThan(1e-9);
}
});
it('draws no grid for a system with nothing to measure against', () => {
for (const outermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(systemGridRingsAu(outermost)).toEqual([]);
}
});
});
describe('systemViewDirection', () => {
const RAD_TO_DEG = 180 / Math.PI;
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), (OBLIQUITY_J2000_DEG * Math.PI) / 180);
/** Angle between the camera direction and the plane's own normal, in degrees. */
function angleFromNormalDeg(frame: THREE.Quaternion): number {
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(frame);
return Math.acos(Math.abs(systemViewDirection(frame).dot(normal))) * RAD_TO_DEG;
}
it('returns a unit direction', () => {
expect(systemViewDirection(ECLIPTIC_FRAME).length()).toBeCloseTo(1, 12);
});
it('holds the same three-quarter angle to the plane whatever plane that is', () => {
// The whole point: one fixed direction in the scene's frame would be face-on for the solar
// system and edge-on for an exoplanet system measured against the plane of the sky.
const skyPlanes = [
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0.3, -0.5, 0.81).normalize()),
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(-1, 0, 0)),
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0))
];
// atan(0.6 / 0.8) — the angle the in-plane direction was chosen at, held exactly.
const expected = Math.atan2(SYSTEM_VIEW_DIRECTION_IN_PLANE.y, SYSTEM_VIEW_DIRECTION_IN_PLANE.z) * RAD_TO_DEG;
for (const frame of [ECLIPTIC_FRAME, ...skyPlanes]) {
expect(angleFromNormalDeg(frame)).toBeCloseTo(expected, 9);
}
});
it('is well clear of edge-on in every case, which is what it exists to prevent', () => {
for (const axis of [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0.2, 0.9, -0.4).normalize()]) {
const frame = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), axis);
expect(angleFromNormalDeg(frame)).toBeLessThan(60);
}
});
it('leaves the solar system framed exactly as the ecliptic conversion used to frame it', () => {
// The previous behaviour was correct for the one system whose elements are ecliptic; this
// pins that it did not move while the other systems were fixed.
const previous = eclipticToEquatorial(SYSTEM_VIEW_DIRECTION_IN_PLANE);
const current = systemViewDirection(ECLIPTIC_FRAME);
const length = Math.hypot(previous.x, previous.y, previous.z);
expect(current.x).toBeCloseTo(previous.x / length, 12);
expect(current.y).toBeCloseTo(previous.y / length, 12);
expect(current.z).toBeCloseTo(previous.z / length, 12);
});
});