Show each body on its page turned as it is at the map's date, under its real Sun
The body page used to spin every body about its pole at 0.08 radians a second, under a light at (4, 3, 5) whatever the date. A body with IAU elements is now drawn as it is at the map's clock, using the same pole, prime meridian and map convention as the system view. It is shown pole up under a Sun held at the light's old azimuth, so the camera still opens on the day side. The Sun's height above the equator is the real one, and so is the face it lights. The clock's rate now turns the page too: at 1 h/s Earth's sub-solar point moved 15.17 degrees in the 1.012 h of sky one wall second carried. What the page gives up is the stars, which do not turn with the body. bodyPageView in src/app/shared/rendering/body-orientation.ts takes the Sun's direction from where the body is: a planet's own mean elements, a moon's planet's place plus its own offset. It sets the sphere's rotation and the light's direction. Exoplanets, Eris, Haumea and Makemake keep the old slow turn and light. Saturn's rings now lie flat in its equator, the page's horizontal. They used to lean 17 degrees, which put them out of the plane they orbit in. Measured: - Live app, clock pinned to 2025-06-01 12:00 UTC: the Sun stands over 0.433 W, 22.125 N on Earth's page, the same point as on its sphere in the system view. - Unit test, raycast on the page's own sphere: Earth one light-time earlier is 0.09 degrees from Horizons' sub-solar longitude. Its latitude, put on the flattened Earth, is within 0.03. - The Moon's sub-solar point is within 0.004 of Horizons'. Three mutants each fail their named test: a moon lit as if it had no planet; the Sun not held at the page's azimuth; the body left in the ICRF instead of the page's frame. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|||||||
|
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { EngineService } from '../../core/engine/engine.service';
|
import { EngineService } from '../../core/engine/engine.service';
|
||||||
|
import { bodyPageView } from '../../shared/rendering/body-orientation';
|
||||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||||
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||||
@@ -14,6 +15,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
|||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { Bookmark } from '../../shared/state/bookmarks.store';
|
import { Bookmark } from '../../shared/state/bookmarks.store';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
|
import { TimeStore } from '../../shared/state/time.store';
|
||||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||||
import { HudDockComponent } from '../hud/hud-dock.component';
|
import { HudDockComponent } from '../hud/hud-dock.component';
|
||||||
import { BodyDetailViewModel } from './body-detail.model';
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
@@ -24,6 +26,8 @@ import { InfoPanelComponent } from './info-panel.component';
|
|||||||
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
||||||
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
||||||
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. */
|
||||||
|
const SUN_LIGHT_POSITION = new THREE.Vector3(4, 3, 5);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -81,6 +85,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private scene?: THREE.Scene;
|
private scene?: THREE.Scene;
|
||||||
private planet?: THREE.Mesh;
|
private planet?: THREE.Mesh;
|
||||||
private planetMaterial?: THREE.MeshStandardMaterial;
|
private planetMaterial?: THREE.MeshStandardMaterial;
|
||||||
|
private sunLight?: THREE.DirectionalLight;
|
||||||
|
/** The solar-system record behind the body shown, which is what can be turned by its real pole. */
|
||||||
|
private body?: BodyRecord;
|
||||||
private ring?: THREE.Mesh;
|
private ring?: THREE.Mesh;
|
||||||
private glow?: THREE.Sprite;
|
private glow?: THREE.Sprite;
|
||||||
private resizeObserver?: ResizeObserver;
|
private resizeObserver?: ResizeObserver;
|
||||||
@@ -100,7 +107,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private readonly dataLoader: DataLoaderService,
|
private readonly dataLoader: DataLoaderService,
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
private readonly router: Router,
|
private readonly router: Router,
|
||||||
private readonly navigationStore: NavigationStore
|
private readonly navigationStore: NavigationStore,
|
||||||
|
private readonly time: TimeStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
@@ -176,6 +184,10 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// A fluid envelope scatters light more evenly than a solid surface does.
|
// A fluid envelope scatters light more evenly than a solid surface does.
|
||||||
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
|
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
|
||||||
this.planetMaterial.needsUpdate = true;
|
this.planetMaterial.needsUpdate = true;
|
||||||
|
// Back to the page's own light and a sphere at rest; `tick` turns both where the IAU says how.
|
||||||
|
this.body = this.bodies.find((body) => body.id === viewModel.id);
|
||||||
|
this.planet?.rotation.set(0, 0, 0);
|
||||||
|
this.sunLight?.position.copy(SUN_LIGHT_POSITION);
|
||||||
|
|
||||||
this.disposeRing();
|
this.disposeRing();
|
||||||
this.disposeGlow();
|
this.disposeGlow();
|
||||||
@@ -219,7 +231,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ring = new THREE.Mesh(geometry, material);
|
const ring = new THREE.Mesh(geometry, material);
|
||||||
ring.rotation.x = Math.PI / 2 - THREE.MathUtils.degToRad(17);
|
// Flat in Saturn's equator, which is the page's horizontal: the planet is drawn pole up. The
|
||||||
|
// 17 degrees they used to lean put them out of the equator they orbit in.
|
||||||
|
ring.rotation.x = Math.PI / 2;
|
||||||
return ring;
|
return ring;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,9 +282,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.controls.maxDistance = 12;
|
this.controls.maxDistance = 12;
|
||||||
|
|
||||||
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||||||
const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
|
this.sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
|
||||||
sunLight.position.set(4, 3, 5);
|
this.sunLight.position.copy(SUN_LIGHT_POSITION);
|
||||||
scene.add(sunLight);
|
scene.add(this.sunLight);
|
||||||
|
|
||||||
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
||||||
const viewModel = this.viewModel();
|
const viewModel = this.viewModel();
|
||||||
@@ -289,9 +303,20 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.engine.start();
|
this.engine.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A body the IAU gives rotational elements for is turned as it is at the map's date, under its
|
||||||
|
* real Sun, at the rate the map's clock runs (see `bodyPageView`). Any other — an exoplanet, or
|
||||||
|
* Eris, Haumea or Makemake — turns slowly for show, as the page always turned them.
|
||||||
|
*/
|
||||||
private tick(deltaSeconds: number): void {
|
private tick(deltaSeconds: number): void {
|
||||||
this.controls?.update();
|
this.controls?.update();
|
||||||
if (this.planet) {
|
if (!this.planet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sunAzimuth = Math.atan2(SUN_LIGHT_POSITION.x, SUN_LIGHT_POSITION.z);
|
||||||
|
if (this.body && this.sunLight && bodyPageView(this.body, this.bodies, this.time.julianDate(), sunAzimuth, this.planet.quaternion, this.sunLight.position)) {
|
||||||
|
this.sunLight.position.multiplyScalar(SUN_LIGHT_POSITION.length());
|
||||||
|
} else {
|
||||||
this.planet.rotation.y += deltaSeconds * 0.08;
|
this.planet.rotation.y += deltaSeconds * 0.08;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BodyRecord } from '../models/body.model';
|
||||||
|
import { bodyPageView } from './body-orientation';
|
||||||
|
|
||||||
|
// Earth (the Earth-Moon barycentre's mean elements) and the Moon as bodies.json carries them.
|
||||||
|
const EARTH: BodyRecord = {
|
||||||
|
id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbitSource: 'test',
|
||||||
|
orbit: {semiMajorAxisAu: 1.00000018, eccentricity: 0.01673163, inclinationDeg: -0.00054346, longitudeOfAscendingNodeDeg: -5.11260389, argumentOfPeriapsisDeg: 108.04266274, meanAnomalyAtEpochDeg: -2.4631431299999917, epochJd: 2451545},
|
||||||
|
rates: {meanMotionDegPerDay: 0.9856091187759068, longitudeOfAscendingNodeDegPerDay: -0.000006604751813826146, argumentOfPeriapsisDegPerDay: 0.000015309819575633124, semiMajorAxisAuPerDay: -8.213552361396303e-13, eccentricityPerDay: -1.002327173169062e-9, inclinationDegPerDay: -3.6609938398357287e-7},
|
||||||
|
rotationalElements: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]}
|
||||||
|
};
|
||||||
|
const MOON: BodyRecord = {
|
||||||
|
id: 'moon', systemStarId: 0, name: 'Moon', kind: 'moon', radiusKm: 1737.4, orbitSource: 'test', parentBodyId: 'earth',
|
||||||
|
orbit: {semiMajorAxisAu: 0.0025695552897999907, eccentricity: 0.0554, inclinationDeg: 5.16, longitudeOfAscendingNodeDeg: 125.08, argumentOfPeriapsisDeg: 318.15, meanAnomalyAtEpochDeg: 135.27, epochJd: 2451545},
|
||||||
|
rates: {meanMotionDegPerDay: 13.176358, longitudeOfAscendingNodeDegPerDay: -0.052990660396105185, argumentOfPeriapsisDegPerDay: 0.164353223839846},
|
||||||
|
rotationalElements: {poleRaDeg: [269.9949, 0.0031, 0], poleDecDeg: [66.5392, 0.013, 0], primeMeridianDeg: [38.3213, 13.17635815, -1.4e-12], terms: [{angleDeg: [125.045, -1935.5364525], ra: -3.8787, dec: 1.5419, pm: 3.561}, {angleDeg: [250.089, -3871.072905], ra: -0.1204, dec: 0.0239, pm: 0.1208}, {angleDeg: [260.008, 475263.3328725], ra: 0.07, dec: -0.0278, pm: -0.0642}, {angleDeg: [176.625, 487269.629985], ra: -0.0172, dec: 0.0068, pm: 0.0158}, {angleDeg: [357.529, 35999.0509575], ra: 0, dec: 0, pm: 0.0252}]}
|
||||||
|
};
|
||||||
|
const BODIES = [EARTH, MOON];
|
||||||
|
const JUNE_1_2025_NOON_UTC = 2460828.0;
|
||||||
|
/** The page's light, at (4, 3, 5): 38.7 degrees round from the camera's side. */
|
||||||
|
const SUN_AZIMUTH = Math.atan2(4, 5);
|
||||||
|
|
||||||
|
/** The point of the page's sphere, as east longitude and latitude on its map, that faces the Sun. */
|
||||||
|
function subSolarPoint(body: BodyRecord, jdUtc: number): { eastDeg: number; latDeg: number } {
|
||||||
|
const sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32));
|
||||||
|
const sun = new THREE.Vector3();
|
||||||
|
expect(bodyPageView(body, BODIES, jdUtc, SUN_AZIMUTH, sphere.quaternion, sun)).toBe(true);
|
||||||
|
sphere.updateMatrixWorld();
|
||||||
|
const hit = new THREE.Raycaster(sun.clone().multiplyScalar(4), sun.clone().negate()).intersectObject(sphere)[0];
|
||||||
|
return { eastDeg: (hit.uv!.x - 0.5) * 360, latDeg: (hit.uv!.y - 0.5) * 180 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bodyPageView', () => {
|
||||||
|
it('lights the same face of Earth on its page: within 4 degrees of Greenwich at noon UTC, and where Horizons has it', () => {
|
||||||
|
expect(Math.abs(subSolarPoint(EARTH, JUNE_1_2025_NOON_UTC).eastDeg)).toBeLessThan(4);
|
||||||
|
// Horizons' sub-solar point from the Sun, 1.5795 E and 22.2604 N, is Earth as it was 8.43
|
||||||
|
// minutes before, when the light arriving then left the Sun; its latitude is geodetic, on the
|
||||||
|
// flattened Earth, where the sphere's is geocentric: 0.14 degrees apart at this latitude.
|
||||||
|
const horizons = subSolarPoint(EARTH, JUNE_1_2025_NOON_UTC - 8.43351424 / 1440);
|
||||||
|
const geodetic = (Math.atan(Math.tan((horizons.latDeg * Math.PI) / 180) / (1 - 1 / 298.257) ** 2) * 180) / Math.PI;
|
||||||
|
expect(Math.abs(horizons.eastDeg - 1.579501)).toBeLessThan(0.1);
|
||||||
|
expect(Math.abs(geodetic - 22.260426)).toBeLessThan(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a moon’s Sun from where it and its planet are: the Moon’s sub-solar point is Horizons’', () => {
|
||||||
|
const moon = subSolarPoint(MOON, JUNE_1_2025_NOON_UTC);
|
||||||
|
// 116.2859 E and 1.5030 N, seen from Earth's centre.
|
||||||
|
expect(Math.abs(moon.eastDeg - 116.285934)).toBeLessThan(0.1);
|
||||||
|
expect(Math.abs(moon.latDeg - 1.503004)).toBeLessThan(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the pole up and the Sun where the page’s light stands, turning the body under it', () => {
|
||||||
|
const planet = new THREE.Quaternion();
|
||||||
|
const sun = new THREE.Vector3();
|
||||||
|
for (const hours of [0, 6, 12]) {
|
||||||
|
bodyPageView(EARTH, BODIES, JUNE_1_2025_NOON_UTC + hours / 24, SUN_AZIMUTH, planet, sun);
|
||||||
|
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(planet).angleTo(new THREE.Vector3(0, 1, 0))).toBeLessThan(1e-9);
|
||||||
|
expect(Math.atan2(sun.x, sun.z)).toBeCloseTo(SUN_AZIMUTH, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a body with no elements to the page, as it was', () => {
|
||||||
|
const planet = new THREE.Quaternion(0.1, 0.2, 0.3, 0.9).normalize();
|
||||||
|
const before = planet.clone();
|
||||||
|
const sun = new THREE.Vector3(4, 3, 5);
|
||||||
|
|
||||||
|
expect(bodyPageView({ ...EARTH, rotationalElements: undefined }, BODIES, JUNE_1_2025_NOON_UTC, SUN_AZIMUTH, planet, sun)).toBe(false);
|
||||||
|
expect(planet.equals(before)).toBe(true);
|
||||||
|
expect(sun.toArray()).toEqual([4, 3, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
import { TT_MINUS_UTC_DAYS } from '../astro/constants';
|
import { TT_MINUS_UTC_DAYS } from '../astro/constants';
|
||||||
import { laplacePlaneToEquatorial } from '../astro/coordinates';
|
import { CartesianCoordinates, eclipticToEquatorial, laplacePlaneToEquatorial } from '../astro/coordinates';
|
||||||
|
import { meanElementsAt, positionAtEpoch } from '../astro/kepler';
|
||||||
import { orientationAt } from '../astro/rotational-elements';
|
import { orientationAt } from '../astro/rotational-elements';
|
||||||
import { RotationalElements } from '../models/body.model';
|
import { BodyRecord, RotationalElements } from '../models/body.model';
|
||||||
|
|
||||||
const DEG_TO_RAD = Math.PI / 180;
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
const Y_AXIS = new THREE.Vector3(0, 1, 0);
|
||||||
const Z_AXIS = new THREE.Vector3(0, 0, 1);
|
const Z_AXIS = new THREE.Vector3(0, 0, 1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,3 +60,45 @@ export function bodyOrientation(elements: RotationalElements, jdUtc: number, tar
|
|||||||
.multiply(MAP_TO_BODY);
|
.multiply(MAP_TO_BODY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a body is from the Sun at a date, in the ICRF, AU: a moon's planet's place plus its own. */
|
||||||
|
function heliocentricPosition(body: BodyRecord, bodies: readonly BodyRecord[], jdUtc: number): CartesianCoordinates {
|
||||||
|
const own = positionAtEpoch(meanElementsAt(body.orbit, body.rates, jdUtc));
|
||||||
|
const parent = body.parentBodyId ? bodies.find((candidate) => candidate.id === body.parentBodyId) : undefined;
|
||||||
|
if (!parent) {
|
||||||
|
return eclipticToEquatorial(own);
|
||||||
|
}
|
||||||
|
const offset = body.laplacePole ? laplacePlaneToEquatorial(own, body.laplacePole) : eclipticToEquatorial(own);
|
||||||
|
const centre = eclipticToEquatorial(positionAtEpoch(meanElementsAt(parent.orbit, parent.rates, jdUtc)));
|
||||||
|
return { x: centre.x + offset.x, y: centre.y + offset.y, z: centre.z + offset.z };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scratchPage = new THREE.Quaternion();
|
||||||
|
const scratchPageTurn = new THREE.Quaternion();
|
||||||
|
const scratchBody = new THREE.Quaternion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the body page shows a body the IAU gives elements for: pole up, as the page has always
|
||||||
|
* drawn it, turned as it really is at the map's date against a Sun held at `sunAzimuthRad` round
|
||||||
|
* that pole — where the page's light has always stood, so the camera still opens on the day side.
|
||||||
|
* The Sun's height above the equator is its real one, and the face it lights is the real one:
|
||||||
|
* seen from the body, the Sun sits over the same point of its map as in the system view. What the
|
||||||
|
* page gives up is the stars, which do not turn with the body.
|
||||||
|
*
|
||||||
|
* Sets `planet` to the sphere's rotation and `sun` to the unit direction of the Sun in the page's
|
||||||
|
* frame. Returns false, touching neither, for a body without elements.
|
||||||
|
*/
|
||||||
|
export function bodyPageView(body: BodyRecord, bodies: readonly BodyRecord[], jdUtc: number, sunAzimuthRad: number, planet: THREE.Quaternion, sun: THREE.Vector3): boolean {
|
||||||
|
const elements = body.rotationalElements;
|
||||||
|
if (!elements) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { poleRaDeg, poleDecDeg } = orientationAt(elements, jdUtc + TT_MINUS_UTC_DAYS);
|
||||||
|
// From the ICRF into the body's frame with its pole on +Y, before the turn about that pole.
|
||||||
|
const toPage = poleFrame({ raDeg: poleRaDeg, decDeg: poleDecDeg }, scratchPage).multiply(MAP_TO_BODY).invert();
|
||||||
|
const position = heliocentricPosition(body, bodies, jdUtc);
|
||||||
|
sun.set(-position.x, -position.y, -position.z).normalize().applyQuaternion(toPage);
|
||||||
|
const turn = scratchPageTurn.setFromAxisAngle(Y_AXIS, sunAzimuthRad - Math.atan2(sun.x, sun.z));
|
||||||
|
sun.applyQuaternion(turn);
|
||||||
|
planet.copy(turn).multiply(toPage).multiply(bodyOrientation(elements, jdUtc, scratchBody));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user