diff --git a/README.md b/README.md index a8114b9..99612b2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ same place an in-scene click would. star field is instanced quads on a `SpriteNodeMaterial` instead, which behaves the same on both backends. Their size is angular rather than world-space — real stars are unresolvable point sources, so apparent size should follow brightness, not distance. +- **One reference frame.** The two data sources disagree: HYG gives star positions in + equatorial J2000, while JPL Horizons reports orbital elements against the ecliptic, tilted + 23.4° away. The scene is equatorial throughout and orbits are rotated into it, so a direction + means the same thing in both views. Systems are still presented face-on — by placing the + camera relative to the orbital plane rather than by rotating the world into a convenient pose. - **Two coordinate scales.** The galaxy view works in parsecs and the system view in AU — about eight orders of magnitude apart, which wrecks float precision if rendered in one unit space. The camera rig recentres the active star to the origin ("floating origin") and swaps 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 53774cc..bd38439 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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(); diff --git a/src/app/features/galaxy-system/system-framing.ts b/src/app/features/galaxy-system/system-framing.ts index 3307eb7..7c73d65 100644 --- a/src/app/features/galaxy-system/system-framing.ts +++ b/src/app/features/galaxy-system/system-framing.ts @@ -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. * 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 87f9ac5..fc2311b 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -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(); + }); + }); }); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 6979bee..56f1931 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -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); } } diff --git a/src/app/shared/astro/coordinates.spec.ts b/src/app/shared/astro/coordinates.spec.ts index 2b75a28..0bc35f6 100644 --- a/src/app/shared/astro/coordinates.spec.ts +++ b/src/app/shared/astro/coordinates.spec.ts @@ -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); + }); +}); diff --git a/src/app/shared/astro/coordinates.ts b/src/app/shared/astro/coordinates.ts index 7b4b7e3..8118407 100644 --- a/src/app/shared/astro/coordinates.ts +++ b/src/app/shared/astro/coordinates.ts @@ -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 —