Files
star-map/src/app/features/galaxy-system/system-orbits-renderer.ts
T
SenrokaiandClaude Opus 5 468c98b14a Surface the system view with the photographs it already had, lit by its star
Every body in the system view was an unlit sphere wearing a 32 by 16 pixel
procedural texture — the size chosen when a marker was a few pixels across and
what survived was its average colour. The thirteen real photographs in
`src/assets/textures/bodies/` were used only by the detail page. So Mars was a
pale grey ball with invented polar caps while its own NASA mosaic sat unread in
the repository, and nothing had a day side or a night side.

Each marker now takes its own photograph where one exists, at the size the
detail page uses, and the derived texture only where none does — the five moons
no probe mapped, and every exoplanet, none of which has ever been imaged. The
material is lit, and the light is a point at the star, so each world shows the
terminator where it really falls.

The light does not fall off with distance. Under the inverse square that real
light obeys, Neptune receives a thousandth of what Mercury does and reads as
black; the map is a set of worlds to look at rather than a light meter, so each
is lit as a photograph of it would be. That is the same concession the pixel
floor makes for size, and it is only about brightness: the *direction* is real.

Spheres are 32 by 24 rather than 16 by 12, since at true scale a body is drawn
anywhere from a pixel to the whole frame and the old silhouette was visibly
faceted at the near end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:03:24 +02:00

489 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three/webgpu';
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
import { gmForParent } from '../../shared/astro/constants';
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
import { PolarGridPlane, TetherField } from './grid-plane';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
/** A pickable marker for one rendered body/exoplanet, keyed by its own record id. */
export interface SystemMember {
id: string;
kind: SystemMemberKind;
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 DWARF_COLOR = new THREE.Color(0.8, 0.7, 0.55);
const MOON_COLOR = new THREE.Color(0.75, 0.75, 0.75);
const EXOPLANET_COLOR = new THREE.Color(0.85, 0.4, 0.85);
const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
planet: 0.5,
dwarf: 0.4,
moon: 0.35,
exoplanet: 0.35
};
const EARTH_RADIUS_KM = 6371;
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
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons
* against the ecliptic, so this is their frame.
*/
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD);
/**
* Rotation carrying the frame an **exoplanet's** elements are measured in into the scene.
*
* The Exoplanet Archive measures inclination from the *plane of the sky* — the plane
* perpendicular to our line of sight to the host star — not from the ecliptic. 90 degrees means
* edge-on as seen from Earth, which is why transiting planets cluster there: 1643 of the 2061
* published inclinations are within 5 degrees of 90. Treating that as an ecliptic inclination
* tips every transiting system on its side against a plane it was never measured against.
*
* Carrying the elements' +Z onto the line of sight fixes it: an inclination of `i` then means
* the orbit's normal sits `i` from our line of sight, which is exactly the definition. The
* rotation about that axis is the node's position angle on the sky, which the archive does not
* publish, so the shortest arc from +Z is used — deterministic, and no less arbitrary than any
* other choice given no data.
*
* Falls back to the ecliptic frame when there is no direction to work with.
*/
function skyPlaneFrame(lineOfSight: CartesianCoordinates | undefined): THREE.Quaternion {
if (!lineOfSight) {
return ECLIPTIC_FRAME.clone();
}
const direction = new THREE.Vector3(lineOfSight.x, lineOfSight.y, lineOfSight.z);
if (direction.lengthSq() === 0) {
return ECLIPTIC_FRAME.clone();
}
return new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), direction.normalize());
}
function colorForKind(kind: SystemMemberKind): THREE.Color {
switch (kind) {
case 'planet':
return PLANET_COLOR;
case 'dwarf':
return DWARF_COLOR;
case 'moon':
return MOON_COLOR;
case 'exoplanet':
return EXOPLANET_COLOR;
}
}
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
const ORBIT_LINE_NAME = 'orbit-line';
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3);
const scratch = new THREE.Vector3();
points.forEach((point, index) => {
// Elements are measured against their source's own reference plane; `frame` rotates that
// plane into the scene's equatorial one.
const { x, y, z } = scratch.set(point.x, point.y, point.z).applyQuaternion(frame);
positions[index * 3] = x;
positions[index * 3 + 1] = y;
positions[index * 3 + 2] = z;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: colorForKind(kind),
transparent: true,
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
});
const line = new THREE.Line(geometry, material);
line.name = ORBIT_LINE_NAME;
return line;
}
/**
* A marker sphere, surfaced with the body's own photograph where one has ever been taken, and
* with a texture derived from its measurements where none has — and lit by its star either way,
* so a world shows the day and night it actually has.
*
* The photographs were already in the repository, used only by the detail page: the system view
* drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across
* was indistinguishable from its average colour and, once the camera closed in, was a blur. A
* marker can now fill the frame, so it takes the real image at the size the detail page uses.
*/
function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
// 32 by 24 rather than 16 by 12: at true scale a body is drawn as small as a pixel and as large
// as the screen, and the silhouette of the old sphere was visibly faceted at the near end.
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), 32, 24);
const photograph = id ? bodyTexturePath(id) : undefined;
const map = photograph ? loadCachedTexture(photograph) : appearance ? planetTexture(appearance) : undefined;
const material = new THREE.MeshStandardMaterial({
map,
color: map ? 0xffffff : colorForKind(kind),
roughness: 1,
metalness: 0
});
return new THREE.Mesh(geometry, material);
}
/**
* The star's own light, at the centre of the system it lights.
*
* `decay` is 0, which is not what light does: a point source falls off with the square of the
* distance, and under that law Neptune receives a thousandth of what Mercury does and reads as
* black. The map is a set of worlds to look at rather than a light meter, so each is lit as a
* photograph of it would be — the same concession the pixel floor makes for size. What the light
* does carry truthfully is *direction*: every body shows its day side toward the star and its
* night side away from it, and the terminator falls where it really falls.
*/
function starLight(): THREE.PointLight {
const light = new THREE.PointLight(0xfff4e0, 2.2, 0, 0);
light.position.set(0, 0, 0);
return light;
}
/** Local axis a sphere is built around, and what the spin is applied about. */
const SPIN_AXIS = new THREE.Vector3(0, 1, 0);
const HOURS_PER_DAY = 24;
/**
* How a body is turned at a given date: its own sidereal rotation, about its own axis.
*
* The axis is the orbit normal tilted by the obliquity, about the orbit’s ascending node —
* which is where an obliquity is measured from, and the only line in the orbit the elements
* name. The phase at the epoch is not published for any of these bodies, so a body is drawn
* with its prime meridian toward the node at J2000 and turned from there: the rate and the
* direction are real, where the face pointed at the camera is not.
*
* A negative period is retrograde — Venus, and Uranus by the IAU’s convention — and comes
* out of the arithmetic without a special case.
*/
function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, obliquityDeg: number, epochJd: number): THREE.Quaternion {
const node = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD;
const inclination = elements.inclinationDeg * DEG_TO_RAD;
const nodeDirection = new THREE.Vector3(Math.cos(node), Math.sin(node), 0);
const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination))
.applyAxisAngle(nodeDirection, obliquityDeg * DEG_TO_RAD)
.applyQuaternion(frame);
const turns = ((epochJd - elements.epochJd) * HOURS_PER_DAY) / rotationPeriodHours;
return new THREE.Quaternion()
.setFromUnitVectors(SPIN_AXIS, axis)
.multiply(new THREE.Quaternion().setFromAxisAngle(SPIN_AXIS, turns * 2 * Math.PI));
}
interface TrackedTopLevelBody {
id: string;
kind: SystemMemberKind;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
/** Rotation from this body's own element frame into the scene's equatorial one. */
frame: THREE.Quaternion;
/** AU position last computed for this body; moons read their parent's here. */
position: THREE.Vector3;
/** Sidereal rotation, where the catalogue publishes one; negative is retrograde. */
rotationPeriodHours?: number;
obliquityDeg?: number;
}
interface TrackedMoon {
id: string;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
frame: THREE.Quaternion;
pivot: THREE.Group;
parentId: string;
rotationPeriodHours?: number;
obliquityDeg?: number;
}
/**
* Builds and animates the orbit ellipses + planet/moon/exoplanet markers for one star system,
* in AU, with the star itself at the origin. Moons are parented to a pivot group that tracks
* their planet's live position each tick, so their (small, planet-relative) orbit ellipse and
* marker never need to be rebuilt.
*/
export class SystemOrbitsRenderer {
readonly object = new THREE.Group();
readonly members: readonly SystemMember[];
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
readonly maxTopLevelSemiMajorAxisAu: number;
/** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
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;
/**
* Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost
* orbit — is the widest thing the system draws, so it is what the camera has to frame.
*/
readonly gridOuterRadiusAu: number;
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
private readonly moons: TrackedMoon[] = [];
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(
bodies: readonly BodyRecord[],
exoplanets: readonly ExoplanetRecord[],
/** Direction from the Sun to this system's host star, equatorial — the exoplanet line of sight. */
hostStarDirection?: CartesianCoordinates,
/**
* The host star's luminosity in solar units, which is what sets how hot each body in the
* system is and therefore what it looks like. Omitted for a host that is not in the star
* catalogue, leaving its bodies classified on size and density alone.
*/
hostLuminositySolar?: number | null
) {
const members: SystemMember[] = [];
const topLevelBodiesById = new Map<string, BodyRecord>();
// Measured before anything is built, because marker sizes are scaled against the span and
// the markers are created as the bodies are added.
const topLevelAxes = [
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
].filter((axis) => Number.isFinite(axis) && axis > 0);
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
for (const body of bodies) {
if (!body.parentBodyId) {
topLevelBodiesById.set(body.id, body);
}
}
for (const body of bodies) {
if (body.parentBodyId) {
continue;
}
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
const kind: SystemMemberKind = body.kind;
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg });
members.push({ id: body.id, kind, marker: tracked.marker });
}
for (const body of bodies) {
if (!body.parentBodyId) {
continue;
}
const parent = topLevelBodiesById.get(body.parentBodyId);
const parentTracked = parent && this.topLevelBodies.find((tracked) => tracked.id === parent.id);
if (!parentTracked) {
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), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg });
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.
const exoplanetFrame = skyPlaneFrame(hostStarDirection);
for (const exoplanet of exoplanets) {
// Only a semi-major axis is genuinely required; resolveOrbitalElements defaults the rest,
// eccentricity included. Demanding a published eccentricity as well used to drop 1509
// otherwise drawable planets, so a user could open one's detail page, jump to its system,
// and find it missing from the very system it belongs to.
if (!isPropagatableOrbit(exoplanet.orbit)) {
continue;
}
const elements = resolveOrbitalElements(exoplanet.orbit);
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
const gm = resolveGravitationalParameter({
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
periodDays: exoplanet.periodDays,
hostStarMassSolar: exoplanet.hostStarMassSolar
});
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar));
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
}
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);
this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0;
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);
}
// The star lights its own system. The star marker itself is unlit — it is the source, not a
// surface — so nothing here changes how it is drawn.
this.object.add(starLight());
}
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
update(epochJd: number): void {
for (const body of this.topLevelBodies) {
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
body.marker.position.copy(body.position);
if (body.rotationPeriodHours) {
body.marker.quaternion.copy(spinFor(body.elements, body.frame, body.rotationPeriodHours, body.obliquityDeg ?? 0, epochJd));
}
}
for (const moon of this.moons) {
const parent = this.topLevelBodies.find((body) => body.id === moon.parentId);
if (!parent) {
continue;
}
moon.pivot.position.copy(parent.position);
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
if (moon.rotationPeriodHours) {
moon.marker.quaternion.copy(spinFor(moon.elements, moon.frame, moon.rotationPeriodHours, moon.obliquityDeg ?? 0, epochJd));
}
}
// 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). */
memberForObject(object: THREE.Object3D): SystemMember | undefined {
return this.members.find((member) => member.marker === object);
}
/** All marker objects, for raycasting. */
get pickableObjects(): THREE.Object3D[] {
return this.members.map((member) => member.marker);
}
/** Shows or hides the orbit lines and the reference grid, leaving the bodies themselves. */
setLayerVisibility(layers: { orbits: boolean; grid: boolean }): void {
this.object.traverse((child) => {
if (child.name === ORBIT_LINE_NAME) {
child.visible = layers.orbits;
}
});
if (this.grid) {
this.grid.object.visible = layers.grid;
}
if (this.tethers) {
this.tethers.object.visible = layers.grid;
}
}
dispose(): void {
this.grid?.dispose();
this.tethers?.dispose();
for (const { geometry, material } of this.disposables) {
geometry.dispose();
material.dispose();
}
// Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old
// one, but without this the old orbit lines and markers stay parented to the system group
// forever — still traversed and re-uploaded every frame despite their geometries being
// disposed, and drawn over the new system while being unpickable.
this.object.removeFromParent();
this.object.clear();
}
private addTopLevelBody(
id: string,
kind: SystemMemberKind,
elements: OrbitalElements,
gmAu3PerDay2: number,
radiusKm: number | undefined,
frame: THREE.Quaternion,
appearance?: PlanetAppearance,
rotation?: { periodHours?: number; obliquityDeg?: number }
): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind, frame);
const marker = buildMarker(id, kind, radiusKm, appearance);
this.object.add(orbitLine, marker);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
this.topLevelBodies.push(tracked);
return tracked;
}
private addMoon(
id: string,
elements: OrbitalElements,
gmAu3PerDay2: number,
radiusKm: number | undefined,
parent: TrackedTopLevelBody,
frame: THREE.Quaternion,
appearance?: PlanetAppearance,
rotation?: { periodHours?: number; obliquityDeg?: number }
): TrackedMoon {
const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon', frame);
// 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(id, 'moon', radiusKm, appearance);
pivot.add(orbitLine, marker);
this.object.add(pivot);
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg };
this.moons.push(moon);
return moon;
}
private trackDisposable(geometry: THREE.BufferGeometry, material: THREE.Material): void {
this.disposables.push({ geometry, material });
}
}