Move a body page's camera across the equator only once the Sun is 3 degrees past it

a9e910c mirrored the camera whenever the Sun's height above the body's
equator changed sign. The side is chosen for Saturn's rings, whose Sun goes
26.7 degrees either side, but the rule held for every body: Mercury's Sun,
never more than 0.034 degrees off its equator, crosses it 8.3 times a year,
so at a month a second the camera went from one side to the other every
1.45 seconds, mirroring wherever the reader had orbited it, with both sides
lit alike. Venus's Sun reaches 2.6 degrees and the Moon's 1.6.

The side is still chosen whenever a body is shown, but afterwards it changes
only when the Sun is more than 3 degrees across (SUN_SIDE_MIN_SINE, on the
sine of its latitude). Measured in the app on :4301, camera side changes
while the clock ran at a month a second:
- Mercury, 12 s from 2026-10-20: 0 (the Sun at most 0.0339 degrees off).
- Venus, 12 s: 0 (2.64). The Moon, 15 s: 0 (1.58).
- Earth, 12 s from 2025: 2, on 2025-03-28 and 2025-09-30, a week after
  each equinox.
- Saturn, 20 s from 2038-06: 1, on 2039-08-03, half a year after its
  equinox (it followed at 2039-01-19 before).
Reviewers had measured 7 or 8 changes in 10 to 12 s on Mercury's page, 3 on
Venus's and 1 or 2 on the Moon's.

Test: 'leaves the camera on its side while the Sun only grazes the equator:
Mercury through two crossings' steps Mercury's page a day at a time from
2026-10-20 over 60 days, through the Sun's crossings about 1 November and
6 December, and expects the camera south throughout. Guarded mutants, each
failing that test alone: the dead band removed (the sign alone chooses),
and the dead band applied to a body just shown too (Mercury then opens with
the camera on the side away from its Sun). The Saturn tests (2032 and past
the 2039 equinox, set to 2045) pass unchanged. Unit suite 867 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:33:04 +02:00
co-authored by Claude Opus 5.5
parent 69cada7914
commit d5a5a0fc3a
2 changed files with 41 additions and 6 deletions
@@ -38,7 +38,14 @@ const SATURN: BodyRecord = {
// their own light; Eris's day is measured, and Hyperion tumbles and has none. // their own light; Eris's day is measured, and Hyperion tumbles and has none.
const ERIS: BodyRecord = { ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, rotationalElements: undefined, rotationPeriodHours: 378.504 }; const ERIS: BodyRecord = { ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, rotationalElements: undefined, rotationPeriodHours: 378.504 };
const HYPERION: BodyRecord = { ...ERIS, id: 'hyperion', name: 'Hyperion', kind: 'moon', radiusKm: 135, parentBodyId: 'saturn', rotationPeriodHours: undefined }; const HYPERION: BodyRecord = { ...ERIS, id: 'hyperion', name: 'Hyperion', kind: 'moon', radiusKm: 135, parentBodyId: 'saturn', rotationPeriodHours: undefined };
const BODIES = [EARTH, SATURN, ERIS, HYPERION]; // Mercury as shipped, but its 0.01-degree libration: its Sun is never 0.034 degrees off its equator.
const MERCURY: BodyRecord = {
id: 'mercury', systemStarId: 0, name: 'Mercury', kind: 'planet', radiusKm: 2439.4, orbitSource: 'test',
orbit: {semiMajorAxisAu: 0.38709843, eccentricity: 0.20563661, inclinationDeg: 7.00559432, longitudeOfAscendingNodeDeg: 48.33961819, argumentOfPeriapsisDeg: 29.118100759999997, meanAnomalyAtEpochDeg: 174.79394829, epochJd: 2451545},
rates: {meanMotionDegPerDay: 4.092338805372484, longitudeOfAscendingNodeDegPerDay: -0.0000033440607802874744, argumentOfPeriapsisDegPerDay: 0.000007708198494182067},
rotationalElements: {poleRaDeg: [281.0103, -0.0328, 0], poleDecDeg: [61.4155, -0.0049, 0], primeMeridianDeg: [329.5988, 6.1385108, 0]}
};
const BODIES = [EARTH, SATURN, ERIS, HYPERION, MERCURY];
/** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */ /** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */
class FakeEngineService { class FakeEngineService {
@@ -223,6 +230,23 @@ describe('BodyDetailSceneComponent', () => {
expect(camera.getWorldDirection(new THREE.Vector3()).angleTo(toSaturn)).toBeLessThan(1e-9); expect(camera.getWorldDirection(new THREE.Vector3()).angleTo(toSaturn)).toBeLessThan(1e-9);
}); });
it('leaves the camera on its side while the Sun only grazes the equator: Mercury through two crossings', async () => {
// The Sun is south of Mercury's equator on 2026-10-20, north from about 1 November, and south
// again from about 6 December, never more than 0.034 degrees either side.
await open('mercury', '2026-10-20T00:00Z');
const camera = engine.getCamera();
expect(page.sunLight.position.y).toBeLessThan(0);
expect(camera.position.y).toBeLessThan(0);
const sunSides = new Set<number>();
for (let day = 1; day <= 60; day++) {
time.setDate(new Date(Date.parse('2026-10-20T00:00Z') + day * 86400000));
engine.tick(0.016);
sunSides.add(Math.sign(page.sunLight.position.y));
expect(camera.position.y).toBeLessThan(0);
}
expect([...sunSides].sort()).toEqual([-1, 1]);
});
it('leaves the camera where the reader orbits it while the Sun stays on one side', async () => { it('leaves the camera where the reader orbits it while the Sun stays on one side', async () => {
await open('saturn', '2032-06-01T12:00Z'); await open('saturn', '2032-06-01T12:00Z');
const camera = engine.getCamera(); const camera = engine.getCamera();
@@ -28,6 +28,16 @@ const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
const GLOW_SCALE = 2.6; const GLOW_SCALE = 2.6;
/** Where the page's light stands, and the Sun with it wherever the body's real one is known. */ /** Where the page's light stands, and the Sun with it wherever the body's real one is known. */
const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5); const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5);
/**
* How far from the equator the Sun must stand, as the sine of its latitude, before the camera
* follows it across: 3 degrees. The side is for Saturn's rings, lit on one face only, whose Sun
* goes 26.7 degrees either side. Mercury's never leaves the equator by more than 0.034 degrees
* and crosses it 8.3 times a year, which moved the camera from one side to the other every 1.45
* seconds at a month a second, both sides lit alike; Venus's reaches 2.6 and the Moon's 1.6.
* Earth's and Saturn's pages still follow their seasons, a week and half a year after each
* equinox (2025-03-28 and 2039-08-03, measured).
*/
const SUN_SIDE_MIN_SINE = Math.sin((3 * Math.PI) / 180);
/** /**
* Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera * Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera
@@ -302,13 +312,14 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
} else if (!this.body) { } else if (!this.body) {
this.planet.rotation.y += deltaSeconds * 0.08; this.planet.rotation.y += deltaSeconds * 0.08;
} }
const sunSide = this.sunLight.position.y < 0 ? -1 : 1; const sunLatitudeSine = this.sunLight.position.y / this.sunLight.position.length();
const sunSide = this.sunSide !== 0 && Math.abs(sunLatitudeSine) < SUN_SIDE_MIN_SINE ? this.sunSide : sunLatitudeSine < 0 ? -1 : 1;
if (sunSide !== this.sunSide) { if (sunSide !== this.sunSide) {
// Above or below the equator, whichever side the Sun is on, when a body is shown and again // Above or below the equator, whichever side the Sun is on, when a body is shown and again
// whenever the Sun crosses it, as the clock runs or is set: held above it, the page opened // whenever the Sun is well across it (SUN_SIDE_MIN_SINE), as the clock runs or is set: held
// Saturn on the unlit face of its rings from 2025 until 2039, while the Sun is south of them — // above it, the page opened Saturn on the unlit face of its rings from 2025 until 2039, while
// the face Earth does not see either — and the Clock set to 2045 left it on the other one. // the Sun is south of them — the face Earth does not see either — and the Clock set to 2045
// Between crossings the camera is the reader's to orbit where they like. // left it on the other one. Between crossings the camera is the reader's to orbit where they like.
this.sunSide = sunSide; this.sunSide = sunSide;
const camera = this.engine.getCamera(); const camera = this.engine.getCamera();
camera.position.y = Math.abs(camera.position.y) * sunSide; camera.position.y = Math.abs(camera.position.y) * sunSide;