Draw the system at true scale, drop the halo, and refuse to enter what is off screen

Three changes to what the system view claims, all of them the same claim: that
the sizes on screen mean something.

**The halo is gone.** It was a sprite sized against the arrival frame — 1.12 AU
for the Sun — so it stayed that wide as the camera closed in and ended up a flat
gradient filling the screen, over the photograph it was meant to dress. It
existed to keep the star visible at a framing that holds the whole system, which
is now handled in pixels instead.

**Bodies are drawn at their own radius.** The old marker size was exaggerated
and scaled to the system span, and clamped: Jupiter and Ganymede both ran past
the ceiling and were drawn at one radius, so every moon orbited inside its
planet, and Phobos and Triton sat entirely within Mars and Neptune. True scale
needs no rule against that — physics already puts a moon outside the planet it
orbits. What it costs is visibility at the arrival framing, where every body is
sub-pixel, so the scene floors each marker at 3 px on screen and holds a moon to
half its planet's drawn size. Measured in the Sun's system: at arrival, planets
3 px and moons 1.5 px, against 3 px for everything before; at Jupiter, the
planet 10.8 px at scale 1 with the Galilean moons on their orbits outside it.

The Sun is drawn at its own radius too. Every other star keeps a size derived
from its innermost orbit, because no stellar radius reaches the app — Gaia's
`radius_gspphot` is the obvious next fetch.

**A click cannot enter a system that is not on screen.** The picker tested depth
but not the frame, and a star's hit area is its drawn size plus a slop, so a
click in the last pixels of the view could fly into a system outside it, with
nothing on screen to explain where it had gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-21 17:26:32 +02:00
co-authored by Claude Opus 5
parent e45c3b6287
commit 3f0abf8717
6 changed files with 135 additions and 218 deletions
@@ -16,7 +16,7 @@ import { EngineService, SceneCamera } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model'; import { BodyRecord } from '../../shared/models/body.model';
import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { DeepSkyRecord } from '../../shared/models/deepsky.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { applyMilkyWaySkybox } from '../../shared/rendering/skybox';
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
import { isDesignation } from '../../shared/models/star-catalog'; import { isDesignation } from '../../shared/models/star-catalog';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
@@ -26,7 +26,7 @@ import { CameraRigController } from './camera-rig-controller';
import { DeepSkyRenderer } from './deep-sky-renderer'; import { DeepSkyRenderer } from './deep-sky-renderer';
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane'; import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
import { MilkyWayRenderer } from './milky-way-renderer'; import { MilkyWayRenderer } from './milky-way-renderer';
import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; import { starMarkerRadiusAu, SUN_RADIUS_AU, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity'; import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
import { distanceRings, formatRoundLength, scaleBar, type LengthUnit, type ScaleBar } from '../../shared/format/scale-bar'; import { distanceRings, formatRoundLength, scaleBar, type LengthUnit, type ScaleBar } from '../../shared/format/scale-bar';
import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { BodyDetailViewModel } from '../body-detail/body-detail.model';
@@ -50,8 +50,8 @@ import { SystemOrbitsRenderer } from './system-orbits-renderer';
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */ /** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
const SOL_STAR_ID = 0; const SOL_STAR_ID = 0;
/** Stars drawn from a colour rather than a photograph get a more restrained halo. */ /** Radius, in CSS pixels, below which a body in the system view is scaled up to be seen at all. */
const DIM_STAR_GLOW_SCALE = 0.6; const MIN_MARKER_PIXELS = 3;
/** /**
* How far from what the camera is looking at a star can be and still be named, as a fraction of * How far from what the camera is looking at a star can be and still be named, as a fraction of
@@ -465,7 +465,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private currentStarId: number | null = null; private currentStarId: number | null = null;
private systemRenderer?: SystemOrbitsRenderer; private systemRenderer?: SystemOrbitsRenderer;
private starMarker?: THREE.Mesh; private starMarker?: THREE.Mesh;
private starGlow?: THREE.Sprite;
constructor( constructor(
private readonly engine: EngineService, private readonly engine: EngineService,
@@ -513,7 +512,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.labelOverlay?.dispose(); this.labelOverlay?.dispose();
this.systemRenderer?.dispose(); this.systemRenderer?.dispose();
(this.starMarker?.material as THREE.Material | undefined)?.dispose(); (this.starMarker?.material as THREE.Material | undefined)?.dispose();
(this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose();
this.starMarkerGeometry?.dispose(); this.starMarkerGeometry?.dispose();
this.starMarkerMaterial.dispose(); this.starMarkerMaterial.dispose();
this.engine.dispose(); this.engine.dispose();
@@ -704,12 +702,58 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.systemGroup.visible) { if (this.systemGroup.visible) {
this.systemRenderer?.update(dateToJulianDate()); this.systemRenderer?.update(dateToJulianDate());
this.keepMarkersLegible(camera);
} }
this.updateSelectionMark(camera); this.updateSelectionMark(camera);
this.updateNeighbourRing(camera); this.updateNeighbourRing(camera);
this.labelOverlay?.render(camera); this.labelOverlay?.render(camera);
} }
/**
* Holds every body in the system view to a minimum size on screen, by scaling the markers that
* would otherwise be smaller than {@link MIN_MARKER_PIXELS}.
*
* A system is framed to hold its outermost orbit, and at that distance the bodies on the inner
* ones are sub-pixel: at the solar system's arrival distance Jupiter projects to about a pixel
* and Earth to less, so the labels and the selection arcs point at nothing. The halo used to
* cover the star's half of this — a light that reached past the innermost orbit, claiming
* brightness rather than size — but it covered the star only, and at a fixed extent that filled
* the screen once the camera closed in.
*
* Sizing in pixels instead keeps the exaggeration where it is needed and takes it away where it
* is not: a body whose true radius already spans more than the floor is drawn at that radius, so
* zooming in walks back to the real proportions rather than away from them.
*/
private keepMarkersLegible(camera: SceneCamera): void {
const heightPx = this.canvasRef().nativeElement.clientHeight;
if (!this.systemRenderer || heightPx === 0) {
return;
}
const world = new THREE.Vector3();
const drawnRadiusAu = new Map<string, number>();
const radiusOf = (marker: THREE.Object3D): number | undefined => ((marker as THREE.Mesh).geometry as THREE.SphereGeometry | undefined)?.parameters?.radius;
const floorFor = (marker: THREE.Object3D): number => {
marker.getWorldPosition(world);
return MIN_MARKER_PIXELS * ((2 * this.engine.visibleHalfHeight(camera.position.distanceTo(world))) / heightPx);
};
// Parents first: a moon's ceiling is its planet's drawn radius, which has to be known by then.
const members = [...this.systemRenderer.members].sort((a, b) => Number(a.kind === 'moon') - Number(b.kind === 'moon'));
for (const { id, marker, parentId } of this.starMarker ? [...members, { id: 'star', marker: this.starMarker, parentId: undefined }] : members) {
const radiusAu = radiusOf(marker);
if (!radiusAu) {
continue;
}
// Lifted to the floor, but never past half of what it orbits: at the arrival framing every
// body is sub-pixel, and floored on its own a moon comes out the size of its planet and
// sitting on top of it — which is the thing true scale was adopted to stop.
const ceiling = parentId !== undefined ? (drawnRadiusAu.get(parentId) ?? Number.POSITIVE_INFINITY) / 2 : Number.POSITIVE_INFINITY;
const drawn = Math.min(Math.max(radiusAu, floorFor(marker)), Math.max(radiusAu, ceiling));
drawnRadiusAu.set(id, drawn);
marker.scale.setScalar(drawn / radiusAu);
}
}
/** /**
* Blends between the two things that share parsec space: the catalogued star field with its * Blends between the two things that share parsec space: the catalogued star field with its
* local grid, and the Milky Way model with its galactic one. Driven by how far the camera * local grid, and the Milky Way model with its galactic one. Driven by how far the camera
@@ -1811,11 +1855,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.systemGroup.remove(this.starMarker); this.systemGroup.remove(this.starMarker);
(this.starMarker.material as THREE.Material).dispose(); (this.starMarker.material as THREE.Material).dispose();
} }
if (this.starGlow) {
this.systemGroup.remove(this.starGlow);
(this.starGlow.material as THREE.SpriteMaterial).dispose();
this.starGlow = undefined;
}
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id); const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id); const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
@@ -1838,10 +1877,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const framingCamera = this.engine.getPerspectiveCamera(); const framingCamera = this.engine.getPerspectiveCamera();
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect }; const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect };
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport); const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
// Sized against this system's innermost orbit, so the star never swallows its own planets. // The Sun at its own radius; every other star sized against its innermost orbit, which is all
const starRadiusAu = starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu); // the catalogue supports, and which at least never lets it swallow its own planets.
const starRadiusAu = star.id === SOL_STAR_ID ? SUN_RADIUS_AU : starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu);
this.starMarkerGeometry?.dispose(); this.starMarkerGeometry?.dispose();
this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 24, 16); this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 24, 16);
@@ -1852,13 +1891,14 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// other point in the galaxy view is far too distant to be resolved as a disk. // other point in the galaxy view is far too distant to be resolved as a disk.
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH); starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
starMarkerMaterial.color.set(0xffffff); starMarkerMaterial.color.set(0xffffff);
this.starGlow = createGlowSprite(0xfff2c0, starGlowExtentAu(starRadiusAu, frameRadiusAu));
} else { } else {
starMarkerMaterial.color.copy(starColor); starMarkerMaterial.color.copy(starColor);
this.starGlow = createGlowSprite(starColor, starGlowExtentAu(starRadiusAu, frameRadiusAu, DIM_STAR_GLOW_SCALE));
} }
// No halo. It was a sprite sized against the arrival frame — 1.12 AU for the Sun — so it
// stayed put as the camera closed in and ended up filling the screen with the flat gradient
// that was meant to dress the star, over the photograph underneath it.
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial); this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
this.systemGroup.add(this.starMarker, this.starGlow); this.systemGroup.add(this.starMarker);
this.galaxyGroup.visible = false; this.galaxyGroup.visible = false;
this.systemGroup.visible = true; this.systemGroup.visible = true;
@@ -172,6 +172,23 @@ describe('StarFieldRenderer', () => {
renderer.dispose(); renderer.dispose();
}); });
it('ignores a star just outside the frame, however close the pointer gets to the edge', () => {
// Its hit area is the drawn size plus a slop, so near an edge that area reaches past the
// frame — and a system nobody can see is not one a click should fly into.
const offScreen = [star({ id: 9, x: 0, y: 0, z: -10, magnitude: -2 })];
const renderer = new StarFieldRenderer(offScreen, packPositions(offScreen));
const centre = new THREE.Vector3(0, 0, -10).project(camera);
expect(renderer.pickAt(new THREE.Vector2(centre.x, centre.y), camera, camera.aspect)).toBe(9);
// The same star, now a hair outside the top of the frame.
const above = [star({ id: 9, x: 0, y: 10 * Math.tan((camera.fov * Math.PI) / 360) * 1.02, z: -10, magnitude: -2 })];
const outside = new StarFieldRenderer(above, packPositions(above));
expect(outside.pickAt(new THREE.Vector2(0, 0.99), camera, camera.aspect)).toBeUndefined();
renderer.dispose();
outside.dispose();
});
it('picks the star nearest the pointer when several are in view', () => { it('picks the star nearest the pointer when several are in view', () => {
const spread = [ const spread = [
star({ id: 1, x: 0, y: 0, z: -10 }), star({ id: 1, x: 0, y: 0, z: -10 }),
@@ -406,6 +406,11 @@ export class StarFieldRenderer {
* needed: each star is tested against the size it is actually drawn at, so the hit area matches * needed: each star is tested against the size it is actually drawn at, so the hit area matches
* what the user sees at every zoom level instead of being over-permissive up close and * what the user sees at every zoom level instead of being over-permissive up close and
* sub-pixel at the far end of the camera's range. * sub-pixel at the far end of the camera's range.
*
* Only stars on screen can be picked. The hit area is the drawn size plus a slop of
* {@link PICK_NDC_SLOP}, and near an edge that slop reaches past the frame: a click in the
* last few pixels of the view used to be able to fly into a system whose star was outside it,
* with nothing on screen to explain where it had gone.
*/ */
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined { pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
// What a unit of angular size is worth on screen. Under perspective the field of view sets // What a unit of angular size is worth on screen. Under perspective the field of view sets
@@ -5,7 +5,6 @@ import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/co
import { import {
bodyMarkerRadiusAu, bodyMarkerRadiusAu,
DEFAULT_STAR_MARKER_RADIUS_AU, DEFAULT_STAR_MARKER_RADIUS_AU,
starGlowExtentAu,
starMarkerRadiusAu, starMarkerRadiusAu,
systemFrameRadiusAu, systemFrameRadiusAu,
systemFramingDistanceAu, systemFramingDistanceAu,
@@ -113,105 +112,6 @@ describe('systemFramingDistanceAu', () => {
}); });
}); });
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', () => { describe('the grid and the framing together', () => {
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */ /** 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 } { function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
@@ -278,54 +178,41 @@ describe('star and framing together', () => {
describe('bodyMarkerRadiusAu', () => { describe('bodyMarkerRadiusAu', () => {
const EARTH_RADIUS_KM = 6371; const EARTH_RADIUS_KM = 6371;
const SOLAR_SPAN_AU = 30.07; const KM_PER_AU = 149597870.7;
it('scales in proportion to the system span', () => { it('draws a body at its true size', () => {
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU); expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100); expect(bodyMarkerRadiusAu(696340)).toBeCloseTo(0.00465, 5); // the Sun
expect(compact / wide).toBeCloseTo(0.01, 6);
}); });
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => { it('keeps a moon smaller than its planet and outside it, which the exaggeration did not', () => {
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one // Jupiter and Ganymede both ran past the old 0.09 AU ceiling and came out one size, so
// planet swallowed the whole view. // Ganymede orbited inside Jupiter; Phobos and Triton sat entirely within Mars and Neptune.
for (const span of [0.06, 0.204, 1, 30.07, 800]) { const jupiter = bodyMarkerRadiusAu(69911);
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5); const ganymede = bodyMarkerRadiusAu(2634);
} const callisto = bodyMarkerRadiusAu(2410);
const GANYMEDE_SEMI_MAJOR_AXIS_AU = 0.007155;
expect(ganymede).toBeLessThan(jupiter);
expect(callisto).toBeLessThan(ganymede);
expect(jupiter + ganymede).toBeLessThan(GANYMEDE_SEMI_MAJOR_AXIS_AU);
}); });
it('gives compact and wide systems the same apparent marker size', () => { it('keeps Phobos outside Mars, where a marker scaled to the system buried it', () => {
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span); const PHOBOS_SEMI_MAJOR_AXIS_AU = 0.00006268;
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6); expect(bodyMarkerRadiusAu(3390) + bodyMarkerRadiusAu(11.27)).toBeLessThan(PHOBOS_SEMI_MAJOR_AXIS_AU);
}); });
it('still renders a bigger body as a bigger marker', () => { it('still renders a bigger body as a bigger marker', () => {
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU); expect(bodyMarkerRadiusAu(69911)).toBeGreaterThan(bodyMarkerRadiusAu(1188));
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', () => { it('falls back to an Earth for a body with no published radius', () => {
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU); for (const nothing of [undefined, 0, -1]) {
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU); expect(bodyMarkerRadiusAu(nothing as number | undefined)).toBeCloseTo(EARTH_RADIUS_KM / KM_PER_AU, 12);
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', () => { describe('systemGridRingsAu', () => {
@@ -27,31 +27,6 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
*/ */
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45; const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
/**
* Halo extent as a multiple of the star's own radius, and the floor on that extent as a
* fraction of the framed radius.
*
* The floor is what keeps a star visible. A system's star is sized against its *innermost*
* orbit — it must never swallow its closest planet — while the camera is placed to frame the
* *outermost* ring, and those differ by a factor of a hundred in the solar system. At the
* distance that fits Pluto in view, a disc that stays clear of Mercury is about one pixel
* across; there is no radius that satisfies both, because the information genuinely does not
* fit on one screen at that zoom.
*
* The halo resolves it, because light is not a surface: a glow that reaches past the innermost
* orbit does not claim the star is that large, it claims the star is bright. So the disc stays
* honest to the orbits and the halo is floored against the frame.
*
* The floor is set by what it must not cover. Its visual radius is half the extent, so a floor
* of `f` puts the halo's edge at `f / 2` of the frame radius — and the orbits it has to leave
* legible sit at their own fraction of that same radius. In the solar system, framed to hold
* Pluto, Venus's orbit is at 1.3% of the frame radius and Earth's at 1.8%, so a floor of 2%
* leaves both of them outside the halo. Mercury's, at 0.7%, is inside it — and would be at any
* halo large enough to see, since the orbit itself is only a few pixels wide there.
*/
const STAR_GLOW_TO_MARKER = 3.2;
const MIN_STAR_GLOW_TO_FRAME = 0.02;
/** /**
* Clear space left around the framed radius, as a fraction of it. The camera backs off this * Clear space left around the framed radius, as a fraction of it. The camera backs off this
* much further than the geometry strictly needs, so the outermost ring sits inside the frame * much further than the geometry strictly needs, so the outermost ring sits inside the frame
@@ -150,20 +125,6 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
return distanceAu * tightHalfExtent(viewport); return distanceAu * tightHalfExtent(viewport);
} }
/**
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
*
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
* Floored against the framed radius, so a star framed from far enough out to hold its whole
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
* caller dim the halo for stars drawn without a real photograph.
*/
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
return Math.max(fromStar, fromFrame);
}
/** /**
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin * Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
* around it. * around it.
@@ -223,32 +184,35 @@ export function systemGridRingsAu(outermostOrbitAu: number): number[] {
} }
/** /**
* Span of the solar system, in AU, used as the reference every other system's marker sizes are * A body is drawn at its true size. Astronomical Unit in kilometres, and what a body with no
* scaled against. The marker constants below were tuned by eye at this scale. * published radius is drawn as — Earth, which is the middle of the range for the exoplanets that
* reach here without one.
*/ */
const REFERENCE_SYSTEM_SPAN_AU = 30; const KM_PER_AU = 149597870.7;
const DEFAULT_BODY_RADIUS_KM = 6371;
/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */
const MIN_MARKER_RADIUS_AU = 0.012;
const MAX_MARKER_RADIUS_AU = 0.09;
/** Physical radius (km) that maps to one AU of marker radius before clamping. */
const MARKER_RADIUS_KM_PER_AU = 18000;
/** /**
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in. * The Sun's own radius, in AU — the one star whose size this map knows.
* *
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its * Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU * orbit rather than measured, because no stellar radius reaches the app: the catalogue carries
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth * positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so * here, and until the ETL fetches it, a system's star is the one body in the view that is not
* a single planet swallows the entire view. * to scale.
*
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
* planets as small dots on them.
*/ */
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number { export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU); /**
* Radius (AU) to draw a planet, moon or exoplanet marker at: its own, unexaggerated.
*
* Sizes used to be exaggerated and scaled to the system span, which is what made a moon the size
* of its planet — Jupiter and Ganymede both ran past the ceiling and were drawn at one radius, so
* every moon orbited inside its parent. True scale needs no rule to prevent that: physics already
* puts a moon outside the planet it orbits, and the Sun at a hundredth of Mercury’s orbit.
*
* What true scale costs is visibility at the framing that holds a whole system, where every body
* is sub-pixel. That is paid for on screen instead, in pixels, by the scene's `keepMarkersLegible`.
*/
export function bodyMarkerRadiusAu(radiusKm: number | undefined): number {
return (radiusKm && radiusKm > 0 ? radiusKm : DEFAULT_BODY_RADIUS_KM) / KM_PER_AU;
} }
@@ -18,6 +18,8 @@ export interface SystemMember {
id: string; id: string;
kind: SystemMemberKind; kind: SystemMemberKind;
marker: THREE.Object3D; marker: THREE.Object3D;
/** For a moon, the id of the body it orbits: what its drawn size is held against. */
parentId?: string;
} }
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0); const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
@@ -126,8 +128,8 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what * The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
* survives is essentially its average colour, and generating it costs well under a millisecond. * survives is essentially its average colour, and generating it costs well under a millisecond.
*/ */
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh { function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12); const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), 16, 12);
const material = appearance const material = appearance
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) }) ? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) }); : new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
@@ -241,7 +243,7 @@ export class SystemOrbitsRenderer {
continue; // orphaned moon reference; skip rather than crash. continue; // orphaned moon reference; skip rather than crash.
} }
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
members.push({ id: body.id, kind: 'moon', marker: moon.marker }); members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id });
} }
// Every exoplanet in a system shares the same line of sight, so the frame is built once. // Every exoplanet in a system shares the same line of sight, so the frame is built once.
@@ -374,7 +376,7 @@ export class SystemOrbitsRenderer {
appearance?: PlanetAppearance appearance?: PlanetAppearance
): TrackedTopLevelBody { ): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind, frame); const orbitLine = buildOrbitLine(elements, kind, frame);
const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); const marker = buildMarker(kind, radiusKm, appearance);
this.object.add(orbitLine, marker); this.object.add(orbitLine, marker);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material);
@@ -395,7 +397,9 @@ export class SystemOrbitsRenderer {
): TrackedMoon { ): TrackedMoon {
const pivot = new THREE.Group(); const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon', frame); const orbitLine = buildOrbitLine(elements, 'moon', frame);
const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); // A moon's own orbit is the thing it must not swallow: drawn at the system's exaggeration it
// is the same size as its planet, and every moon here orbits inside one.
const marker = buildMarker('moon', radiusKm, appearance);
pivot.add(orbitLine, marker); pivot.add(orbitLine, marker);
this.object.add(pivot); this.object.add(pivot);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);