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 91c56bb..855de38 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -372,3 +372,58 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => { }); }); }); + +describe('rotation', () => { + /** Earth, near enough: a day of 23.934 h, tipped 23.44 degrees off its orbit. */ + function spinning(overrides: Partial = {}): BodyRecord { + return { + 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 }, + rotationPeriodHours: 23.934, + obliquityDeg: 23.4392911, + ...overrides + }; + } + + /** How far the marker has turned about its own axis between two dates, in degrees. */ + function turnedDegrees(body: BodyRecord, afterDays: number): number { + const renderer = new SystemOrbitsRenderer([body], [], undefined, 1); + renderer.update(DEFAULT_EPOCH_JD); + const start = renderer.members[0].marker.quaternion.clone(); + renderer.update(DEFAULT_EPOCH_JD + afterDays); + const turn = start.invert().multiply(renderer.members[0].marker.quaternion); + const axis = new THREE.Vector3(); + const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w))); + turn.normalize(); + axis.set(turn.x, turn.y, turn.z); + const signed = axis.y >= 0 ? angle : -angle; + return (signed * 180) / Math.PI; + } + + it('turns a body once per its own sidereal day', () => { + // A full turn in 23.934 h, so a quarter of that is a quarter turn. + expect(Math.abs(turnedDegrees(spinning(), 23.934 / 96))).toBeCloseTo(90, 1); + }); + + it('turns a retrograde body the other way', () => { + // Venus: its day runs backwards, which the catalogue carries as a negative period. + const forward = turnedDegrees(spinning(), 23.934 / 96); + const backward = turnedDegrees(spinning({ rotationPeriodHours: -23.934 }), 23.934 / 96); + + expect(Math.sign(backward)).toBe(-Math.sign(forward)); + }); + + it('leaves a body with no published rotation still', () => { + // Titan: Horizons states no period for it, and an invented one would be a claim. + const renderer = new SystemOrbitsRenderer([spinning({ rotationPeriodHours: undefined })], [], undefined, 1); + renderer.update(DEFAULT_EPOCH_JD); + const start = renderer.members[0].marker.quaternion.clone(); + renderer.update(DEFAULT_EPOCH_JD + 40); + + expect(renderer.members[0].marker.quaternion.angleTo(start)).toBe(0); + }); +}); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index d003445..3e05190 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -136,6 +136,36 @@ function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, appea return new THREE.Mesh(geometry, material); } + +/** Local axis a sphere is built around, and what the spin is applied about. */ +const SPIN_AXIS = new THREE.Vector3(0, 1, 0); +const HOURS_PER_DAY = 24; + +/** + * How a body is turned at a given date: its own sidereal rotation, about its own axis. + * + * The axis is the orbit normal tilted by the obliquity, about the orbit’s ascending node — + * which is where an obliquity is measured from, and the only line in the orbit the elements + * name. The phase at the epoch is not published for any of these bodies, so a body is drawn + * with its prime meridian toward the node at J2000 and turned from there: the rate and the + * direction are real, where the face pointed at the camera is not. + * + * A negative period is retrograde — Venus, and Uranus by the IAU’s convention — and comes + * out of the arithmetic without a special case. + */ +function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, obliquityDeg: number, epochJd: number): THREE.Quaternion { + const node = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD; + const inclination = elements.inclinationDeg * DEG_TO_RAD; + const nodeDirection = new THREE.Vector3(Math.cos(node), Math.sin(node), 0); + const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination)) + .applyAxisAngle(nodeDirection, obliquityDeg * DEG_TO_RAD) + .applyQuaternion(frame); + const turns = ((epochJd - elements.epochJd) * HOURS_PER_DAY) / rotationPeriodHours; + return new THREE.Quaternion() + .setFromUnitVectors(SPIN_AXIS, axis) + .multiply(new THREE.Quaternion().setFromAxisAngle(SPIN_AXIS, turns * 2 * Math.PI)); +} + interface TrackedTopLevelBody { id: string; kind: SystemMemberKind; @@ -146,6 +176,9 @@ interface TrackedTopLevelBody { frame: THREE.Quaternion; /** AU position last computed for this body; moons read their parent's here. */ position: THREE.Vector3; + /** Sidereal rotation, where the catalogue publishes one; negative is retrograde. */ + rotationPeriodHours?: number; + obliquityDeg?: number; } interface TrackedMoon { @@ -156,6 +189,8 @@ interface TrackedMoon { frame: THREE.Quaternion; pivot: THREE.Group; parentId: string; + rotationPeriodHours?: number; + obliquityDeg?: number; } /** @@ -229,7 +264,7 @@ export class SystemOrbitsRenderer { } // A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'. const kind: SystemMemberKind = body.kind; - const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); + const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg }); members.push({ id: body.id, kind, marker: tracked.marker }); } @@ -242,7 +277,7 @@ export class SystemOrbitsRenderer { if (!parentTracked) { continue; // orphaned moon reference; skip rather than crash. } - const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); + const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg }); members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id }); } @@ -309,6 +344,9 @@ export class SystemOrbitsRenderer { const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd); body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame); body.marker.position.copy(body.position); + if (body.rotationPeriodHours) { + body.marker.quaternion.copy(spinFor(body.elements, body.frame, body.rotationPeriodHours, body.obliquityDeg ?? 0, epochJd)); + } } for (const moon of this.moons) { @@ -319,6 +357,9 @@ export class SystemOrbitsRenderer { moon.pivot.position.copy(parent.position); const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd); moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame); + if (moon.rotationPeriodHours) { + moon.marker.quaternion.copy(spinFor(moon.elements, moon.frame, moon.rotationPeriodHours, moon.obliquityDeg ?? 0, epochJd)); + } } // Moons are left out: their tether would land within a marker's width of their planet's and @@ -373,7 +414,8 @@ export class SystemOrbitsRenderer { gmAu3PerDay2: number, radiusKm: number | undefined, frame: THREE.Quaternion, - appearance?: PlanetAppearance + appearance?: PlanetAppearance, + rotation?: { periodHours?: number; obliquityDeg?: number } ): TrackedTopLevelBody { const orbitLine = buildOrbitLine(elements, kind, frame); const marker = buildMarker(kind, radiusKm, appearance); @@ -381,7 +423,7 @@ export class SystemOrbitsRenderer { this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); - const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() }; + const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg }; this.topLevelBodies.push(tracked); return tracked; } @@ -393,7 +435,8 @@ export class SystemOrbitsRenderer { radiusKm: number | undefined, parent: TrackedTopLevelBody, frame: THREE.Quaternion, - appearance?: PlanetAppearance + appearance?: PlanetAppearance, + rotation?: { periodHours?: number; obliquityDeg?: number } ): TrackedMoon { const pivot = new THREE.Group(); const orbitLine = buildOrbitLine(elements, 'moon', frame); @@ -405,7 +448,7 @@ export class SystemOrbitsRenderer { this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); - const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id }; + const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg }; this.moons.push(moon); return moon; } diff --git a/src/app/shared/models/body.model.ts b/src/app/shared/models/body.model.ts index 2485db6..95c9126 100644 --- a/src/app/shared/models/body.model.ts +++ b/src/app/shared/models/body.model.ts @@ -29,4 +29,13 @@ export interface BodyRecord { * relative to that planet, not heliocentrically. Undefined for planets/dwarfs. */ parentBodyId?: string; + /** + * How the body turns on its own axis: the sidereal rotation period in hours, negative where it + * turns retrograde (Venus, Triton), and the tilt of that axis from its orbital plane. + * + * Absent where Horizons publishes neither — the view then leaves the body still rather than + * spinning it at an invented rate. + */ + rotationPeriodHours?: number; + obliquityDeg?: number; } diff --git a/src/assets/data/bodies.json b/src/assets/data/bodies.json index 571d22c..5220896 100644 --- a/src/assets/data/bodies.json +++ b/src/assets/data/bodies.json @@ -13,7 +13,9 @@ "argumentOfPeriapsisDeg": 29.19561108948743, "meanAnomalyAtEpochDeg": 103.9465145977117, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 1407.512239412851, + "obliquityDeg": 2.11 }, { "id": "venus", @@ -29,7 +31,9 @@ "argumentOfPeriapsisDeg": 55.15075425343929, "meanAnomalyAtEpochDeg": 280.0749102629981, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": -5832.539941165383, + "obliquityDeg": 177.3 }, { "id": "earth", @@ -45,7 +49,9 @@ "argumentOfPeriapsisDeg": 272.9783142442708, "meanAnomalyAtEpochDeg": 357.4122246804211, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 23.934472399219285, + "obliquityDeg": 23.4392911 }, { "id": "mars", @@ -61,7 +67,9 @@ "argumentOfPeriapsisDeg": 286.7114828288203, "meanAnomalyAtEpochDeg": 124.444888195349, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 24.622955438662025, + "obliquityDeg": 25.19 }, { "id": "jupiter", @@ -77,7 +85,9 @@ "argumentOfPeriapsisDeg": 273.6090683600047, "meanAnomalyAtEpochDeg": 58.98282567286461, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 9.925102371306965, + "obliquityDeg": 3.13 }, { "id": "saturn", @@ -93,7 +103,9 @@ "argumentOfPeriapsisDeg": 337.1663598259115, "meanAnomalyAtEpochDeg": 264.9877650588944, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 10.656221583138441, + "obliquityDeg": 26.73 }, { "id": "uranus", @@ -109,7 +121,9 @@ "argumentOfPeriapsisDeg": 90.49593456147204, "meanAnomalyAtEpochDeg": 255.8822481742851, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": -17.24003330792427, + "obliquityDeg": 97.77 }, { "id": "neptune", @@ -125,7 +139,9 @@ "argumentOfPeriapsisDeg": 268.1146665565159, "meanAnomalyAtEpochDeg": 319.6858384317641, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 16.110037586020876, + "obliquityDeg": 28.32 }, { "id": "pluto", @@ -141,7 +157,8 @@ "argumentOfPeriapsisDeg": 113.5754868232679, "meanAnomalyAtEpochDeg": 51.93655343727463, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 153.29335198 }, { "id": "moon", @@ -158,7 +175,9 @@ "meanAnomalyAtEpochDeg": 290.7825171697369, "epochJd": 2460676.5 }, - "parentBodyId": "earth" + "parentBodyId": "earth", + "rotationPeriodHours": 655.7197475276438, + "obliquityDeg": 6.67 }, { "id": "phobos", @@ -175,7 +194,8 @@ "meanAnomalyAtEpochDeg": 342.6005509941174, "epochJd": 2460676.5 }, - "parentBodyId": "mars" + "parentBodyId": "mars", + "rotationPeriodHours": 7.660212212136228 }, { "id": "deimos", @@ -192,7 +212,8 @@ "meanAnomalyAtEpochDeg": 273.8943716897566, "epochJd": 2460676.5 }, - "parentBodyId": "mars" + "parentBodyId": "mars", + "rotationPeriodHours": 30.304279685850094 }, { "id": "io", @@ -209,7 +230,8 @@ "meanAnomalyAtEpochDeg": 74.88524962049125, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 42.51537583211252 }, { "id": "europa", @@ -226,7 +248,8 @@ "meanAnomalyAtEpochDeg": 40.72575250295771, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 85.27848729079142 }, { "id": "ganymede", @@ -243,7 +266,8 @@ "meanAnomalyAtEpochDeg": 355.7249344187845, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 171.78271980469088 }, { "id": "callisto", @@ -260,7 +284,8 @@ "meanAnomalyAtEpochDeg": 126.0198540648681, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 400.52470451330635 }, { "id": "titan", @@ -294,6 +319,7 @@ "meanAnomalyAtEpochDeg": 273.1157438944629, "epochJd": 2460676.5 }, - "parentBodyId": "neptune" + "parentBodyId": "neptune", + "rotationPeriodHours": 141.05626027628523 } ] \ No newline at end of file diff --git a/tools/etl/fetchSolarSystem.ts b/tools/etl/fetchSolarSystem.ts index 9eda8d5..d71b0cc 100644 --- a/tools/etl/fetchSolarSystem.ts +++ b/tools/etl/fetchSolarSystem.ts @@ -1,10 +1,14 @@ import { writeFileSync } from 'node:fs'; import { BodyRecord } from '../../src/app/shared/models/body.model'; +import { gmForParent } from '../../src/app/shared/astro/constants'; +import { orbitalPeriodDays } from '../../src/app/shared/astro/kepler'; import { SUN_STAR_ID } from '../../src/app/shared/models/star.model'; import { fetchHorizonsBody } from './lib/horizons'; import { dataPath, ensureDataDir } from './lib/paths'; +const HOURS_PER_DAY = 24; + interface BodySpec { id: string; name: string; @@ -56,6 +60,15 @@ export async function fetchSolarSystem(): Promise { console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`); } + // A tidally locked moon's day is its orbit, which Horizons states as a word rather than a + // number: Kepler gives the period from the elements just parsed and the parent it goes round. + const rotationPeriodHours = result.tidallyLocked + ? orbitalPeriodDays(result.orbit.semiMajorAxisAu, gmForParent(spec.parentBodyId)) * HOURS_PER_DAY + : result.rotationPeriodHours; + if (rotationPeriodHours === undefined) { + console.warn(` no rotation period found for ${spec.name}; it will not turn.`); + } + bodies.push({ id: spec.id, systemStarId: SUN_STAR_ID, @@ -63,7 +76,9 @@ export async function fetchSolarSystem(): Promise { kind: spec.kind, radiusKm: result.radiusKm ?? 0, orbit: result.orbit, - ...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}) + ...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}), + ...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}), + ...(result.obliquityDeg !== undefined ? { obliquityDeg: result.obliquityDeg } : {}) }); } diff --git a/tools/etl/lib/horizons.ts b/tools/etl/lib/horizons.ts index a4960f9..67e086b 100644 --- a/tools/etl/lib/horizons.ts +++ b/tools/etl/lib/horizons.ts @@ -20,6 +20,15 @@ export interface HorizonsQuery { export interface HorizonsResult { radiusKm?: number; orbit: OrbitalElements; + /** + * Sidereal rotation period in hours, negative where the body turns retrograde — Venus, whose + * day runs backwards, and Triton. Absent where the page publishes none. + */ + rotationPeriodHours?: number; + /** Tilt of the rotation axis from the body's own orbital plane, in degrees. */ + obliquityDeg?: number; + /** The page says "Synchronous" instead of a period: its day is its orbit. */ + tidallyLocked: boolean; } const RADIUS_PATTERNS = [ @@ -30,10 +39,66 @@ const RADIUS_PATTERNS = [ /Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i ]; +/** + * How each page states how fast the body turns, in the order they are tried. + * + * The rate in radians per second is preferred wherever it appears: it is unambiguous and it is + * signed, which is how Venus and Triton are known to turn backwards. A period in hours or days + * comes next, then the sexagesimal form the giant planets use, and finally the word every major + * moon here carries instead of a number — tidally locked, so its day is its year, which the + * caller supplies from the orbit it has already parsed. + */ +const ROTATION_RATE_PATTERN = /Rot(?:ational)?\.?\s*Rate\s*[(,]\s*rad\/s\s*\)?\s*=\s*(-?[\d.]+)/i; +const ROTATION_PERIOD_PATTERNS = [ + /Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(-?[\d.]+)(?:\+-[\d.]+)?\s*(h|hr|hrs|d|day|days)\b/i, + /Rotation(?:al)?\s*period[^=]*=\s*(-?[\d.]+)\s*(h|hr|hrs|d|day|days)\b/i +]; +/** `9h 55m 29.711 s`, as Jupiter and Saturn state it. */ +const SEXAGESIMAL_ROTATION_PATTERN = /Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(\d+)\s*h\s*(\d+)\s*m\s*([\d.]+)\s*s/i; +const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i; +const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)/i; + +const HOURS_PER_DAY = 24; +const SECONDS_PER_HOUR = 3600; + +/** + * True where the page gives no number because the body keeps one face to its parent, so its day + * is its orbit — every major moon here. The period itself is then Kepler's, which the caller + * works out from the elements above and the parent's mass. + */ +export function isTidallyLocked(text: string): boolean { + return SYNCHRONOUS_PATTERN.test(text); +} + +/** Sidereal rotation period, in hours, from whichever form the page states it in. */ +export function extractRotationPeriodHours(text: string): number | undefined { + const rate = text.match(ROTATION_RATE_PATTERN); + if (rate && Number(rate[1]) !== 0) { + return (2 * Math.PI) / (Number(rate[1]) * SECONDS_PER_HOUR); + } + const sexagesimal = text.match(SEXAGESIMAL_ROTATION_PATTERN); + if (sexagesimal) { + return Number(sexagesimal[1]) + Number(sexagesimal[2]) / 60 + Number(sexagesimal[3]) / SECONDS_PER_HOUR; + } + for (const pattern of ROTATION_PERIOD_PATTERNS) { + const match = text.match(pattern); + if (match) { + const hours = Number(match[1]) * (match[2].toLowerCase().startsWith('d') ? HOURS_PER_DAY : 1); + return Number.isFinite(hours) && hours !== 0 ? hours : undefined; + } + } + return undefined; +} + +export function extractObliquityDeg(text: string): number | undefined { + const match = text.match(OBLIQUITY_PATTERN); + return match ? Number(match[1]) : undefined; +} + /** * Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating - * orbital elements plus, when available, its mean physical radius — both in a single - * request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`). + * orbital elements plus, when available, its mean physical radius and how it turns — all in a + * single request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`). */ export async function fetchHorizonsBody(query: HorizonsQuery): Promise { const url = @@ -44,7 +109,10 @@ export async function fetchHorizonsBody(query: HorizonsQuery): Promise