diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 9252089..6e61774 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -6,6 +6,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService } from '../../core/engine/engine.service'; +import { bodyPageView } from '../../shared/rendering/body-orientation'; import { planetTexture } from '../../shared/rendering/procedural-planet-texture'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; 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 { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore } from '../../shared/state/navigation.store'; +import { TimeStore } from '../../shared/state/time.store'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { HudDockComponent } from '../hud/hud-dock.component'; 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']); /** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */ 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 @@ -81,6 +85,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { private scene?: THREE.Scene; private planet?: THREE.Mesh; 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 glow?: THREE.Sprite; private resizeObserver?: ResizeObserver; @@ -100,7 +107,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { private readonly dataLoader: DataLoaderService, private readonly route: ActivatedRoute, private readonly router: Router, - private readonly navigationStore: NavigationStore + private readonly navigationStore: NavigationStore, + private readonly time: TimeStore ) {} ngAfterViewInit(): void { @@ -176,6 +184,10 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { // 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.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.disposeGlow(); @@ -219,7 +231,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { }); 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; } @@ -268,9 +282,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { this.controls.maxDistance = 12; scene.add(new THREE.AmbientLight(0xffffff, 0.35)); - const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6); - sunLight.position.set(4, 3, 5); - scene.add(sunLight); + this.sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6); + this.sunLight.position.copy(SUN_LIGHT_POSITION); + scene.add(this.sunLight); const geometry = new THREE.SphereGeometry(1, 64, 48); const viewModel = this.viewModel(); @@ -289,9 +303,20 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { 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 { 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; } } diff --git a/src/app/shared/rendering/body-orientation.spec.ts b/src/app/shared/rendering/body-orientation.spec.ts new file mode 100644 index 0000000..6421380 --- /dev/null +++ b/src/app/shared/rendering/body-orientation.spec.ts @@ -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]); + }); +}); diff --git a/src/app/shared/rendering/body-orientation.ts b/src/app/shared/rendering/body-orientation.ts index 763460f..a0ea9d9 100644 --- a/src/app/shared/rendering/body-orientation.ts +++ b/src/app/shared/rendering/body-orientation.ts @@ -1,11 +1,13 @@ import * as THREE from 'three/webgpu'; 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 { RotationalElements } from '../models/body.model'; +import { BodyRecord, RotationalElements } from '../models/body.model'; 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); /** @@ -58,3 +60,45 @@ export function bodyOrientation(elements: RotationalElements, jdUtc: number, tar .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; +}