Draw the 1509 exoplanets that were being silently dropped
The system renderer required both a semi-major axis and an eccentricity before it would place an exoplanet, even though resolveOrbitalElements already defaults every other missing element. The archive publishes an axis far more often than an eccentricity: 3895 records have one and only 2386 have both, so 1509 planets were dropped for want of a value that can simply be assumed. A missing eccentricity now defaults to 0, a circle. That is the conventional assumption for an orbit whose shape has not been constrained, and it is the only honest option available, since the axis alone says nothing about elongation. The effect is not subtle. 18 systems gain planets, and seven of them previously rendered as a bare star with nothing around it at all: Gl 357 goes from zero planets to three, HD 176986 likewise. Beyond the effect today, a user could already reach one of these planets through search and its detail page, then jump to its system and find it missing from the very system it belongs to. isPropagatableOrbit replaces the old inline guard and also rejects what the old one never checked: a non-positive axis, and an eccentricity of 1 or more. Those are escape trajectories that no ellipse describes, and propagating them anyway does not throw — it yields NaN, which reaches the vertex buffer and poisons the geometry's bounding sphere, disabling culling for the whole object rather than just the bad orbit. Being a type guard, it also lets the caller drop a seven-line field-by-field copy of the orbit. Fixes a label leak found while verifying this in the browser. Galaxy star labels were being cleared on entering system space but immediately recomputed, because the tick gated them on `currentStarId`, which is not assigned until the arrival flight finishes a second later — so parsec-scale names sat pinned over the system. Both label and orbit updates now gate on which group is actually visible, which is true throughout the transition rather than only at the end of it. Tests: 185 passing, up from 171. Verified in a real browser: GJ 1151 draws the orbit and marker it gained, and no labels survive into the system view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -240,7 +240,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.rig?.update(deltaSeconds);
|
this.rig?.update(deltaSeconds);
|
||||||
this.controls?.update();
|
this.controls?.update();
|
||||||
|
|
||||||
if (this.currentStarId === null) {
|
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
|
||||||
|
// arrival flight finishes. In between, the scene has already swapped to system space while
|
||||||
|
// `currentStarId` is still null, so labels were being recomputed from galaxy-scale positions
|
||||||
|
// and pinned over the system — the whole point of clearing them on the swap.
|
||||||
|
if (this.galaxyGroup.visible) {
|
||||||
this.labelUpdateAccumulator += deltaSeconds;
|
this.labelUpdateAccumulator += deltaSeconds;
|
||||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||||
this.labelUpdateAccumulator = 0;
|
this.labelUpdateAccumulator = 0;
|
||||||
@@ -248,7 +252,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentStarId !== null) {
|
if (this.systemGroup.visible) {
|
||||||
this.systemRenderer?.update(dateToJulianDate());
|
this.systemRenderer?.update(dateToJulianDate());
|
||||||
}
|
}
|
||||||
this.labelOverlay?.render(camera);
|
this.labelOverlay?.render(camera);
|
||||||
|
|||||||
@@ -88,6 +88,54 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('orbits with no published eccentricity', () => {
|
||||||
|
// The archive publishes a semi-major axis far more often than an eccentricity. Requiring
|
||||||
|
// both dropped 1509 otherwise drawable planets.
|
||||||
|
it('draws a planet that has an axis but no eccentricity', () => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0.4 } })]);
|
||||||
|
|
||||||
|
expect(renderer.members).toHaveLength(1);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places it on a circle of the right radius', () => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0.4 } })]);
|
||||||
|
|
||||||
|
for (const offset of [0, 5, 20, 60]) {
|
||||||
|
expect(positionAt(renderer, DEFAULT_EPOCH_JD + offset).length()).toBeCloseTo(0.4, 6);
|
||||||
|
}
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still honours the measured period', () => {
|
||||||
|
const renderer = new SystemOrbitsRenderer(
|
||||||
|
[],
|
||||||
|
[exoplanet({ orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU }, periodDays: TRAPPIST_1B_PERIOD_DAYS })]
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||||
|
const afterOnePeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS);
|
||||||
|
expect(afterOnePeriod.distanceTo(start)).toBeLessThan(1e-6);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips an escape trajectory rather than emitting NaN positions', () => {
|
||||||
|
// e >= 1 is not an ellipse; propagating it anyway yields NaN, which poisons the geometry's
|
||||||
|
// bounding sphere and disables culling for the whole object.
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 1, eccentricity: 1.4 } })]);
|
||||||
|
|
||||||
|
expect(renderer.members).toHaveLength(0);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a non-positive semi-major axis', () => {
|
||||||
|
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0, eccentricity: 0.1 } })]);
|
||||||
|
|
||||||
|
expect(renderer.members).toHaveLength(0);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps every propagated position finite', () => {
|
it('keeps every propagated position finite', () => {
|
||||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS, orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0.62 } })]);
|
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS, orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0.62 } })]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
import { gmForParent } from '../../shared/astro/constants';
|
import { gmForParent } from '../../shared/astro/constants';
|
||||||
import { orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
|
|
||||||
@@ -147,18 +147,14 @@ export class SystemOrbitsRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const exoplanet of exoplanets) {
|
for (const exoplanet of exoplanets) {
|
||||||
if (!exoplanet.orbit.semiMajorAxisAu || exoplanet.orbit.eccentricity === undefined) {
|
// Only a semi-major axis is genuinely required; resolveOrbitalElements defaults the rest,
|
||||||
continue; // not enough data to place on an orbit.
|
// 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({
|
const elements = resolveOrbitalElements(exoplanet.orbit);
|
||||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
|
||||||
eccentricity: exoplanet.orbit.eccentricity,
|
|
||||||
inclinationDeg: exoplanet.orbit.inclinationDeg,
|
|
||||||
longitudeOfAscendingNodeDeg: exoplanet.orbit.longitudeOfAscendingNodeDeg,
|
|
||||||
argumentOfPeriapsisDeg: exoplanet.orbit.argumentOfPeriapsisDeg,
|
|
||||||
meanAnomalyAtEpochDeg: exoplanet.orbit.meanAnomalyAtEpochDeg,
|
|
||||||
epochJd: exoplanet.orbit.epochJd
|
|
||||||
});
|
|
||||||
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||||
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
// 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.
|
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
|
import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
|
||||||
import {
|
import {
|
||||||
gravitationalParameterFromPeriod,
|
gravitationalParameterFromPeriod,
|
||||||
|
isPropagatableOrbit,
|
||||||
meanMotionRadPerDay,
|
meanMotionRadPerDay,
|
||||||
orbitEllipsePoints,
|
orbitEllipsePoints,
|
||||||
orbitalPeriodDays,
|
orbitalPeriodDays,
|
||||||
@@ -252,3 +253,58 @@ describe('resolveGravitationalParameter', () => {
|
|||||||
expect(gm / GM_SUN_AU3_PER_DAY2).toBeCloseTo(1.1, 1);
|
expect(gm / GM_SUN_AU3_PER_DAY2).toBeCloseTo(1.1, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isPropagatableOrbit', () => {
|
||||||
|
it('accepts an orbit with only a semi-major axis', () => {
|
||||||
|
// 1509 archive records publish an axis and no eccentricity; they are perfectly drawable.
|
||||||
|
expect(isPropagatableOrbit({ semiMajorAxisAu: 1 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a fully specified elliptical orbit', () => {
|
||||||
|
expect(isPropagatableOrbit({ semiMajorAxisAu: 0.05, eccentricity: 0.62 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the boundary eccentricities of an ellipse', () => {
|
||||||
|
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity: 0 })).toBe(true);
|
||||||
|
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity: 0.999 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an orbit with no semi-major axis at all', () => {
|
||||||
|
expect(isPropagatableOrbit({})).toBe(false);
|
||||||
|
expect(isPropagatableOrbit({ eccentricity: 0.1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-positive or non-finite semi-major axis', () => {
|
||||||
|
// sqrt of a negative and division by zero both yield NaN rather than throwing.
|
||||||
|
for (const semiMajorAxisAu of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||||
|
expect(isPropagatableOrbit({ semiMajorAxisAu })).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an eccentricity that is not an ellipse', () => {
|
||||||
|
// e >= 1 is a parabolic or hyperbolic escape trajectory, which no ellipse describes.
|
||||||
|
for (const eccentricity of [1, 1.4, -0.2, Number.NaN]) {
|
||||||
|
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity })).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveOrbitalElements eccentricity default', () => {
|
||||||
|
it('treats a missing eccentricity as a circle', () => {
|
||||||
|
expect(resolveOrbitalElements({ semiMajorAxisAu: 2 }).eccentricity).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a published eccentricity, including exactly zero', () => {
|
||||||
|
expect(resolveOrbitalElements({ semiMajorAxisAu: 2, eccentricity: 0.35 }).eccentricity).toBe(0.35);
|
||||||
|
expect(resolveOrbitalElements({ semiMajorAxisAu: 2, eccentricity: 0 }).eccentricity).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a genuine circle, not a degenerate ellipse', () => {
|
||||||
|
const elements = resolveOrbitalElements({ semiMajorAxisAu: 2 });
|
||||||
|
const radii = orbitEllipsePoints(elements).map((point) => Math.hypot(point.x, point.y, point.z));
|
||||||
|
|
||||||
|
for (const radius of radii) {
|
||||||
|
expect(radius).toBeCloseTo(2, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,15 +7,21 @@ const TWO_PI = Math.PI * 2;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
|
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
|
||||||
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: inclination, longitude
|
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: eccentricity,
|
||||||
* of ascending node, mean anomaly at epoch, and the epoch itself. Missing angles default to
|
* inclination, longitude of ascending node, mean anomaly at epoch, and the epoch itself.
|
||||||
* zero (a face-on, unrotated ellipse) and the missing epoch defaults to J2000 — enough to draw
|
* Missing angles default to zero (a face-on, unrotated ellipse) and the missing epoch defaults
|
||||||
* a plausible, period-correct orbit even without full data.
|
* to J2000 — enough to draw a plausible, period-correct orbit even without full data.
|
||||||
|
*
|
||||||
|
* A missing eccentricity defaults to 0, a circle. That is the conventional assumption for an
|
||||||
|
* orbit whose shape has not been constrained, and it is also the only honest one available: the
|
||||||
|
* semi-major axis alone says nothing about elongation. It matters because the archive publishes
|
||||||
|
* an axis far more often than an eccentricity — 1509 exoplanets have the first without the
|
||||||
|
* second — and treating those as undrawable simply hid them.
|
||||||
*/
|
*/
|
||||||
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu' | 'eccentricity'>): OrbitalElements {
|
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'>): OrbitalElements {
|
||||||
return {
|
return {
|
||||||
semiMajorAxisAu: partial.semiMajorAxisAu,
|
semiMajorAxisAu: partial.semiMajorAxisAu,
|
||||||
eccentricity: partial.eccentricity,
|
eccentricity: partial.eccentricity ?? 0,
|
||||||
inclinationDeg: partial.inclinationDeg ?? 0,
|
inclinationDeg: partial.inclinationDeg ?? 0,
|
||||||
longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0,
|
longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0,
|
||||||
argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0,
|
argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0,
|
||||||
@@ -24,6 +30,33 @@ export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a partially-specified orbit can actually be propagated as an ellipse.
|
||||||
|
*
|
||||||
|
* Everything downstream — the mean motion, the Kepler solver, the ellipse sampling — assumes a
|
||||||
|
* closed elliptical orbit around a positive semi-major axis. Feed it anything else and it does
|
||||||
|
* not throw: `sqrt` of a negative number and division by zero both yield `NaN`, which
|
||||||
|
* propagates silently into the vertex buffer and poisons the geometry's bounding sphere, taking
|
||||||
|
* out culling for the whole object rather than just the bad orbit.
|
||||||
|
*
|
||||||
|
* A missing eccentricity is fine and defaults to a circle (see {@link resolveOrbitalElements});
|
||||||
|
* a present but non-elliptical one (`e >= 1`, an escape trajectory) is not, since no ellipse
|
||||||
|
* describes it.
|
||||||
|
*/
|
||||||
|
export function isPropagatableOrbit(
|
||||||
|
partial: Partial<OrbitalElements>
|
||||||
|
): partial is Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'> {
|
||||||
|
const { semiMajorAxisAu, eccentricity } = partial;
|
||||||
|
|
||||||
|
if (semiMajorAxisAu === undefined || !Number.isFinite(semiMajorAxisAu) || semiMajorAxisAu <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (eccentricity !== undefined && (!Number.isFinite(eccentricity) || eccentricity < 0 || eccentricity >= 1)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Mean motion (rad/day) of a body via Kepler's third law: n = sqrt(GM / a^3). */
|
/** Mean motion (rad/day) of a body via Kepler's third law: n = sqrt(GM / a^3). */
|
||||||
export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
|
export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
|
||||||
return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));
|
return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));
|
||||||
|
|||||||
Reference in New Issue
Block a user