Put orbits and stars in the same reference frame

The app's two sources disagree about which frame they are in, and nothing
reconciled them. HYG star positions are equatorial J2000 — that is what
raDecDistanceToXyz produces and what the galaxy view renders directly. Orbital
elements come from JPL Horizons, whose default reference plane for element
output is the ecliptic, and the ETL never overrides it. The two are tilted 23.4
degrees apart, so the orbits sat that far off the sky they are drawn against.

Confirmed rather than assumed, from both ends: the Horizons request in
lib/horizons.ts sets no REF_PLANE, and the resulting solar-system inclinations
are 0 to 17 degrees with Earth exactly 0.00 — which is only true of the ecliptic,
since Earth's orbit defines it.

eclipticToEquatorial now rotates orbit positions into the scene frame, so a
direction means the same thing in the galaxy view and the system view. The
rotation is about the vernal-equinox axis, which both frames share.

That exposed a presentation problem the old code had been hiding. The renderer
mapped the propagator's z straight onto the scene's vertical, which silently
redefined the frame but did make systems render flat. In a properly equatorial
scene, orbital planes lie 23.4 degrees off the scene's own axes, so a system
would be presented edge-on. Rather than rotate the world back into a comfortable
pose — which would only put the orbits at odds with the sky again — the camera
now settles relative to the orbital plane: a three-quarter view about 37 degrees
off the ecliptic normal. The arrival still begins along the approach direction
and swings round as it settles, so the transition stays continuous, and the
framing is now the same every time rather than inherited from wherever the
camera happened to be.

Tests: 247 passing, up from 237. The frame tests are the discriminating kind —
Earth's orbit must lie perpendicular to the ecliptic pole rather than to the
scene's vertical, and must reach 23.4 degrees of declination a quarter orbit on,
where it used to read zero. Verified in a browser against Sol and Gl 357.

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 16:10:17 +00:00
parent dad4b93e8a
commit 2293585940
7 changed files with 214 additions and 10 deletions
+69 -1
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest';
import { distanceBetween, parallaxMasToParsecs, parseSexagesimal, raDecDistanceToXyz, raDecToUnitVector, raDegDecDistanceToXyz } from './coordinates';
import {
distanceBetween,
eclipticToEquatorial,
equatorialToEcliptic,
OBLIQUITY_J2000_DEG,
parallaxMasToParsecs,
parseSexagesimal,
raDecDistanceToXyz,
raDecToUnitVector,
raDegDecDistanceToXyz
} from './coordinates';
// Reference values taken directly from the HYG v4.1 database (RA/Dec/dist and its own
// precomputed x/y/z, which uses the same equatorial-Cartesian convention we implement).
@@ -136,3 +146,61 @@ describe('parseSexagesimal', () => {
expect(parseSexagesimal('12::56')).toBeNull();
});
});
describe('eclipticToEquatorial', () => {
const RAD = Math.PI / 180;
it('leaves the vernal equinox untouched, since both frames share that axis', () => {
// +X is where the ecliptic crosses the celestial equator, so it is the rotation axis.
expect(eclipticToEquatorial({ x: 1, y: 0, z: 0 })).toEqual({ x: 1, y: 0, z: 0 });
});
it('puts the ecliptic pole the obliquity away from the celestial pole', () => {
const pole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
const angleFromCelestialPoleDeg = Math.acos(pole.z) / RAD;
expect(angleFromCelestialPoleDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
expect(pole.x).toBeCloseTo(0, 12);
expect(pole.y).toBeCloseTo(-Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
});
it('places the summer solstice point at the obliquity in declination', () => {
// Ecliptic longitude 90 degrees is the northernmost point of the Sun's yearly path, whose
// declination is by definition the obliquity — about 23.4 degrees.
const solstice = eclipticToEquatorial({ x: 0, y: 1, z: 0 });
const declinationDeg = Math.asin(solstice.z) / RAD;
expect(declinationDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
});
it('preserves length, being a rotation', () => {
const rotated = eclipticToEquatorial({ x: 0.3, y: -0.5, z: 0.81 });
expect(Math.hypot(rotated.x, rotated.y, rotated.z)).toBeCloseTo(Math.hypot(0.3, -0.5, 0.81), 12);
});
it('leaves a point in the ecliptic plane in that plane, tilted out of the equator', () => {
const inPlane = eclipticToEquatorial({ x: 0.6, y: 0.8, z: 0 });
expect(inPlane.z).toBeCloseTo(0.8 * Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
});
});
describe('equatorialToEcliptic', () => {
it('is the exact inverse of eclipticToEquatorial', () => {
for (const point of [
{ x: 1, y: 0, z: 0 },
{ x: 0, y: 1, z: 0 },
{ x: 0, y: 0, z: 1 },
{ x: -0.37, y: 0.42, z: 0.83 }
]) {
const round = equatorialToEcliptic(eclipticToEquatorial(point));
expect(round.x).toBeCloseTo(point.x, 12);
expect(round.y).toBeCloseTo(point.y, 12);
expect(round.z).toBeCloseTo(point.z, 12);
}
});
it('brings the celestial pole back to the obliquity off the ecliptic pole', () => {
const pole = equatorialToEcliptic({ x: 0, y: 0, z: 1 });
expect(Math.acos(pole.z) / (Math.PI / 180)).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
});
});
+43
View File
@@ -33,6 +33,49 @@ export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc:
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
}
/**
* Obliquity of the ecliptic at J2000.0, in degrees — the tilt of Earth's orbital plane against
* its equator, and so the angle between this app's two source frames.
*/
export const OBLIQUITY_J2000_DEG = 23.4392911;
/**
* Rotates a vector from the **ecliptic** frame into the **equatorial** frame, both J2000.
*
* The app has to span both because its two sources disagree. Star positions come from HYG as
* equatorial coordinates, which `raDecDistanceToXyz` produces and which the galaxy view renders
* directly. Orbital elements come from JPL Horizons, whose default reference plane for element
* output is the ecliptic — the ETL never overrides it. The two are tilted
* {@link OBLIQUITY_J2000_DEG} apart about the shared vernal-equinox axis, so orbits have to be
* rotated before they can share a scene with the stars.
*/
export function eclipticToEquatorial(position: CartesianCoordinates): CartesianCoordinates {
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
const cos = Math.cos(obliquity);
const sin = Math.sin(obliquity);
// A rotation about +X, which both frames share: it points at the vernal equinox, where the
// ecliptic and the celestial equator cross.
return {
x: position.x,
y: position.y * cos - position.z * sin,
z: position.y * sin + position.z * cos
};
}
/** Inverse of {@link eclipticToEquatorial}. */
export function equatorialToEcliptic(position: CartesianCoordinates): CartesianCoordinates {
const obliquity = OBLIQUITY_J2000_DEG * DEG_TO_RAD;
const cos = Math.cos(obliquity);
const sin = Math.sin(obliquity);
return {
x: position.x,
y: position.y * cos + position.z * sin,
z: -position.y * sin + position.z * cos
};
}
/**
* Direction to a point on the celestial sphere as a unit vector, in the same equatorial frame
* as {@link raDecDistanceToXyz}. Used for sources whose distance is unknown or irrelevant —