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:
@@ -15,7 +15,7 @@ import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { CameraRigController } from './camera-rig-controller';
|
||||
import { DeepSkyRenderer } from './deep-sky-renderer';
|
||||
import { starMarkerRadiusAu, systemFramingDistanceAu } from './system-framing';
|
||||
import { starMarkerRadiusAu, SYSTEM_VIEW_DIRECTION, systemFramingDistanceAu } from './system-framing';
|
||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
@@ -431,8 +431,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
|
||||
|
||||
const framingDistance = systemFramingDistanceAu(this.systemRenderer.maxTopLevelSemiMajorAxisAu);
|
||||
// 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
|
||||
// presented edge-on. See SYSTEM_VIEW_DIRECTION.
|
||||
const viewDirection = new THREE.Vector3(SYSTEM_VIEW_DIRECTION.x, SYSTEM_VIEW_DIRECTION.y, SYSTEM_VIEW_DIRECTION.z).normalize();
|
||||
|
||||
this.rig!.flyTo({ position: direction.clone().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.navigationStore.setViewLevel('system');
|
||||
onComplete();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CartesianCoordinates, eclipticToEquatorial } from '../../shared/astro/coordinates';
|
||||
|
||||
/**
|
||||
* How the system view sizes itself to whatever system it is showing.
|
||||
*
|
||||
@@ -42,6 +44,19 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera settles when arriving at a system, as a unit direction from the star in the
|
||||
* scene's equatorial frame.
|
||||
*
|
||||
* The scene is equatorial so that orbits and stars share one frame, but orbital planes lie
|
||||
* close to the *ecliptic*, which is tilted 23.4 degrees out of it. Left to the equatorial axes,
|
||||
* 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 });
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw the system's star at, given its innermost orbit.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants';
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
|
||||
@@ -145,4 +147,67 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
describe('reference frame', () => {
|
||||
/** Earth: inclination 0 by definition — its orbit *is* the ecliptic plane. */
|
||||
const EARTH: BodyRecord = {
|
||||
id: 'earth',
|
||||
systemStarId: 0,
|
||||
name: 'Earth',
|
||||
kind: 'planet',
|
||||
radiusKm: 6371,
|
||||
orbit: {
|
||||
semiMajorAxisAu: 1,
|
||||
eccentricity: 0.0167,
|
||||
inclinationDeg: 0,
|
||||
longitudeOfAscendingNodeDeg: 0,
|
||||
argumentOfPeriapsisDeg: 0,
|
||||
meanAnomalyAtEpochDeg: 0,
|
||||
epochJd: DEFAULT_EPOCH_JD
|
||||
}
|
||||
};
|
||||
|
||||
it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => {
|
||||
// Horizons reports elements against the ecliptic; the scene is equatorial, to match the
|
||||
// star catalogue. So Earth's orbit must come out tilted, lying perpendicular to the
|
||||
// *ecliptic* pole rather than to the scene's own vertical.
|
||||
const renderer = new SystemOrbitsRenderer([EARTH], []);
|
||||
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
|
||||
for (const offset of [0, 40, 91, 200, 300]) {
|
||||
renderer.update(DEFAULT_EPOCH_JD + offset);
|
||||
const p = renderer.members[0].marker.position;
|
||||
const outOfPlane = p.x * eclipticPole.x + p.y * eclipticPole.y + p.z * eclipticPole.z;
|
||||
expect(Math.abs(outOfPlane)).toBeLessThan(1e-9);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('tilts that orbit away from the celestial equator by the obliquity', () => {
|
||||
// The discriminating check: before the frames were reconciled, the orbit sat flat in the
|
||||
// scene and this angle was zero.
|
||||
const renderer = new SystemOrbitsRenderer([EARTH], []);
|
||||
renderer.update(DEFAULT_EPOCH_JD + 91); // a quarter orbit on, well away from the equinox
|
||||
|
||||
const p = renderer.members[0].marker.position;
|
||||
const latitudeDeg = (Math.asin(p.z / p.length()) * 180) / Math.PI;
|
||||
|
||||
expect(Math.abs(latitudeDeg)).toBeGreaterThan(1);
|
||||
expect(Math.abs(latitudeDeg)).toBeLessThanOrEqual(OBLIQUITY_J2000_DEG + 1e-6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps the vernal equinox direction shared between the two frames', () => {
|
||||
// A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move.
|
||||
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
|
||||
const renderer = new SystemOrbitsRenderer([atEquinox], []);
|
||||
renderer.update(DEFAULT_EPOCH_JD);
|
||||
|
||||
const p = renderer.members[0].marker.position;
|
||||
expect(p.x).toBeCloseTo(1, 6);
|
||||
expect(p.y).toBeCloseTo(0, 9);
|
||||
expect(p.z).toBeCloseTo(0, 9);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as THREE from 'three/webgpu';
|
||||
|
||||
import { gmForParent } from '../../shared/astro/constants';
|
||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||
import { eclipticToEquatorial } from '../../shared/astro/coordinates';
|
||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||
import { bodyMarkerRadiusAu } from './system-framing';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
@@ -46,9 +47,12 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THRE
|
||||
const points = orbitEllipsePoints(elements);
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
points.forEach((point, index) => {
|
||||
positions[index * 3] = point.x;
|
||||
positions[index * 3 + 1] = point.z; // AU "up" (ecliptic normal) maps to scene Y.
|
||||
positions[index * 3 + 2] = point.y;
|
||||
// Elements are ecliptic (Horizons' default reference plane); the scene is equatorial, to
|
||||
// match the star catalogue. Without this the orbits sit 23.4 degrees off the sky.
|
||||
const { x, y, z } = eclipticToEquatorial(point);
|
||||
positions[index * 3] = x;
|
||||
positions[index * 3 + 1] = y;
|
||||
positions[index * 3 + 2] = z;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
@@ -175,8 +179,8 @@ export class SystemOrbitsRenderer {
|
||||
/** 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 { x, y, z } = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
||||
body.position.set(x, z, y); // AU "up" maps to scene Y, matching buildOrbitLine.
|
||||
const { x, y, z } = eclipticToEquatorial(propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd));
|
||||
body.position.set(x, y, z); // Equatorial, matching buildOrbitLine and the star field.
|
||||
body.marker.position.copy(body.position);
|
||||
}
|
||||
|
||||
@@ -186,8 +190,8 @@ export class SystemOrbitsRenderer {
|
||||
continue;
|
||||
}
|
||||
moon.pivot.position.copy(parent.position);
|
||||
const { x, y, z } = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
||||
moon.marker.position.set(x, z, y);
|
||||
const { x, y, z } = eclipticToEquatorial(propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd));
|
||||
moon.marker.position.set(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user