Turn each body at its own rate, from Horizons' own figures

The view had one rotation in it — the planet on the detail page, at 0.08 rad/s,
a number with no source. Nothing in the system view turned at all.

The data was already on disk: every cached Horizons page carries how its body
spins, in one of five forms. The rate in radians per second is preferred where
it appears, because it is signed — that is how Venus and Uranus are known to
turn backwards — then a period in hours or days, then the `9h 55m 29.711 s`
the giant planets use, and finally the word every major moon here carries
instead of a number: Synchronous. A tidally locked moon's day is its orbit, so
Kepler supplies it from the elements already parsed and the parent it goes
round.

Seventeen of the eighteen bodies come out within 1% of their published period —
Earth 23.934 h, Jupiter 9.925 h, Venus -5832.5 h, Io 42.5 h, Callisto 400.5 h.
Titan is the exception: its page states no period at all, so it is left still
rather than turned at an invented rate.

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 published for none of
these bodies, so the face turned toward the camera is not a claim; the rate and
the direction are.

At true rates nothing is visible moving — Earth turns 15 degrees an hour. A
clock the reader can run faster is the next piece, and the audit asks for it
anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-21 23:12:02 +02:00
co-authored by Claude Opus 5
parent 3f0abf8717
commit 225d676ab0
6 changed files with 243 additions and 27 deletions
@@ -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> = {}): 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);
});
});
@@ -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;
}
+9
View File
@@ -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;
}