Put a reference grid under the system view

A system was a handful of ellipses floating in the dark. You could see
that one orbit was bigger than another, but not how big, and not that a
planet sat above or below the plane the others share.

Adds the same plane-and-tether reading aid the outer scales got: a polar
grid in the system's own reference plane, with a drop line from each body
onto it.

Ring radii snap to a 1-2-5 ladder rather than dividing the system evenly,
because the point is to put a number on a distance — 5, 10, 15 AU can be
read at a glance and 4.34, 8.68, 13.02 cannot. That holds across the four
orders of magnitude real systems span: the solar system gets 5 AU rings,
TRAPPIST-1 gets 0.01 AU ones. The outermost ring encloses the outermost
orbit rather than falling just inside it.

The rings are dashed. Solid ones would sit in the same plane as the orbit
ellipses, which are themselves rings, and at a glance a reference circle
and a circular orbit are the same picture. Dashes are cut by dropping
whole segments rather than by a dashed material: the ring is already built
from independent segment pairs, so a material's dash pattern would restart
at every one.

Drawing the grid exposed a framing bug it made unmissable. The camera
settled along one fixed direction derived from the ecliptic, which is
face-on only for the one system whose elements are ecliptic. Every
exoplanet system — measured against the plane of the sky, perpendicular to
the line of sight to its own host star — was being presented nearly
edge-on, a smear of overlapping ellipses. The settle direction is now
taken relative to whichever plane the system was measured in, so all of
them read as discs. The solar system is unmoved, which a test pins.

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 20:32:31 +00:00
parent 2e525fb5c3
commit a84e2d3a69
9 changed files with 437 additions and 55 deletions
+7 -1
View File
@@ -138,7 +138,7 @@ interface DeepSkyRecord {
- `SearchComponent` — text search across `stars-index.json`, `bodies.json`, `exoplanets.json`; on match, dispatches a navigation action. - `SearchComponent` — text search across `stars-index.json`, `bodies.json`, `exoplanets.json`; on match, dispatches a navigation action.
- `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galactic' | 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view. - `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galactic' | 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view.
- `MilkyWayRenderer` / `milky-way-model.ts` — the Galaxy itself as an instanced particle cloud scattered around the structural model in `shared/astro/galaxy.ts`, crossfaded against the catalogued star field by camera distance. - `MilkyWayRenderer` / `milky-way-model.ts` — the Galaxy itself as an instanced particle cloud scattered around the structural model in `shared/astro/galaxy.ts`, crossfaded against the catalogued star field by camera distance.
- `PolarGridPlane` / `TetherField` (`grid-plane.ts`) — the reference plane the map is read against: rings and spokes lying in the galactic plane, plus drop lines from stars to it. - `PolarGridPlane` / `TetherField` (`grid-plane.ts`) — the reference plane a view is read against: rings and spokes lying in a given plane, plus drop lines onto it. Used at all three scales — the galactic plane, the Sun's plane, and each system's own orbital plane.
- `StarmapHudComponent` — the heads-up display: scale ladder, readout panel, range, reticle and frame. - `StarmapHudComponent` — the heads-up display: scale ladder, readout panel, range, reticle and frame.
### File Structure ### File Structure
@@ -291,3 +291,9 @@ The map opens out from the catalogued neighbourhood to the whole Milky Way, in t
- Add `MilkyWayRenderer`, crossfaded against the star field by camera distance so the two scales share one continuous parsec space rather than being separate scenes. - Add `MilkyWayRenderer`, crossfaded against the star field by camera distance so the two scales share one continuous parsec space rather than being separate scenes.
- Add the galactic and local grid planes, star tethers, and the HUD (scale ladder, readout panel, range, reticle, frame). - Add the galactic and local grid planes, star tethers, and the HUD (scale ladder, readout panel, range, reticle, frame).
- Label the Galaxy model as a model wherever it is shown: its skeleton is measured, its particles are not. - Label the Galaxy model as a model wherever it is shown: its skeleton is measured, its particles are not.
### ✓ Step 8: Put a reference grid under the system view
The same plane-and-tether reading aid the outer scales got, applied to a single system.
- Add `systemGridRingsAu`: ring radii snapped to a 1-2-5 ladder so a distance can be read off, at any of the four orders of magnitude real systems span.
- Draw the grid and the body tethers in the system's own reference plane — the ecliptic for the solar system, the plane of the sky otherwise — dashed, so it is never mistaken for an orbit.
- Frame the camera against that same plane, so an exoplanet system is presented face-on rather than edge-on.
+8 -2
View File
@@ -43,7 +43,10 @@ in it is measured and what is not.
**System view** — selecting a star flies the camera continuously into its system rather than **System view** — selecting a star flies the camera continuously into its system rather than
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other
stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated
along them by a Kepler solver against the current epoch. along them by a Kepler solver against the current epoch. Under them, a dashed grid marks out
round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a
drop line from each body, so eccentricity and inclination read against a circular reference
instead of having to be inferred from a shape in space.
**Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet, **Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet,
with real photography where NASA/ESA/USGS imagery exists. with real photography where NASA/ESA/USGS imagery exists.
@@ -66,7 +69,10 @@ same place an in-scene click would.
to each host star, which is why transiting planets cluster at 90°. Each set of elements is to each host star, which is why transiting planets cluster at 90°. Each set of elements is
rotated from its own reference plane into the scene's equatorial frame, so a direction means rotated from its own reference plane into the scene's equatorial frame, so a direction means
the same thing everywhere. Systems are still presented face-on — by placing the camera the same thing everywhere. Systems are still presented face-on — by placing the camera
relative to the orbital plane rather than by rotating the world into a convenient pose. relative to whichever plane that system's elements were measured in, rather than by rotating
the world into a convenient pose. That plane is per-system, not global: one fixed viewing
direction is face-on for the solar system and edge-on for an exoplanet system whose host star
lies elsewhere on the sky. It is also the plane the system's reference grid lies in.
- **Two coordinate scales.** The galaxy view works in parsecs and the system view in AU — - **Two coordinate scales.** The galaxy view works in parsecs and the system view in AU —
about eight orders of magnitude apart, which wrecks float precision if rendered in one unit about eight orders of magnitude apart, which wrecks float precision if rendered in one unit
space. The camera rig recentres the active star to the origin ("floating origin") and swaps space. The camera rig recentres the active star to the origin ("floating origin") and swaps
@@ -18,7 +18,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 { starMarkerRadiusAu, SYSTEM_VIEW_DIRECTION, systemFramingDistanceAu } from './system-framing'; import { starMarkerRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
import { HudReadout, StarmapHudComponent } from './starmap-hud.component'; import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer'; import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
@@ -332,15 +332,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.galacticLabels = this.milkyWay.labelPoints(); this.galacticLabels = this.milkyWay.labelPoints();
const centre = galacticCentrePositionPc(); const centre = galacticCentrePositionPc();
this.galacticGrid = new PolarGridPlane({ this.galacticGrid = new PolarGridPlane({
ringRadiiPc: GALACTIC_GRID_RINGS_PC, ringRadii: GALACTIC_GRID_RINGS_PC,
spokeCount: GALACTIC_GRID_SPOKES, spokeCount: GALACTIC_GRID_SPOKES,
centrePc: new THREE.Vector3(centre.x, centre.y, centre.z), centre: new THREE.Vector3(centre.x, centre.y, centre.z),
emphasisRadiiPc: [SUN_GALACTOCENTRIC_RADIUS_PC] emphasisRadii: [SUN_GALACTOCENTRIC_RADIUS_PC]
}); });
this.localGrid = new PolarGridPlane({ this.localGrid = new PolarGridPlane({
ringRadiiPc: LOCAL_GRID_RINGS_PC, ringRadii: LOCAL_GRID_RINGS_PC,
spokeCount: LOCAL_GRID_SPOKES, spokeCount: LOCAL_GRID_SPOKES,
emphasisRadiiPc: [LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]] emphasisRadii: [LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]]
}); });
// Drop lines for the Sun's nearest neighbours. A fixed set rather than whatever is currently // Drop lines for the Sun's nearest neighbours. A fixed set rather than whatever is currently
// labelled: these are the stars the local view is about, they cluster where the grid is // labelled: these are the stars the local view is about, they cluster where the grid is
@@ -731,9 +731,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const framingDistance = systemFramingDistanceAu(this.systemRenderer.maxTopLevelSemiMajorAxisAu); const framingDistance = systemFramingDistanceAu(this.systemRenderer.maxTopLevelSemiMajorAxisAu);
// Arrives along whichever direction the approach came from, then swings round to look down // Arrives along whichever direction the approach came from, then swings round to look down
// on the orbital plane as it settles — so the swap stays continuous but the system is not // on this system's own orbital plane as it settles — so the swap stays continuous but the
// presented edge-on. See SYSTEM_VIEW_DIRECTION. // system is not presented edge-on. See `systemViewDirection`.
const viewDirection = new THREE.Vector3(SYSTEM_VIEW_DIRECTION.x, SYSTEM_VIEW_DIRECTION.y, SYSTEM_VIEW_DIRECTION.z).normalize(); const viewDirection = systemViewDirection(this.systemRenderer.referenceFrame);
this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => { this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
this.currentStarId = star.id; this.currentStarId = star.id;
@@ -30,7 +30,7 @@ describe('galacticFrameQuaternion', () => {
describe('PolarGridPlane', () => { describe('PolarGridPlane', () => {
const rings = [10, 20, 50]; const rings = [10, 20, 50];
const spokes = 8; const spokes = 8;
const grid = new PolarGridPlane({ ringRadiiPc: rings, spokeCount: spokes, emphasisRadiiPc: [50] }); const grid = new PolarGridPlane({ ringRadii: rings, spokeCount: spokes, emphasisRadii: [50] });
it('draws every ring segment and every spoke', () => { it('draws every ring segment and every spoke', () => {
expect(grid.object.geometry.getAttribute('position').count).toBe(rings.length * SEGMENTS_PER_RING * 2 + spokes * 2); expect(grid.object.geometry.getAttribute('position').count).toBe(rings.length * SEGMENTS_PER_RING * 2 + spokes * 2);
@@ -72,9 +72,9 @@ describe('PolarGridPlane', () => {
it('sits on the galactic centre when given it, still in the plane', () => { it('sits on the galactic centre when given it, still in the plane', () => {
const centre = galacticCentrePositionPc(); const centre = galacticCentrePositionPc();
const galacticGrid = new PolarGridPlane({ const galacticGrid = new PolarGridPlane({
ringRadiiPc: [2500, 8178], ringRadii: [2500, 8178],
spokeCount: 4, spokeCount: 4,
centrePc: new THREE.Vector3(centre.x, centre.y, centre.z) centre: new THREE.Vector3(centre.x, centre.y, centre.z)
}); });
galacticGrid.object.updateMatrixWorld(true); galacticGrid.object.updateMatrixWorld(true);
@@ -86,6 +86,31 @@ describe('PolarGridPlane', () => {
galacticGrid.dispose(); galacticGrid.dispose();
}); });
it('lies in whatever plane it is oriented into, for a system read against its own', () => {
// The system view passes the frame its orbital elements were measured in, which has nothing
// to do with the Galaxy's plane.
const orientation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
const systemGrid = new PolarGridPlane({ ringRadii: [1, 2, 3], spokeCount: 6, orientation });
systemGrid.object.updateMatrixWorld(true);
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(orientation);
for (const index of [0, 200, systemGrid.object.geometry.getAttribute('position').count - 1]) {
const world = vertexAt(systemGrid.object.geometry, index).applyMatrix4(systemGrid.object.matrixWorld);
expect(world.dot(normal)).toBeCloseTo(0, 6);
}
// ...and it is genuinely a different plane from the default.
expect(Math.abs(normal.dot(galacticNormal()))).toBeLessThan(0.99);
systemGrid.dispose();
});
it('honours an explicit peak opacity, for a grid that has to sit under other rings', () => {
const quiet = new PolarGridPlane({ ringRadii: [1, 2], spokeCount: 4, opacity: 0.2 });
quiet.setStrength(1);
expect((quiet.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(0.2, 6);
quiet.dispose();
});
it('keeps the emphasised ring brighter than the rest', () => { it('keeps the emphasised ring brighter than the rest', () => {
const colors = grid.object.geometry.getAttribute('color'); 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. // Vertices are written ring by ring, in the order they were listed: 10 pc first, 50 pc last.
@@ -148,6 +173,27 @@ describe('TetherField', () => {
field.dispose(); field.dispose();
}); });
it('drops down whatever normal it was built with, not always the galactic one', () => {
const normal = new THREE.Vector3(0, 1, 0);
const field = new TetherField(2, { normal });
field.setTargets([new THREE.Vector3(3, 7, 5)]);
const foot = vertexAt(field.object.geometry, 1);
// The foot keeps the in-plane components and loses only the height along the normal.
expect(foot.x).toBeCloseTo(3, 6);
expect(foot.y).toBeCloseTo(0, 6);
expect(foot.z).toBeCloseTo(5, 6);
field.dispose();
});
it('normalises the normal it is given, so an unnormalised frame axis still lands on the plane', () => {
const field = new TetherField(2, { normal: new THREE.Vector3(0, 0, 4) });
field.setTargets([new THREE.Vector3(1, 1, 9)]);
expect(vertexAt(field.object.geometry, 1).z).toBeCloseTo(0, 6);
field.dispose();
});
it('stays hidden until it is given a strength', () => { it('stays hidden until it is given a strength', () => {
const field = new TetherField(2); const field = new TetherField(2);
expect(field.object.visible).toBe(false); expect(field.object.visible).toBe(false);
+63 -28
View File
@@ -21,31 +21,53 @@ export function galacticNormal(): THREE.Vector3 {
return new THREE.Vector3(z.x, z.y, z.z); return new THREE.Vector3(z.x, z.y, z.z);
} }
/**
* Distances here carry no unit of their own: they are whatever the group the grid is added to
* works in — parsecs in the galaxy view, AU in the system view.
*/
export interface PolarGridOptions { export interface PolarGridOptions {
/** Ring radii to draw, in parsecs, innermost first. */ /** Ring radii to draw, innermost first. */
readonly ringRadiiPc: readonly number[]; readonly ringRadii: readonly number[];
/** Radial spokes drawn from the innermost to the outermost ring. */ /** Radial spokes drawn from the innermost to the outermost ring. */
readonly spokeCount: number; readonly spokeCount: number;
/** /**
* Centre of the grid in the scene's equatorial frame, which also fixes the plane it lies in. * Rotation from the grid's own XY plane onto the plane it should lie in. Defaults to the
* Defaults to the Sun (the origin) — note that the Sun's own plane is * galactic plane; the system view passes the frame its orbital elements were measured in.
* {@link SUN_HEIGHT_ABOVE_MIDPLANE_PC} above the Galaxy's midplane, which matters at the local */
readonly orientation?: THREE.Quaternion;
/**
* Centre of the grid, which also fixes the plane it lies in. Defaults to the origin — note
* that in the galaxy view the origin is the Sun, whose own plane is
* {@link SUN_HEIGHT_ABOVE_MIDPLANE_PC} above the Galaxy's midplane; that matters at the local
* scale and is invisible at the galactic one. * scale and is invisible at the galactic one.
*/ */
readonly centrePc?: THREE.Vector3; readonly centre?: THREE.Vector3;
readonly color?: THREE.ColorRepresentation; readonly color?: THREE.ColorRepresentation;
/** Rings listed here are drawn at full strength — used to call out a meaningful radius. */ /** Rings listed here are drawn at full strength — used to call out a meaningful radius. */
readonly emphasisRadiiPc?: readonly number[]; readonly emphasisRadii?: readonly number[];
/** Peak opacity, for a grid that should read louder or quieter than the default. */
readonly opacity?: number;
/**
* Breaks the rings into dashes. Worth it where the grid shares a plane with real curves it
* could be mistaken for — the system view draws orbit ellipses in the same plane, and a solid
* ring there is indistinguishable at a glance from a circular orbit. Dashed reads as
* "reference", solid as "something is actually there".
*/
readonly dashed?: boolean;
} }
/** Ring segments per dash and per gap when {@link PolarGridOptions.dashed} is set. */
const DASH_SEGMENTS = 2;
/** /**
* A polar grid lying in the galactic plane: concentric rings and radial spokes, fading out with * A polar grid lying in a reference plane: concentric rings and radial spokes, fading out with
* radius. * radius.
* *
* This is the one piece of chrome that makes a 3D star map readable. Without a reference plane * This is the one piece of chrome that makes a 3D map readable. Without a reference plane a
* a cloud of points has no depth at all — two stars a thousand parsecs apart look like * cloud of points has no depth at all — two stars a thousand parsecs apart look like neighbours,
* neighbours. With a plane under them, and a tether from each to the plane, the eye reads their * and a planet above its system's plane looks like one inside it. With a plane under them, and a
* height directly. It is also the signature of the map this view is modelled on. * tether from each down to it, the eye reads height directly. It is also the signature of the
* map this view is modelled on.
*/ */
export class PolarGridPlane { export class PolarGridPlane {
readonly object: THREE.LineSegments; readonly object: THREE.LineSegments;
@@ -56,9 +78,9 @@ export class PolarGridPlane {
constructor(options: PolarGridOptions) { constructor(options: PolarGridOptions) {
const color = new THREE.Color(options.color ?? 0x4dd7ff); const color = new THREE.Color(options.color ?? 0x4dd7ff);
const emphasis = new Set(options.emphasisRadiiPc ?? []); const emphasis = new Set(options.emphasisRadii ?? []);
const outerRadius = Math.max(...options.ringRadiiPc); const outerRadius = Math.max(...options.ringRadii);
const innerRadius = Math.min(...options.ringRadiiPc); const innerRadius = Math.min(...options.ringRadii);
const vertices: number[] = []; const vertices: number[] = [];
const colors: number[] = []; const colors: number[] = [];
@@ -68,10 +90,17 @@ export class PolarGridPlane {
colors.push(color.r * brightness, color.g * brightness, color.b * brightness); colors.push(color.r * brightness, color.g * brightness, color.b * brightness);
}; };
for (const radius of options.ringRadiiPc) { for (const radius of options.ringRadii) {
// Rings dim toward the edge of the grid so it dissolves into the void instead of ending. // Rings dim toward the edge of the grid so it dissolves into the void instead of ending.
const brightness = emphasis.has(radius) ? 1 : 0.55 * (1 - (0.6 * radius) / outerRadius); const brightness = emphasis.has(radius) ? 1 : 0.55 * (1 - (0.6 * radius) / outerRadius);
for (let segment = 0; segment < SEGMENTS_PER_RING; segment++) { for (let segment = 0; segment < SEGMENTS_PER_RING; segment++) {
// Dashes are cut by dropping whole segments rather than by a dashed material: the ring is
// already built from independent segment pairs, so a material's dash pattern would
// restart at each one. Skipping segments also keeps the dash angular, so every ring is
// dashed at the same rate however large it is.
if (options.dashed && segment % (DASH_SEGMENTS * 2) >= DASH_SEGMENTS) {
continue;
}
const a = (segment / SEGMENTS_PER_RING) * Math.PI * 2; const a = (segment / SEGMENTS_PER_RING) * Math.PI * 2;
const b = ((segment + 1) / SEGMENTS_PER_RING) * Math.PI * 2; const b = ((segment + 1) / SEGMENTS_PER_RING) * Math.PI * 2;
push(Math.cos(a) * radius, Math.sin(a) * radius, brightness); push(Math.cos(a) * radius, Math.sin(a) * radius, brightness);
@@ -91,7 +120,7 @@ export class PolarGridPlane {
this.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); this.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
// Deliberately restrained: the grid is the reference the map is read against, not the map. // Deliberately restrained: the grid is the reference the map is read against, not the map.
this.baseOpacity = 0.55; this.baseOpacity = options.opacity ?? 0.55;
this.material = new THREE.LineBasicMaterial({ this.material = new THREE.LineBasicMaterial({
vertexColors: true, vertexColors: true,
transparent: true, transparent: true,
@@ -101,9 +130,9 @@ export class PolarGridPlane {
}); });
this.object = new THREE.LineSegments(this.geometry, this.material); this.object = new THREE.LineSegments(this.geometry, this.material);
// Built flat in its own XY plane, then rotated onto the galactic plane and slid to centre. // Built flat in its own XY plane, then rotated onto the reference plane and slid to centre.
this.object.quaternion.copy(galacticFrameQuaternion()); this.object.quaternion.copy(options.orientation ?? galacticFrameQuaternion());
this.object.position.copy(options.centrePc ?? new THREE.Vector3()); this.object.position.copy(options.centre ?? new THREE.Vector3());
this.object.visible = false; this.object.visible = false;
this.object.renderOrder = -1; this.object.renderOrder = -1;
} }
@@ -137,10 +166,15 @@ export class TetherField {
private readonly material: THREE.LineBasicMaterial; private readonly material: THREE.LineBasicMaterial;
private readonly positions: Float32Array; private readonly positions: Float32Array;
private readonly maxCount: number; private readonly maxCount: number;
private readonly normal: THREE.Vector3;
private readonly peakOpacity: number;
constructor(maxCount: number, color: THREE.ColorRepresentation = 0x4dd7ff) { constructor(maxCount: number, options: { color?: THREE.ColorRepresentation; normal?: THREE.Vector3; opacity?: number } = {}) {
this.maxCount = maxCount; this.maxCount = maxCount;
this.normal = (options.normal ?? galacticNormal()).clone().normalize();
this.peakOpacity = options.opacity ?? 0.45;
this.positions = new Float32Array(maxCount * 6); this.positions = new Float32Array(maxCount * 6);
const color = options.color ?? 0x4dd7ff;
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3)); this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
this.geometry.setDrawRange(0, 0); this.geometry.setDrawRange(0, 0);
@@ -153,19 +187,20 @@ export class TetherField {
} }
/** /**
* Drops a tether from each point onto a plane parallel to the galactic plane. * Drops a tether from each point onto the plane through the origin with this field's normal,
* offset along that normal by `planeOffset`.
* *
* `planeHeightPc` is that plane's height above the Sun along the galactic normal, so it is `0` * The offset is `0` for a plane through the origin — the Sun in the galaxy view, the host star
* for a grid through the Sun and `-SUN_HEIGHT_ABOVE_MIDPLANE_PC` for one on the Galaxy's true * in the system view — and `-SUN_HEIGHT_ABOVE_MIDPLANE_PC` for a grid on the Galaxy's true
* midplane. Points past the field's capacity are dropped. * midplane. Points past the field's capacity are dropped.
*/ */
setTargets(points: readonly THREE.Vector3[], planeHeightPc = 0): void { setTargets(points: readonly THREE.Vector3[], planeOffset = 0): void {
const normal = galacticNormal(); const normal = this.normal;
const count = Math.min(points.length, this.maxCount); const count = Math.min(points.length, this.maxCount);
for (let index = 0; index < count; index++) { for (let index = 0; index < count; index++) {
const point = points[index]; const point = points[index];
const height = point.dot(normal) - planeHeightPc; const height = point.dot(normal) - planeOffset;
this.positions.set( this.positions.set(
[point.x, point.y, point.z, point.x - normal.x * height, point.y - normal.y * height, point.z - normal.z * height], [point.x, point.y, point.z, point.x - normal.x * height, point.y - normal.y * height, point.z - normal.z * height],
index * 6 index * 6
@@ -179,7 +214,7 @@ export class TetherField {
/** Crossfades the tethers, matching whichever grid they are dropping onto. */ /** Crossfades the tethers, matching whichever grid they are dropping onto. */
setStrength(strength: number): void { setStrength(strength: number): void {
const clamped = Math.max(0, Math.min(1, strength)); const clamped = Math.max(0, Math.min(1, strength));
this.material.opacity = clamped * 0.45; this.material.opacity = clamped * this.peakOpacity;
this.object.visible = clamped > 0; this.object.visible = clamped > 0;
} }
@@ -1,6 +1,16 @@
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { bodyMarkerRadiusAu, DEFAULT_STAR_MARKER_RADIUS_AU, starMarkerRadiusAu, systemFramingDistanceAu } from './system-framing'; import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import {
bodyMarkerRadiusAu,
DEFAULT_STAR_MARKER_RADIUS_AU,
starMarkerRadiusAu,
systemFramingDistanceAu,
systemGridRingsAu,
SYSTEM_VIEW_DIRECTION_IN_PLANE,
systemViewDirection
} from './system-framing';
/** Real systems spanning the range the view has to cope with. */ /** Real systems spanning the range the view has to cope with. */
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 }; const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
@@ -147,3 +157,101 @@ describe('bodyMarkerRadiusAu', () => {
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2); 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);
});
});
@@ -1,4 +1,6 @@
import { CartesianCoordinates, eclipticToEquatorial } from '../../shared/astro/coordinates'; import * as THREE from 'three/webgpu';
import { CartesianCoordinates } from '../../shared/astro/coordinates';
/** /**
* How the system view sizes itself to whatever system it is showing. * How the system view sizes itself to whatever system it is showing.
@@ -45,17 +47,31 @@ function clamp(value: number, min: number, max: number): number {
} }
/** /**
* Where the camera settles when arriving at a system, as a unit direction from the star in the * Where the camera settles when arriving at a system, as a unit direction from the star —
* scene's equatorial frame. * expressed in the system's *own* reference plane, before that plane is rotated into the scene.
* *
* The scene is equatorial so that orbits and stars share one frame, but orbital planes lie * A three-quarter view, about 37 degrees off the plane's normal, so a system reads as a disc
* close to the *ecliptic*, which is tilted 23.4 degrees out of it. Left to the equatorial axes, * rather than as a line.
* a system would be presented edge-on. Rather than rotate the world into a comfortable pose —
* which would put the orbits back at odds with the sky — the camera is placed relative to the
* plane instead: this is a three-quarter view, about 37 degrees off the ecliptic normal, so a
* system reads as a disc while staying where it truly sits.
*/ */
export const SYSTEM_VIEW_DIRECTION: CartesianCoordinates = eclipticToEquatorial({ x: 0, y: 0.6, z: 0.8 }); export const SYSTEM_VIEW_DIRECTION_IN_PLANE: CartesianCoordinates = { x: 0, y: 0.6, z: 0.8 };
/**
* That direction carried into the scene's equatorial frame by the system's own reference frame.
*
* The scene is equatorial so that orbits and stars share one frame, but no system's orbits lie
* in the equatorial plane: the solar system's are measured against the ecliptic, 23.4 degrees
* out of it, and an exoplanet system's against the plane of the sky, which depends on where its
* host star happens to be. Left to the equatorial axes — or to any single fixed direction — some
* systems come out edge-on.
*
* Rather than rotate the world into a comfortable pose, which would put the orbits back at odds
* with the sky, the camera is placed relative to whichever plane the system was measured in. So
* every system reads as a disc while staying exactly where it truly sits.
*/
export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vector3 {
const { x, y, z } = SYSTEM_VIEW_DIRECTION_IN_PLANE;
return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame);
}
/** /**
* Radius (AU) to draw the system's star at, given its innermost orbit. * Radius (AU) to draw the system's star at, given its innermost orbit.
@@ -82,6 +98,43 @@ export function systemFramingDistanceAu(outermostOrbitAu: number): number {
return clamp(outermostOrbitAu * FRAMING_TO_OUTERMOST_ORBIT, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU); return clamp(outermostOrbitAu * FRAMING_TO_OUTERMOST_ORBIT, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
} }
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
const TARGET_GRID_RING_COUNT = 8;
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
/** Ring spacings are always one of these times a power of ten, so the numbers stay readable. */
const RING_STEP_MANTISSAS = [1, 2, 5, 10];
/**
* Ring radii (AU) for the system view's reference grid, given the system's outermost orbit.
*
* Snapped to a 1-2-5 ladder rather than evenly dividing the system, because the point of the
* grid is to put a number on a distance: rings at 5, 10, 15 AU can be read off at a glance, and
* rings at 4.34, 8.68, 13.02 AU cannot. That holds across the four orders of magnitude real
* systems span — the solar system gets 5 AU rings, TRAPPIST-1 gets 0.01 AU ones.
*
* Empty for a system with no orbits to scale against; there is no distance to mark out.
*/
export function systemGridRingsAu(outermostOrbitAu: number): number[] {
if (!Number.isFinite(outermostOrbitAu) || outermostOrbitAu <= 0) {
return [];
}
const extent = outermostOrbitAu * GRID_EXTENT_TO_OUTERMOST_ORBIT;
const target = extent / TARGET_GRID_RING_COUNT;
const magnitude = Math.pow(10, Math.floor(Math.log10(target)));
const step = magnitude * (RING_STEP_MANTISSAS.find((mantissa) => magnitude * mantissa >= target) ?? 10);
// Rounded up, not truncated: the last ring has to enclose the outermost orbit rather than fall
// just inside it, or the outermost planet spends its year outside the grid meant to measure it.
const count = Math.ceil(extent / step);
const rings: number[] = [];
// Multiplied rather than accumulated, so a step of 0.01 does not drift into 0.060000000000000005.
for (let index = 1; index <= count; index++) {
rings.push(index * step);
}
return rings;
}
/** /**
* Span of the solar system, in AU, used as the reference every other system's marker sizes are * Span of the solar system, in AU, used as the reference every other system's marker sizes are
* scaled against. The marker constants below were tuned by eye at this scale. * scaled against. The marker constants below were tuned by eye at this scale.
@@ -209,6 +209,81 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
expect(p.z).toBeCloseTo(0, 9); expect(p.z).toBeCloseTo(0, 9);
renderer.dispose(); renderer.dispose();
}); });
it('reads the solar system against the ecliptic and everything else against the sky plane', () => {
const solar = new SystemOrbitsRenderer([EARTH], []);
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
const solarNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(solar.referenceFrame);
expect(solarNormal.dot(new THREE.Vector3(eclipticPole.x, eclipticPole.y, eclipticPole.z))).toBeCloseTo(1, 9);
solar.dispose();
const lineOfSight = { x: 0.3, y: -0.5, z: 0.81 };
const exo = new SystemOrbitsRenderer([], [exoplanet()], lineOfSight);
const exoNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(exo.referenceFrame);
const expected = new THREE.Vector3(lineOfSight.x, lineOfSight.y, lineOfSight.z).normalize();
expect(exoNormal.dot(expected)).toBeCloseTo(1, 9);
exo.dispose();
});
});
describe('reference grid', () => {
/** A body far enough out to give the grid something to measure. */
const JUPITER: BodyRecord = {
id: 'jupiter',
systemStarId: 0,
name: 'Jupiter',
kind: 'planet',
radiusKm: 69911,
orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD }
};
/** The grid and the tethers are the only line objects the renderer adds outside a pivot. */
function planeObjects(renderer: SystemOrbitsRenderer): THREE.LineSegments[] {
return renderer.object.children.filter((child): child is THREE.LineSegments => child instanceof THREE.LineSegments);
}
it('lays a grid and tethers in the system plane', () => {
const renderer = new SystemOrbitsRenderer([JUPITER], []);
expect(planeObjects(renderer)).toHaveLength(2);
renderer.dispose();
});
it('drops a tether from every top-level body onto that plane, and follows them', () => {
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
renderer.update(DEFAULT_EPOCH_JD);
// The tether field is the one with an explicit draw range; the grid leaves it at Infinity.
const tethers = planeObjects(renderer).find((object) => Number.isFinite(object.geometry.drawRange.count))!;
const readTop = (): THREE.Vector3 => {
const position = tethers.geometry.getAttribute('position');
return new THREE.Vector3(position.getX(0), position.getY(0), position.getZ(0));
};
// The tether's top is the marker, wherever the marker currently is.
expect(readTop().distanceTo(renderer.members[0].marker.position)).toBeCloseTo(0, 9);
const before = readTop();
renderer.update(DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS / 2);
expect(readTop().distanceTo(renderer.members[0].marker.position)).toBeCloseTo(0, 9);
expect(readTop().distanceTo(before)).toBeGreaterThan(0);
renderer.dispose();
});
it('draws no grid for a star with no known planets', () => {
// Nothing to measure, and a bare ring around a lone star would imply a scale it does not
// have.
const renderer = new SystemOrbitsRenderer([], []);
expect(planeObjects(renderer)).toHaveLength(0);
renderer.dispose();
});
it('detaches the grid on dispose along with everything else', () => {
const renderer = new SystemOrbitsRenderer([JUPITER], []);
const [grid] = planeObjects(renderer);
renderer.dispose();
expect(grid.parent).toBeNull();
});
}); });
describe('exoplanet inclination is measured from the plane of the sky', () => { describe('exoplanet inclination is measured from the plane of the sky', () => {
@@ -4,7 +4,8 @@ import { gmForParent } from '../../shared/astro/constants';
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler'; import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { bodyMarkerRadiusAu } from './system-framing'; import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
import { PolarGridPlane, TetherField } from './grid-plane';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet'; export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
@@ -31,6 +32,11 @@ const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
const EARTH_RADIUS_KM = 6371; const EARTH_RADIUS_KM = 6371;
const DEG_TO_RAD = Math.PI / 180; const DEG_TO_RAD = Math.PI / 180;
/** Spokes on the system's reference grid, and how loudly it is drawn against the orbits. */
const SYSTEM_GRID_SPOKES = 12;
const SYSTEM_GRID_OPACITY = 0.28;
const SYSTEM_TETHER_OPACITY = 0.3;
/** /**
* Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the * Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons * obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons
@@ -145,10 +151,22 @@ export class SystemOrbitsRenderer {
readonly maxTopLevelSemiMajorAxisAu: number; readonly maxTopLevelSemiMajorAxisAu: number;
/** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */ /** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
readonly minTopLevelSemiMajorAxisAu: number; readonly minTopLevelSemiMajorAxisAu: number;
/**
* The plane this system is read against, as a rotation from XY into the scene's equatorial
* frame: the ecliptic for the solar system, the plane of the sky for everything else.
*/
readonly referenceFrame: THREE.Quaternion;
private readonly topLevelBodies: TrackedTopLevelBody[] = []; private readonly topLevelBodies: TrackedTopLevelBody[] = [];
private readonly moons: TrackedMoon[] = []; private readonly moons: TrackedMoon[] = [];
private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = []; private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = [];
private readonly grid?: PolarGridPlane;
private readonly tethers?: TetherField;
/**
* Aliases of the tracked bodies' own position vectors, which `update` writes in place — so
* following them each tick costs no allocation at all.
*/
private tetherPoints: readonly THREE.Vector3[] = [];
constructor( constructor(
bodies: readonly BodyRecord[], bodies: readonly BodyRecord[],
@@ -222,6 +240,35 @@ export class SystemOrbitsRenderer {
} }
this.members = members; this.members = members;
// Which plane the system is read against follows from where its elements came from. Only the
// Sun has Horizons bodies and no system has both, so this is a choice between the two rather
// than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise.
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
if (rings.length > 0) {
this.grid = new PolarGridPlane({
ringRadii: rings,
spokeCount: SYSTEM_GRID_SPOKES,
orientation: this.referenceFrame,
// Quieter and dashed, unlike the galaxy view's: here the grid shares a plane with the
// orbit ellipses, which are themselves rings, and it must not be mistaken for one.
opacity: SYSTEM_GRID_OPACITY,
dashed: true,
emphasisRadii: [rings[rings.length - 1]]
});
this.grid.setStrength(1);
this.tethers = new TetherField(this.topLevelBodies.length, {
normal: new THREE.Vector3(0, 0, 1).applyQuaternion(this.referenceFrame),
opacity: SYSTEM_TETHER_OPACITY
});
this.tethers.setStrength(1);
this.tetherPoints = this.topLevelBodies.map((body) => body.position);
this.object.add(this.grid.object, this.tethers.object);
}
} }
/** Recomputes every marker's position for the given Julian date. Call once per tick. */ /** Recomputes every marker's position for the given Julian date. Call once per tick. */
@@ -241,6 +288,10 @@ export class SystemOrbitsRenderer {
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd); const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame); moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
} }
// Moons are left out: their tether would land within a marker's width of their planet's and
// say nothing the planet's has not already said.
this.tethers?.setTargets(this.tetherPoints);
} }
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */ /** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
@@ -254,6 +305,8 @@ export class SystemOrbitsRenderer {
} }
dispose(): void { dispose(): void {
this.grid?.dispose();
this.tethers?.dispose();
for (const { geometry, material } of this.disposables) { for (const { geometry, material } of this.disposables) {
geometry.dispose(); geometry.dispose();
material.dispose(); material.dispose();