diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 48b4325..61b8bd4 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -240,7 +240,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.rig?.update(deltaSeconds); 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; if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) { 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.labelOverlay?.render(camera); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts index 1a257d9..87f9ac5 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -88,6 +88,54 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => { 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', () => { const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS, orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0.62 } })]); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index f6c3623..810c1d3 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -1,7 +1,7 @@ import * as THREE from 'three/webgpu'; 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 { ExoplanetRecord } from '../../shared/models/exoplanet.model'; @@ -147,18 +147,14 @@ export class SystemOrbitsRenderer { } for (const exoplanet of exoplanets) { - if (!exoplanet.orbit.semiMajorAxisAu || exoplanet.orbit.eccentricity === undefined) { - continue; // not enough data to place on an orbit. + // 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({ - 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 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. diff --git a/src/app/shared/astro/kepler.spec.ts b/src/app/shared/astro/kepler.spec.ts index 6b29761..eaa1a51 100644 --- a/src/app/shared/astro/kepler.spec.ts +++ b/src/app/shared/astro/kepler.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants'; import { gravitationalParameterFromPeriod, + isPropagatableOrbit, meanMotionRadPerDay, orbitEllipsePoints, orbitalPeriodDays, @@ -252,3 +253,58 @@ describe('resolveGravitationalParameter', () => { 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); + } + }); +}); diff --git a/src/app/shared/astro/kepler.ts b/src/app/shared/astro/kepler.ts index d6dda4c..05dff65 100644 --- a/src/app/shared/astro/kepler.ts +++ b/src/app/shared/astro/kepler.ts @@ -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, - * see `ExoplanetRecord.orbit: Partial`) don't report: inclination, longitude - * of ascending node, mean anomaly at epoch, and the epoch itself. Missing angles default to - * zero (a face-on, unrotated ellipse) and the missing epoch defaults to J2000 — enough to draw - * a plausible, period-correct orbit even without full data. + * see `ExoplanetRecord.orbit: Partial`) don't report: eccentricity, + * inclination, longitude of ascending node, mean anomaly at epoch, and the epoch itself. + * Missing angles default to zero (a face-on, unrotated ellipse) and the missing epoch defaults + * 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 & Pick): OrbitalElements { +export function resolveOrbitalElements(partial: Partial & Pick): OrbitalElements { return { semiMajorAxisAu: partial.semiMajorAxisAu, - eccentricity: partial.eccentricity, + eccentricity: partial.eccentricity ?? 0, inclinationDeg: partial.inclinationDeg ?? 0, longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0, argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0, @@ -24,6 +30,33 @@ export function resolveOrbitalElements(partial: Partial & 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 +): partial is Partial & Pick { + 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). */ export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number { return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));