diff --git a/README.md b/README.md index 74cd2d0..055dcf0 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,19 @@ in it is measured and what is not. ![The solar system: orbit ellipses over a dashed reference grid marking 5 AU rings out to 35 AU](docs/screenshots/system-view.jpg) **System view** — selecting a star flies the camera continuously into its system rather than -cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other +cutting to a new scene. The Sun gets the real solar-system bodies, moving on JPL's mean orbital +elements — Standish's for the planets, JPL SSD's satellite table for the moons, the Small-Body +Database for Ceres, Eris, Haumea and Makemake — and turned by the IAU's rotational elements +(Eris, Haumea, Makemake and Nereid, which have none, at their measured days about their orbit +normals, and Hyperion, which tumbles, not at all), Earth by the IERS Earth Rotation Angle; a +tidally locked moon's prime meridian turns at its JPL mean motion, and its pole's terms that turn +within 5 per cent of a multiple of its node's rate at that multiple of its JPL node rate, both +re-phased to the IAU's values on 2025-01-01 (the Moon's and Phobos's are left as the IAU has them, +and so are the circles Ariel's, Umbriel's, Titania's and Oberon's poles go round on, at rates none +of their nodes has), and Iapetus's pole follows its orbit normal +(`lockedToOrbit`), so each keeps its face to its planet from AD 1 to 3000; other stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated -along them by a Kepler solver against the current epoch. Under them, a dashed grid marks out +along them by a Kepler solver to the date on the map's clock. Under them, a dashed grid marks out round distances in AU — 5 AU rings for the solar system, 0.01 AU rings for TRAPPIST-1 — with a drop line from each body, so eccentricity and inclination read against a circular reference instead of having to be inferred from a shape in space. The camera frames that grid rather than @@ -113,7 +123,8 @@ its own readout, so a stale image is visible as one. both backends. Their size is angular rather than world-space — real stars are unresolvable point sources, so apparent size should follow brightness, not distance. - **One reference frame, from three sources.** HYG gives star positions in equatorial J2000. - JPL Horizons reports orbital elements against the ecliptic, tilted 23.4° away. The Exoplanet + JPL gives the planets' orbital elements against the ecliptic, tilted 23.4° away, and the moons' + against the ecliptic (the Moon), a Laplace plane, or their planet's equator (Uranus's and Pluto's). The Exoplanet Archive measures inclination from the *plane of the sky* — perpendicular to our line of sight to each host star, which is why transiting planets cluster at 90°. Each set of elements is rotated from its own reference plane into the scene's equatorial frame, so a direction means @@ -138,9 +149,11 @@ its own readout, so a stale image is visible as one. ### On surfaces that were never photographed -Fifteen bodies here have a real photograph. Everything else does not, and never will on current -instruments: no exoplanet's surface has ever been imaged, and a few of the solar system's own -moons have no usable map in this asset set either. +Twenty-eight bodies here are wrapped in real photography: the Sun, the eight planets and the Moon, +and eighteen moons and dwarf planets in mission mosaics, grey where no probe has seen them +(`src/assets/textures/README.md`). Everything else is not, and no exoplanet ever will be on +current instruments: none has had its surface imaged. The five large moons of Uranus and a few +small bodies have no map in this asset set either. Those bodies get a surface reasoned from what *has* been measured, in a chain that is worth following because every link is standard: @@ -204,7 +217,7 @@ re-runs are cheap and offline-friendly; set `ETL_FORCE_REFRESH=1` to bypass the | Script | Source | Output | | --- | --- | --- | | `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | the catalogue stars, handed to `fetchExoplanets.ts` | -| `fetchSolarSystem.ts` | JPL Horizons / SSD | `bodies.json` | +| `fetchSolarSystem.ts` | JPL SSD mean elements (Standish's planets, the satellite table), the Small-Body Database, NAIF's PCK, JPL Horizons | `bodies.json` | | `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP), and the stars above with the hosts it adds | `exoplanets.json`, `stars.bin`, `stars-meta.bin`, `stars-index.json` | | `fetchDeepSky.ts` | OpenNGC | `deepsky.json` | @@ -330,7 +343,7 @@ plugin's own files are kept so it can be listed from a marketplace of its own la ## Data credits Star catalogue: [HYG database](https://github.com/astronexus/HYG-Database) (Hipparcos, Yale -Bright Star, Gliese) — 68 388 stars within 250 pc. Solar-system ephemerides: NASA/JPL Horizons. Exoplanets: NASA Exoplanet +Bright Star, Gliese) — 68 388 stars within 250 pc. Solar-system orbits: JPL approximate planetary mean elements (Standish), JPL SSD satellite mean elements and the JPL Small-Body Database; rotation: the IAU WGCCRE 2015 report via NAIF's pck00011, with a locked moon's W and its pole's terms within 5 per cent of its node's rate re-rated to its JPL mean elements (but the Moon's and Phobos's) and Iapetus's pole carried round its orbit normal, and for Earth the IERS Conventions 2010; physical data, and the positions the orbits are checked against: NASA/JPL Horizons. Exoplanets: NASA Exoplanet Archive. Deep-sky objects: [OpenNGC](https://github.com/mattiaverga/OpenNGC). Body and skybox imagery: NASA/JPL/USGS public domain and Solar System Scope (CC BY 4.0) — per-file provenance -is recorded in `src/app/shared/rendering/texture-catalog.ts`. +is recorded in `src/assets/textures/README.md`. diff --git a/src/app/features/body-detail/body-detail-scene.component.spec.ts b/src/app/features/body-detail/body-detail-scene.component.spec.ts new file mode 100644 index 0000000..00f1f6e --- /dev/null +++ b/src/app/features/body-detail/body-detail-scene.component.spec.ts @@ -0,0 +1,285 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import * as THREE from 'three/webgpu'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DataLoaderService, StarField } from '../../core/data/data-loader.service'; +import { EngineService, EngineTickCallback } from '../../core/engine/engine.service'; +import { BodyRecord } from '../../shared/models/body.model'; +import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; +import { StarRecord } from '../../shared/models/star.model'; +import { bodyPageView } from '../../shared/rendering/body-orientation'; +import { TimeStore } from '../../shared/state/time.store'; +import { BodyDetailSceneComponent } from './body-detail-scene.component'; + +// jsdom has no ResizeObserver; the page only uses it to follow real layout changes. +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class { + observe(): void {} + disconnect(): void {} +}; + +const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 }; + +// Earth and Saturn as bodies.json carries them: Standish's elements and the IAU's. +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}, + rotationalElements: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]} +}; +const SATURN: BodyRecord = { + id: 'saturn', systemStarId: 0, name: 'Saturn', kind: 'planet', radiusKm: 58232, orbitSource: 'test', + orbit: {semiMajorAxisAu: 9.54149883, eccentricity: 0.05550825, inclinationDeg: 2.49424102, longitudeOfAscendingNodeDeg: 113.63998702, argumentOfPeriapsisDeg: -20.778626390000014, meanAnomalyAtEpochDeg: -42.78564733999999, epochJd: 2451545}, + rates: {meanMotionDegPerDay: 0.033459683702669406, longitudeOfAscendingNodeDegPerDay: -0.000006848734291581108, argumentOfPeriapsisDegPerDay: 0.000021682266940451745}, + rotationalElements: {poleRaDeg: [40.589, -0.036, 0], poleDecDeg: [83.537, -0.004, 0], primeMeridianDeg: [38.9, 810.7939024, 0]} +}; +// Eris and Hyperion as they are shipped for this page's purposes: no IAU model, so their pages keep +// 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 HYPERION: BodyRecord = { ...ERIS, id: 'hyperion', name: 'Hyperion', kind: 'moon', radiusKm: 135, parentBodyId: 'saturn', rotationPeriodHours: undefined }; +// 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]; +// An exoplanet round the Sun's record, which is all the page needs of its host. +const EXOPLANET: ExoplanetRecord = { id: 'x b', hostStarId: 0, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } }; + +/** Stands in for the WebGPU engine: a scene, a camera, and the tick hook, driven by hand. */ +class FakeEngineService { + private readonly scene = new THREE.Scene(); + private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100); + private readonly callbacks = new Set(); + + async init(): Promise {} + getScene(): THREE.Scene { + return this.scene; + } + getCamera(): THREE.PerspectiveCamera { + return this.camera; + } + onTick(callback: EngineTickCallback): () => void { + this.callbacks.add(callback); + return () => this.callbacks.delete(callback); + } + start(): void {} + resize(): void {} + dispose(): void {} + tick(deltaSeconds: number): void { + for (const callback of this.callbacks) { + callback(deltaSeconds, 0); + } + } +} + +class FakeDataLoaderService { + loadStars(): Promise { + return Promise.resolve({ stars: [SUN], positions: new Float32Array([0, 0, 0]) }); + } + loadBodies(): Promise { + return Promise.resolve(BODIES); + } + loadExoplanets(): Promise { + return Promise.resolve([EXOPLANET]); + } +} + +async function flushAsync(turns = 8): Promise { + for (let i = 0; i < turns; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe('BodyDetailSceneComponent', () => { + let engine: FakeEngineService; + let page: { planet: THREE.Mesh; ring?: THREE.Mesh; sunLight: THREE.DirectionalLight }; + let time: TimeStore; + let route: BehaviorSubject>; + + let fixture: ComponentFixture; + + /** Opens a body's page at a date; `whole` keeps the page's own template, dock and panel included. */ + async function open(id: string, date = '2025-06-01T12:00Z', whole = false): Promise { + engine = new FakeEngineService(); + route = new BehaviorSubject(convertToParamMap({ id })); + TestBed.configureTestingModule({ + imports: [BodyDetailSceneComponent], + providers: [ + { provide: DataLoaderService, useClass: FakeDataLoaderService }, + { provide: ActivatedRoute, useValue: { paramMap: route } }, + { provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } } + ] + }).overrideComponent(BodyDetailSceneComponent, { + // The scene alone, unless asked: the info panel and the dock are tested on their own. + set: whole ? { providers: [{ provide: EngineService, useValue: engine }] } : { providers: [{ provide: EngineService, useValue: engine }], imports: [], template: '' } + }); + time = TestBed.inject(TimeStore); + time.setRate(0); + time.setDate(new Date(date)); + fixture = TestBed.createComponent(BodyDetailSceneComponent); + fixture.detectChanges(); + await flushAsync(); + page = fixture.componentInstance as unknown as typeof page; + engine.tick(0.016); + } + + beforeEach(() => TestBed.resetTestingModule()); + + it('lays Saturn’s rings in its equator on the page, where audit #47 found them 17 degrees off it', async () => { + await open('saturn'); + const ring = page.ring!; + ring.updateWorldMatrix(true, false); + const normal = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld); + const pole = new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion); + expect(normal.angleTo(pole)).toBeLessThan(1e-6); + }); + + it('turns Earth on its page as it stands at the map’s date, under its real Sun', async () => { + await open('earth'); + const planet = new THREE.Quaternion(); + const sun = new THREE.Vector3(); + expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, sun)).toBe(true); + expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9); + expect(page.sunLight.position.clone().normalize().angleTo(sun)).toBeLessThan(1e-9); + }); + + it('follows the clock once the page is open, as it runs or is set', async () => { + await open('earth'); + time.setDate(new Date('2025-06-01T18:00Z')); + engine.tick(0.016); + const planet = new THREE.Quaternion(); + expect(bodyPageView(EARTH, BODIES, time.julianDate(), Math.atan2(4, 5), planet, new THREE.Vector3())).toBe(true); + // Six hours on, a quarter turn of Earth: a page frozen at its first frame is 90 degrees out. + expect(page.planet.quaternion.angleTo(planet)).toBeLessThan(1e-9); + }); + + it('puts the page’s own light back when the next body shown has no IAU model to place its Sun', async () => { + await open('earth'); + expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeGreaterThan(0.1); + route.next(convertToParamMap({ id: 'eris' })); + await flushAsync(); + engine.tick(0.016); + expect(page.sunLight.position.distanceTo(new THREE.Vector3(4, 3, 5))).toBeLessThan(1e-9); + }); + + it('puts the sphere back at rest when the next body shown does not turn: Hyperion after Earth', async () => { + await open('earth'); + expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeGreaterThan(0.1); + route.next(convertToParamMap({ id: 'hyperion' })); + await flushAsync(); + engine.tick(0.016); + engine.tick(0.016); + expect(page.planet.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-9); + }); + + it('turns a body whose day is measured but not its pole at that day on the map’s clock: Eris a sixth of a turn in 63.084 hours', async () => { + await open('eris'); + const start = page.planet.rotation.y; + // The clock stands (the page is opened at rate 0): so does Eris, where it used to turn for show. + engine.tick(1); + expect(page.planet.rotation.y).toBe(start); + time.setDate(new Date(Date.parse('2025-06-01T12:00Z') + (378.504 / 6) * 3600000)); + engine.tick(0.016); + const turned = (((page.planet.rotation.y - start) / (2 * Math.PI)) % 1 + 1) % 1; + expect(turned).toBeCloseTo(1 / 6, 6); + // Pole up, as the system view turns it about its orbit's normal. + expect(new THREE.Vector3(0, 1, 0).applyQuaternion(page.planet.quaternion).y).toBeCloseTo(1, 12); + }); + + it('turns an exoplanet slowly for show, clock or no clock: the catalogue carries no day for it', async () => { + await open('x b'); + const start = page.planet.rotation.y; + // The clock stands; a second of the page's own time is 0.08 radians. + engine.tick(1); + expect(page.planet.rotation.y - start).toBeCloseTo(0.08, 12); + }); + + it('says on its dock the date the body is drawn for, and nothing at the present, and offers the clock', async () => { + await open('saturn', '2032-06-01T12:00Z', true); + fixture.detectChanges(); + const host = fixture.nativeElement as HTMLElement; + expect(host.querySelector('[data-testid="hud-date"]')?.textContent).toContain('2032-06-01'); + expect([...host.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim())).toContain('Clock'); + time.reset(); + engine.tick(0.016); + fixture.detectChanges(); + expect(host.querySelector('[data-testid="hud-date"]')).toBeNull(); + }); + + it('opens Saturn on the face of its rings the Sun lights: the south, from 2025 to 2039', async () => { + await open('saturn', '2032-06-01T12:00Z'); + const camera = engine.getCamera(); + // The Sun 26.7 degrees south of the rings, and the camera with it rather than 11 degrees north. + expect(page.sunLight.position.y).toBeLessThan(0); + expect(camera.position.y).toBeLessThan(0); + + route.next(convertToParamMap({ id: 'earth' })); + await flushAsync(); + engine.tick(0.016); + // June: Earth's Sun is in the north, and so is the camera again. + expect(page.sunLight.position.y).toBeGreaterThan(0); + expect(camera.position.y).toBeGreaterThan(0); + }); + + it('follows the Sun across Saturn’s equator when the clock is set past the 2039 equinox, and aims at Saturn in that same frame', async () => { + await open('saturn', '2032-06-01T12:00Z'); + const camera = engine.getCamera(); + expect(camera.position.y).toBeLessThan(0); + // What the page's own Clock tab does: 2045, the Sun 25.7 degrees north of the rings. + time.setDate(new Date('2045-06-01T12:00Z')); + engine.tick(0.016); + expect(page.sunLight.position.y).toBeGreaterThan(0); + expect(camera.position.y).toBeGreaterThan(0); + // The frame drawn straight after the move: aimed from where the camera was, it had Saturn 22.6 + // degrees off the middle of the view. + const toSaturn = new THREE.Vector3().sub(camera.position); + expect(camera.getWorldDirection(new THREE.Vector3()).angleTo(toSaturn)).toBeLessThan(1e-9); + }); + + it('opens the next body shown on its own Sun’s side, wherever the reader left the camera: Earth after Saturn in December', async () => { + await open('saturn', '2032-12-01T12:00Z'); + const camera = engine.getCamera(); + expect(camera.position.y).toBeLessThan(0); + // Taken north by the reader, over Saturn's unlit ring face. + camera.position.y = 0.6; + engine.tick(0.016); + expect(camera.position.y).toBeGreaterThan(0); + + route.next(convertToParamMap({ id: 'earth' })); + await flushAsync(); + engine.tick(0.016); + // December: Earth's Sun is south, as Saturn's was, so only the side chosen afresh moves the camera. + expect(page.sunLight.position.y).toBeLessThan(0); + expect(camera.position.y).toBeLessThan(0); + }); + + 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(); + 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 () => { + await open('saturn', '2032-06-01T12:00Z'); + const camera = engine.getCamera(); + // Taken over the rings, to their unlit face, on purpose. + camera.position.y = 0.6; + engine.tick(0.016); + expect(camera.position.y).toBeGreaterThan(0); + }); +}); 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..8d7aadd 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -6,14 +6,16 @@ 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'; +import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, saturnRing } from '../../shared/rendering/texture-catalog'; import { BodyRecord } from '../../shared/models/body.model'; 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,18 @@ 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); +/** + * 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 @@ -58,9 +72,12 @@ const GLOW_SCALE = 2.6; } - - + + ` }) @@ -81,6 +98,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; @@ -94,13 +114,18 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { readonly viewModel = signal(undefined); readonly notFound = signal(false); + /** The date the body is drawn for, as the dock's strip prints it; empty at the present. */ + readonly date = signal(''); + /** The side of the equator the Sun stood on at the last frame, 1 north or -1 south; 0 once a body is shown. */ + private sunSide = 0; constructor( private readonly engine: EngineService, 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 { @@ -167,8 +192,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { } // Real photography wherever it exists, and a surface derived from the body's own measured - // properties wherever it does not — which is every exoplanet, since none has ever been - // imaged, and the handful of moons no probe returned a usable map of. + // properties wherever it does not — which is every exoplanet, since none has had its + // surface imaged, and the handful of moons no probe returned a usable map of. const realTexturePath = bodyTexturePath(viewModel.id); this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance); // The texture supplies its own colour, so the base stays white rather than tinting it twice. @@ -176,12 +201,19 @@ 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.sunSide = 0; this.disposeRing(); this.disposeGlow(); if (this.scene) { - if (viewModel.id === 'saturn') { - this.ring = this.buildSaturnRing(); + if (viewModel.id === 'saturn' && this.body) { + // Flat in the page's horizontal, which is Saturn's equator: the planet is drawn pole up, at + // unit radius. They used to reach 2.6 radii out; the outermost ring the texture draws is 2.42. + this.ring = saturnRing(this.body.radiusKm, 1); this.scene.add(this.ring); } const atmosphereColor = atmosphereColorFor(viewModel.id); @@ -192,37 +224,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { } } - /** - * Saturn's rings, built from a real ring-transparency map. `RingGeometry`'s default UVs wrap - * around the angle rather than the radius, so the per-vertex U is remapped to distance from - * center — the standard fix for sampling a radially-varying ring texture correctly. - */ - private buildSaturnRing(): THREE.Mesh { - const geometry = new THREE.RingGeometry(1.4, 2.6, 128, 1); - const position = geometry.attributes['position']; - const uv = geometry.attributes['uv']; - const vertex = new THREE.Vector3(); - for (let i = 0; i < position.count; i++) { - vertex.fromBufferAttribute(position, i); - const radialFraction = THREE.MathUtils.clamp((vertex.length() - 1.4) / (2.6 - 1.4), 0, 1); - uv.setXY(i, radialFraction, 1); - } - - const ringTexture = loadCachedTexture(SATURN_RING_TEXTURE_PATH); - const material = new THREE.MeshBasicMaterial({ - map: ringTexture, - alphaMap: ringTexture, - transparent: true, - opacity: 0.85, - side: THREE.DoubleSide, - depthWrite: false - }); - - const ring = new THREE.Mesh(geometry, material); - ring.rotation.x = Math.PI / 2 - THREE.MathUtils.degToRad(17); - return ring; - } - private disposeRing(): void { if (!this.ring) { return; @@ -268,9 +269,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,11 +290,46 @@ 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`). Eris, Haumea, Makemake and + * Nereid, whose day is measured but whose pole is not, turn pole up at that day on the same + * clock, as the system view turns them; Hyperion, which tumbles, is left still, as it is there. + * An exoplanet turns slowly for show, as the page always turned it. + */ private tick(deltaSeconds: number): void { this.controls?.update(); - if (this.planet) { + this.date.set(this.time.atNow() ? '' : this.time.date().toISOString().slice(0, 10)); + if (!this.planet || !this.sunLight) { + return; + } + const sunAzimuth = Math.atan2(SUN_LIGHT_POSITION.x, SUN_LIGHT_POSITION.z); + if (this.body && bodyPageView(this.body, this.bodies, this.time.julianDate(), sunAzimuth, this.planet.quaternion, this.sunLight.position)) { + this.sunLight.position.multiplyScalar(SUN_LIGHT_POSITION.length()); + } else if (this.body?.rotationPeriodHours !== undefined) { + // Counted from the orbit's epoch, as `spinFor` counts: where the meridian starts is unknown. + const turns = ((this.time.julianDate() - this.body.orbit.epochJd) * 24) / this.body.rotationPeriodHours; + this.planet.rotation.set(0, (turns % 1) * 2 * Math.PI, 0); + } else if (!this.body) { this.planet.rotation.y += deltaSeconds * 0.08; } + 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) { + // Above or below the equator, whichever side the Sun is on, when a body is shown and again + // whenever the Sun is well across it (SUN_SIDE_MIN_SINE), as the clock runs or is set: held + // above it, the page opened Saturn on the unlit face of its rings from 2025 until 2039, while + // the Sun is south of them — the face Earth does not see either — and the Clock set to 2045 + // left it on the other one. Between crossings the camera is the reader's to orbit where they like. + this.sunSide = sunSide; + const camera = this.engine.getCamera(); + camera.position.y = Math.abs(camera.position.y) * sunSide; + // Aimed again before this frame is drawn: the controls aimed it from where it was, and the + // frame drawn from here otherwise had the body 22.6 degrees off the middle of the view. + if (this.controls) { + camera.lookAt(this.controls.target); + } + } } private observeResize(canvas: HTMLCanvasElement): void { diff --git a/src/app/features/body-detail/body-detail.model.ts b/src/app/features/body-detail/body-detail.model.ts index 65d9462..925c59e 100644 --- a/src/app/features/body-detail/body-detail.model.ts +++ b/src/app/features/body-detail/body-detail.model.ts @@ -20,6 +20,8 @@ export interface BodyDetailViewModel { */ hostStarId?: number; radiusKm?: number; + /** A triaxial body's semi-axes, where `radiusKm` is the mean of them; see `BodyRecord.semiAxesKm`. */ + semiAxesKm?: readonly [number, number, number]; massEarth?: number; discoveryYear?: number; orbit: Partial; @@ -32,10 +34,11 @@ export interface BodyDetailViewModel { appearance: PlanetAppearance; /** True when a real photograph is being shown rather than the derived surface. */ hasPhotography: boolean; + /** An exoplanet photographed by direct imaging, as a point of light; see `ExoplanetRecord.imaged`. */ + imaged?: boolean; /** - * Sidereal orbital period. Measured where the archive published one; otherwise derived from the - * semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined - * when neither applies — see `heliocentricPeriodDays`. + * Sidereal orbital period. For a solar-system body, 360 degrees over JPL's published mean + * motion; for an exoplanet, the archive's period where it published one, and undefined where not. */ orbitalPeriodDays?: number; /** @@ -44,4 +47,6 @@ export interface BodyDetailViewModel { * derived surface as a photograph. */ orbitalPeriodSource?: 'measured' | 'derived'; + /** Where the orbit comes from and the span it holds over; see `BodyRecord.orbitSource`. */ + orbitSource?: string; } diff --git a/src/app/features/body-detail/body-readouts.ts b/src/app/features/body-detail/body-readouts.ts index ef09264..f9565dd 100644 --- a/src/app/features/body-detail/body-readouts.ts +++ b/src/app/features/body-detail/body-readouts.ts @@ -31,7 +31,11 @@ export interface BodyReadouts { export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { const measured: Readout[] = []; if (body.radiusKm !== undefined) { - measured.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) }); + // A triaxial body is drawn as the sphere of its volume; a radius alone would hide its shape. + measured.push({ label: body.semiAxesKm ? 'Mean radius' : 'Radius', value: formatRadiusKm(body.radiusKm) }); + } + if (body.semiAxesKm) { + measured.push({ label: 'Semi-axes', value: `${body.semiAxesKm.map((axis) => axis.toLocaleString('en-GB')).join(' × ')} km` }); } if (body.massEarth !== undefined) { measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) }); @@ -43,7 +47,9 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) }); } if (body.orbit.inclinationDeg !== undefined) { - measured.push({ label: 'Inclination', value: `${body.orbit.inclinationDeg.toFixed(2)}°` }); + // Its size: Standish fits Earth's as -0.00054 degrees, which is the same orbit as +0.00054 with + // the node half a turn round, and printed as it stands read "-0.00°". + measured.push({ label: 'Inclination', value: `${Math.abs(body.orbit.inclinationDeg).toFixed(2)}°` }); } // The period sits under whichever heading its provenance calls for. Same number, same field — // a published period is an observation and a computed one is not. @@ -65,7 +71,8 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) }); } - return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) }; + const provenance = body.orbitSource ? `${provenanceFor(body)} Orbit: ${body.orbitSource}.` : provenanceFor(body); + return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance }; } /** @@ -77,12 +84,26 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { * 2 714 planets it was printed on. Of the rest, 2 420 have no semi-major axis, and since 869635b * 267 have a host no survey measured the brightness of — OGLE-2005-BLG-390L b, read inside its * own host's system. + * + * A moon or dwarf planet drawn this way has been imaged — Voyager 2 photographed Uranus's five + * large moons, Proteus and Nereid, Cassini Hyperion, and Hubble sees Eris, Haumea and Makemake as + * points — but has no global map this app can use. So have the hundred or so exoplanets the + * archive flags as imaged, HR 8799's four among them, though only as points of light beside their + * star — and one of them has a map, not used here: Luhman 16 b, a brown dwarf, mapped by Doppler + * imaging (Crossfield et al. 2014, Nature 505, 654). Only the other exoplanets, known from what they + * do to starlight, have no image at all. */ function provenanceFor(body: BodyDetailViewModel): string { if (body.hasPhotography) { return 'Surface: NASA/ESA/USGS photography.'; } + const why = + body.kind !== 'exoplanet' + ? 'no global map of this world is used here' + : body.imaged + ? 'it has been imaged only as a point of light beside its star, and no map of it is used here' + : 'no image of this world exists'; return body.appearance.equilibriumTemperatureK === null - ? 'Surface illustrated from this body’s measured size and mass. No temperature could be derived: its star’s luminosity or its orbit’s size is not known. Not an observation — no image of this world exists.' - : 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.'; + ? `Surface illustrated from this body’s measured size and mass. No temperature could be derived: its star’s luminosity or its orbit’s size is not known. Not an observation — ${why}.` + : `Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — ${why}.`; } diff --git a/src/app/features/body-detail/body-view-model.spec.ts b/src/app/features/body-detail/body-view-model.spec.ts index 5a22777..d4e76fa 100644 --- a/src/app/features/body-detail/body-view-model.spec.ts +++ b/src/app/features/body-detail/body-view-model.spec.ts @@ -1,9 +1,13 @@ +/// + +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model'; -import { buildBodyViewModel, heliocentricPeriodDays, luminosityOf, publishedTemperaturesK, starSurfaceOf } from './body-view-model'; +import { bodyReadouts } from './body-readouts'; +import { buildBodyViewModel, luminosityOf, publishedTemperaturesK, starSurfaceOf } from './body-view-model'; const orbit = (overrides: Partial = {}): OrbitalElements => ({ semiMajorAxisAu: 1, @@ -34,6 +38,9 @@ const earth: BodyRecord = { kind: 'planet', radiusKm: 6371, orbit: orbit(), + // Standish's mean longitude rate, 35 999.373 degrees a century. + rates: { meanMotionDegPerDay: 35999.37306329 / 36525, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 }, + orbitSource: 'JPL approximate mean elements (Standish), fit for 3000 BC to AD 3000', }; const luna: BodyRecord = { id: 'luna', @@ -43,45 +50,74 @@ const luna: BodyRecord = { radiusKm: 1737, parentBodyId: 'earth', orbit: orbit({ semiMajorAxisAu: 0.00257 }), + // JPL SSD's sidereal mean motion for the Moon. + rates: { meanMotionDegPerDay: 13.176358, longitudeOfAscendingNodeDegPerDay: -0.05299, argumentOfPeriapsisDegPerDay: 0.16435 }, + orbitSource: 'JPL SSD satellite mean elements, epoch 2000 Jan 1', }; -describe('heliocentricPeriodDays', () => { - it('recovers a known period from the semi-major axis alone', () => { - // P² = a³ in these units, so Earth must come back a year. - expect(heliocentricPeriodDays(earth)).toBeCloseTo(365.25, 1); - }); - - it('scales as the three-halves power', () => { - const jupiter: BodyRecord = { - ...earth, - id: 'jupiter', - name: 'Jupiter', - orbit: orbit({ semiMajorAxisAu: 5.2044 }), - }; - // Jupiter's real sidereal period is 4332.6 days. - expect(heliocentricPeriodDays(jupiter)).toBeCloseTo(4335, -1); - }); - - it('refuses to compute a period for a moon', () => { - // A moon's elements are relative to its planet, whose mass is not in the catalogue — the - // same arithmetic would be wrong by the ratio of that planet's mass to the Sun's. - expect(heliocentricPeriodDays(luna)).toBeUndefined(); - }); -}); - describe('buildBodyViewModel', () => { const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] }; - it('marks a period computed from the semi-major axis as derived', () => { + it('gives a planet the sidereal year its published mean motion goes round in', () => { const model = buildBodyViewModel('earth', catalogues); - expect(model?.orbitalPeriodSource).toBe('derived'); - expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1); + expect(model?.orbitalPeriodSource).toBe('measured'); + expect(model?.orbitalPeriodDays).toBeCloseTo(365.2564, 4); }); - it('leaves a moon without a period rather than inventing one', () => { + it('gives a moon its period too, from the same mean motion that carries it round', () => { + // The card used to refuse, while the scene turned the Moon round the Earth all the same. const model = buildBodyViewModel('luna', catalogues); - expect(model?.orbitalPeriodDays).toBeUndefined(); - expect(model?.orbitalPeriodSource).toBeUndefined(); + expect(model?.orbitalPeriodSource).toBe('measured'); + expect(model?.orbitalPeriodDays).toBeCloseTo(27.32166, 5); + }); + + it('prints the size of an inclination fitted below zero, as the same orbit with its node turned half round', () => { + const tilted: BodyRecord = { ...earth, orbit: orbit({ inclinationDeg: -0.00054346 }) }; + const model = buildBodyViewModel('earth', { ...catalogues, bodies: [tilted] })!; + expect(bodyReadouts(model).measured.find((row) => row.label === 'Inclination')?.value).toBe('0.00°'); + }); + + it('prints the eccentricity measured for a moon whose orbit keeps an older one', () => { + const hyperion: BodyRecord = { ...luna, id: 'hyperion', orbit: orbit({ eccentricity: 0.0232 }), measuredEccentricity: 0.105 }; + const model = buildBodyViewModel('hyperion', { ...catalogues, bodies: [earth, hyperion] })!; + expect(bodyReadouts(model).measured.find((row) => row.label === 'Eccentricity')?.value).toBe('0.105'); + }); + + it('gives a triaxial body its semi-axes beside its mean radius, not a radius alone', () => { + // As shipped: Haumea's shape (Ortiz et al. 2017) travels from the ETL's spec to its card. + const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8')); + const haumea = shipped.find((body) => body.id === 'haumea')!; + const measured = bodyReadouts(buildBodyViewModel('haumea', { ...catalogues, bodies: [earth, haumea] })!).measured; + expect(measured.find((row) => row.label === 'Mean radius')?.value).toBe('798 km'); + expect(measured.find((row) => row.label === 'Semi-axes')?.value).toBe('1,161 × 852 × 513 km'); + expect(measured.find((row) => row.label === 'Radius')).toBeUndefined(); + // Every other body keeps its one radius. + expect(bodyReadouts(buildBodyViewModel('earth', catalogues)!).measured.find((row) => row.label === 'Radius')?.value).toBe('6,371 km'); + }); + + it('says where the orbit comes from, in the card’s provenance', () => { + expect(bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance).toContain('Orbit: JPL SSD satellite mean elements, epoch 2000 Jan 1.'); + }); + + it('says a moon without a map is illustrated, without saying it was never imaged', () => { + // luna has no map under that id. Voyager and Cassini photographed every moon drawn this way. + const provenance = bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance; + expect(provenance).toContain('Not an observation — no global map of this world is used here.'); + expect(provenance).not.toContain('no image of this world exists'); + }); + + it('says an exoplanet the archive does not flag as imaged has no image', () => { + const exoplanet: ExoplanetRecord = { id: 'x', hostStarId: SUN_STAR_ID, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } }; + const model = buildBodyViewModel('x', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!; + expect(bodyReadouts(model).provenance).toContain('Not an observation — no image of this world exists.'); + }); + + it('says a directly imaged exoplanet was seen as a point of light, not that no image of it exists', () => { + // HR 8799 b: photographed beside its star at Gemini and Keck (Marois et al. 2008). + const exoplanet: ExoplanetRecord = { id: 'HR 8799 b', hostStarId: SUN_STAR_ID, hostStarName: 'HR 8799', name: 'HR 8799 b', imaged: true, orbit: { semiMajorAxisAu: 68 } }; + const provenance = bodyReadouts(buildBodyViewModel('HR 8799 b', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!).provenance; + expect(provenance).toContain('Not an observation — it has been imaged only as a point of light beside its star, and no map of it is used here.'); + expect(provenance).not.toContain('no image of this world exists'); }); it('marks a published exoplanet period as measured, not derived', () => { diff --git a/src/app/features/body-detail/body-view-model.ts b/src/app/features/body-detail/body-view-model.ts index 744b552..c5a2799 100644 --- a/src/app/features/body-detail/body-view-model.ts +++ b/src/app/features/body-detail/body-view-model.ts @@ -112,7 +112,10 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body const body = catalogues.bodies.find((candidate) => candidate.id === id); if (body) { const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId); - const periodDays = heliocentricPeriodDays(body); + // The period the map draws, moons included: JPL's own mean motion, which is also what + // carries the body round the scene. Europa's card had no period at all while the scene + // turned it round Jupiter in 3.55 days. + const periodDays = 360 / body.rates.meanMotionDegPerDay; return { id: body.id, name: body.name, @@ -120,11 +123,13 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body hostStarName: hostStar?.name ?? 'Unknown star', hostStarId: body.systemStarId, radiusKm: body.radiusKm, - orbit: body.orbit, + semiAxesKm: body.semiAxesKm, + orbit: body.measuredEccentricity === undefined ? body.orbit : { ...body.orbit, eccentricity: body.measuredEccentricity }, appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)), hasPhotography: bodyTexturePath(body.id) !== undefined, orbitalPeriodDays: periodDays, - orbitalPeriodSource: periodDays === undefined ? undefined : 'derived', + orbitalPeriodSource: 'measured', + orbitSource: body.orbitSource, }; } @@ -149,6 +154,7 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body hostStar ? starSurfaceOf(hostStar, catalogues.exoplanets.filter((candidate) => candidate.hostStarId === hostStar.id)).luminositySolar : null, ), hasPhotography: bodyTexturePath(exoplanet.id) !== undefined, + imaged: exoplanet.imaged, // `periodDays` is populated for none of the shipped records, and deriving one would need the // host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass // host, which would silently mis-state the period of every planet around an M dwarf. @@ -156,19 +162,3 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured', }; } - -/** - * Kepler's third law for a body orbiting the Sun: P² = a³ with P in years and a in AU, which - * holds exactly in these units because the Sun's mass is the unit of mass. - * - * Only for heliocentric orbits. A moon's elements are relative to its parent planet, whose mass - * the catalogue does not carry, so the same arithmetic there would be wrong by the ratio of the - * planet's mass to the Sun's — a factor of a thousand for Jupiter. - */ -export function heliocentricPeriodDays(body: BodyRecord): number | undefined { - if (body.parentBodyId !== undefined || body.systemStarId !== SUN_STAR_ID) { - return undefined; - } - const a = body.orbit.semiMajorAxisAu; - return a > 0 ? Math.pow(a, 1.5) * 365.25 : undefined; -} diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index 743de04..b4ed7d4 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -6,10 +6,13 @@ import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'v import { DataLoaderService, StarField } from '../../core/data/data-loader.service'; import { EngineService, EngineTickCallback } from '../../core/engine/engine.service'; import { BodyRecord } from '../../shared/models/body.model'; +import { GM_SUN_AU3_PER_DAY2 } from '../../shared/astro/constants'; +import { keplerRates } from '../../shared/astro/kepler'; import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; import { NavigationStore } from '../../shared/state/navigation.store'; +import { TimeStore } from '../../shared/state/time.store'; import { LinkBudget } from '../../shared/astro/jump-links'; import { HudDisplay } from '../hud/hud-dock.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; @@ -23,6 +26,7 @@ import { planetTexture } from '../../shared/rendering/procedural-planet-texture' import { loadCachedTexture, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { JumpLinkRenderer } from './jump-link-renderer'; import { StarFieldRenderer } from './star-field-renderer'; +import { SystemOrbitsRenderer } from './system-orbits-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; // jsdom does not implement ResizeObserver; the component only uses it to react to real @@ -93,7 +97,8 @@ const EARTH: BodyRecord = { argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: 2451545.0 - } + }, + rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test' }; /** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely, @@ -865,6 +870,111 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(navigationStore.viewLevel()).toBe('system'); }); + it('says where a system’s orbits come from, and for the Sun how long they hold', async () => { + const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote(); + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + expect(note()).toMatch(/^Orbits propagated from JPL mean elements, the planets’ fit for 3000 BC to AD 3000 and the moons’ checked from 1950 to 2100, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, checked over the same span, to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/); + + navigationStore.selectStar(ALPHA_CENTAURI.id); + await flushAsync(); + await advanceFrames(engine, 5); + expect(note()).toMatch(/^Orbits propagated from published elements to now, \d{4}-\d\d-\d\d \d\d:\d\d UTC\.$/); + }); + + it('turns the Sun about its IAU pole, once in 25.38 days', async () => { + const time = TestBed.inject(TimeStore); + time.setRate(0); + time.setDate(new Date('2026-01-01T00:00Z')); + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + const sun = (): THREE.Object3D => (fixture.componentInstance as unknown as { starMarker: THREE.Object3D }).starMarker; + const turned = (local: THREE.Vector3): THREE.Vector3 => local.applyQuaternion(sun().getWorldQuaternion(new THREE.Quaternion())); + + // The sphere's +Y, which MAP_TO_BODY carries onto the body's pole, at RA 286.13, Dec 63.87. + const ra = (286.13 * Math.PI) / 180; + const dec = (63.87 * Math.PI) / 180; + const pole = new THREE.Vector3(Math.cos(dec) * Math.cos(ra), Math.cos(dec) * Math.sin(ra), Math.sin(dec)); + expect(turned(new THREE.Vector3(0, 1, 0)).angleTo(pole)).toBeLessThan(1e-6); + + const before = turned(new THREE.Vector3(1, 0, 0)); + time.setDate(new Date('2026-01-02T00:00Z')); + await advanceFrames(engine, 0.1); + expect((turned(new THREE.Vector3(1, 0, 0)).angleTo(before) * 180) / Math.PI).toBeCloseTo(14.1844, 3); + }); + + it('names the date the system is drawn for once the clock is set to one', async () => { + const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote(); + TestBed.inject(TimeStore).setDate(new Date('2020-12-21T18:00Z')); + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + // The great conjunction, to the minute: not "now", and not a date the reader has to find. + expect(note()).toMatch(/ to 2020-12-21 18:00 UTC\.$/); + }); + + describe('with Eris, whose aphelion runs past the grid', () => { + type FramedScene = { bodies: BodyRecord[]; controls: { target: THREE.Vector3 }; systemRenderer: SystemOrbitsRenderer; systemGroup: THREE.Group }; + // Its 67.9 AU axis gives the grid an 80 AU outer ring; at aphelion it is 97.7 AU out. + const ERIS: BodyRecord = { + ...EARTH, id: 'eris', name: 'Eris', kind: 'dwarf', radiusKm: 1163, + orbit: { ...EARTH.orbit, semiMajorAxisAu: 67.934, eccentricity: 0.4382 }, rates: keplerRates(67.934, GM_SUN_AU3_PER_DAY2) + }; + + async function enterTheSun(aspect: number): Promise { + const component = fixture.componentInstance as unknown as FramedScene; + component.bodies = [EARTH, ERIS]; + engine.getPerspectiveCamera().aspect = aspect; + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + return component; + } + + it('frames the furthest the system draws, Eris’s aphelion, not the ring inside it nor its semi-major axis', async () => { + const component = await enterTheSun(1); + const camera = engine.getPerspectiveCamera(); + expect(component.systemRenderer.outermostRadiusAu).toBeCloseTo(67.934 * 1.4382, 9); + // 234.7 AU; framed on the 67.9 AU axis the camera would stand at 163 AU and Eris arrive off screen. + expect(camera.position.distanceTo(component.controls.target)).toBeCloseTo( + systemFramingDistanceAu(component.systemRenderer.outermostRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect }), + 6 + ); + }); + + it('holds Earth to its 3-pixel floor at the arrival framing, where its true radius is far under a pixel', async () => { + Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1000 }); + const component = await enterTheSun(1.6); + const earth = component.systemRenderer.members.find((member) => member.id === 'earth')!.marker as THREE.Mesh; + const pixelAu = (2 * engine.visibleHalfHeight(engine.getCamera().position.distanceTo(earth.getWorldPosition(new THREE.Vector3())))) / 1000; + expect((earth.scale.x * (earth.geometry as THREE.SphereGeometry).parameters.radius) / pixelAu).toBeCloseTo(3, 3); + }); + + it('leaves the system outwards even from a phone’s framing, which stands past the 400 AU it used to fly to', async () => { + const component = await enterTheSun(390 / 844); + const camera = engine.getCamera(); + const arrival = camera.position.length(); + expect(arrival).toBeGreaterThan(500); + + navigationStore.selectStar(null); + await flushAsync(1); + // Until the swap: it flew 508 AU in to 400, and the system grew on screen while the reader left it. + let previous = arrival; + for (let frame = 0; frame < 100 && component.systemGroup.visible; frame++) { + engine.tick(0.05); + await flushAsync(1); + if (component.systemGroup.visible) { + expect(camera.position.length()).toBeGreaterThanOrEqual(previous - 1e-9); + previous = camera.position.length(); + } + } + expect(component.systemGroup.visible).toBe(false); + expect(previous).toBeGreaterThan(arrival); + }); + }); + it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => { navigationStore.selectStar(ALPHA_CENTAURI.id); await flushAsync(); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index d4be335..1b63f16 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -24,7 +24,9 @@ import { spectralClassification } from '../../shared/astro/spectral'; import { blackbodyColor } from '../../shared/astro/stellar'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService, SceneCamera } from '../../core/engine/engine.service'; -import { BodyRecord } from '../../shared/models/body.model'; +import { BodyRecord, RotationalElements } from '../../shared/models/body.model'; +import { SUN_ROTATIONAL_ELEMENTS } from '../../shared/astro/rotational-elements'; +import { bodyOrientation } from '../../shared/rendering/body-orientation'; import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { applyMilkyWaySkybox } from '../../shared/rendering/skybox'; @@ -34,7 +36,7 @@ import { SUN_TEXTURE_PATH, } from '../../shared/rendering/texture-catalog'; import { isDesignation } from '../../shared/models/star-catalog'; -import { StarRecord } from '../../shared/models/star.model'; +import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model'; import { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store'; import { TimeStore } from '../../shared/state/time.store'; @@ -319,8 +321,13 @@ const SYSTEM_FAR_AU = 20000; const SYSTEM_MAX_DISTANCE_AU = 5000; /** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */ const SYSTEM_ENTRY_DISTANCE_AU = 200; -/** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */ +/** + * How far out (AU) the camera flies, at least, before swapping back to galaxy/parsec space. A camera + * already beyond it flies half as far again: a phone held upright frames the Sun's system from 508 AU, + * and flying to 400 drew the system 21 per cent nearer while the reader was leaving it. + */ const SYSTEM_EXIT_DISTANCE_AU = 400; +const SYSTEM_EXIT_PULL_BACK = 1.5; const APPROACH_DURATION_SECONDS = 1.0; const SETTLE_DURATION_SECONDS = 0.9; @@ -588,6 +595,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private starMarker?: THREE.Mesh; /** The system's star's radius and temperature, worked out once on entering it. */ private currentStarSurface?: StarSurface; + /** How the star marker is turned: the Sun's IAU elements for the Sun, nothing for any other star. */ + private starRotation?: RotationalElements; constructor( private readonly engine: EngineService, @@ -864,6 +873,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { if (this.systemGroup.visible) { this.systemRenderer?.update(this.time.julianDate()); + if (this.starMarker && this.starRotation) { + bodyOrientation(this.starRotation, this.time.julianDate(), this.starMarker.quaternion); + } this.keepMarkersLegible(camera); } this.updateSelectionMark(camera); @@ -893,8 +905,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } const world = new THREE.Vector3(); const drawnRadiusAu = new Map(); - const radiusOf = (marker: THREE.Object3D): number | undefined => + // A body's marker is a unit sphere scaled to its radius, kept in `userData.radiusAu`; the + // star's is built at its own. + const sphereRadius = (marker: THREE.Object3D): number | undefined => ((marker as THREE.Mesh).geometry as THREE.SphereGeometry | undefined)?.parameters?.radius; + const radiusOf = (marker: THREE.Object3D): number | undefined => + (marker.userData['radiusAu'] as number | undefined) ?? sphereRadius(marker); const floorFor = (marker: THREE.Object3D): number => { marker.getWorldPosition(world); return ( @@ -923,7 +939,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { : Number.POSITIVE_INFINITY; const drawn = Math.min(Math.max(radiusAu, floorFor(marker)), Math.max(radiusAu, ceiling)); drawnRadiusAu.set(id, drawn); - marker.scale.setScalar(drawn / radiusAu); + marker.scale.setScalar(drawn / sphereRadius(marker)!); } } @@ -1796,7 +1812,14 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { }, ...starReadouts(star, this.currentStarSurface), ]); - this.hudNote.set(this.time.atNow() ? 'Orbits propagated from published elements to the current date.' : 'Orbits propagated from published elements to the date on the clock.'); + // Where the orbits come from, and for the Sun how far from the present they hold: each + // body's card names its own source, and for a moon or an SBDB dwarf planet how far it strays from + // Horizons over the span it was checked. + const source = this.bodies.some((body) => body.systemStarId === star.id) ? 'JPL mean elements, the planets’ fit for 3000 BC to AD 3000 and the moons’ checked from 1950 to 2100, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, checked over the same span,' : 'published elements'; + // Named to the minute, in UTC like the date field: a jump to 18:00 on a given day is a + // question about that hour, and the note is where the answer says which sky it is. + const drawnFor = `${this.time.date().toISOString().slice(0, 16).replace('T', ' ')} UTC`; + this.hudNote.set(`Orbits propagated from ${source} to ${this.time.atNow() ? 'now, ' : ''}${drawnFor}.`); this.hudRange.set( formatAu( this.engine.visibleHalfHeight( @@ -2247,16 +2270,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const starRadiusAu = this.currentStarSurface.radiusSolar === null ? UNMEASURED_STAR_RADIUS_AU : this.currentStarSurface.radiusSolar * SUN_RADIUS_AU; - // Framed against the grid's outer ring rather than the outermost orbit — the ring is always - // the wider of the two — or against the star, for a giant wider than both; and against the - // camera this scene actually has, so the margin holds whatever the window shape. + // Framed against the outermost thing drawn — the grid's outer ring, or an eccentric orbit's + // aphelion where it runs past it — or against the star, for a giant wider than both; and + // against the camera this scene actually has, so the margin holds whatever the window shape. // Framed against the perspective camera whichever is active: the framing distance is what // the orthographic frustum is then sized from, so both projections show the same extent. const framingCamera = this.engine.getPerspectiveCamera(); const canvas = this.canvasRef().nativeElement; const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect, shorterSidePx: Math.min(canvas.clientWidth, canvas.clientHeight) }; const framingDistance = systemFramingDistanceAu( - this.systemRenderer.gridOuterRadiusAu, + this.systemRenderer.outermostRadiusAu, viewport, starRadiusAu, ); @@ -2272,6 +2295,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // stayed put as the camera closed in and ended up filling the screen with the flat gradient // that was meant to dress the star, over the photograph underneath it. this.starMarker = new THREE.Mesh(this.starMarkerGeometry, this.starMarkerMaterial); + // Its pole 115 degrees from the one the IAU gives, and still, until it was turned like a planet. + // The map's longitudes are Solar System Scope's, not Carrington's, so only the pole and the + // 25.38-day turn are the Sun's own. + this.starRotation = star.id === SUN_STAR_ID ? SUN_ROTATIONAL_ELEMENTS : undefined; this.systemGroup.add(this.starMarker); this.galaxyGroup.visible = false; @@ -2332,7 +2359,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.rig.flyTo( { - position: direction.clone().multiplyScalar(SYSTEM_EXIT_DISTANCE_AU), + position: direction.clone().multiplyScalar(Math.max(SYSTEM_EXIT_DISTANCE_AU, SYSTEM_EXIT_PULL_BACK * camera.position.length())), target: new THREE.Vector3(0, 0, 0), }, EXIT_DURATION_SECONDS, diff --git a/src/app/features/galaxy-system/system-framing.spec.ts b/src/app/features/galaxy-system/system-framing.spec.ts index 7955d12..6afb033 100644 --- a/src/app/features/galaxy-system/system-framing.spec.ts +++ b/src/app/features/galaxy-system/system-framing.spec.ts @@ -17,6 +17,10 @@ import { const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 }; const GL_357 = { innermost: 0.035, outermost: 0.204 }; const SOLAR = { innermost: 0.387, outermost: 30.07 }; +/** The solar system as the map draws it: out to Eris's semi-major axis, 67.93 AU. */ +const SOLAR_TO_ERIS = { innermost: 0.387, outermost: 67.93 }; +/** Eris's aphelion, a(1 + e) = 67.934 x 1.4382: past the 80 AU ring its semi-major axis gives. */ +const ERIS_APHELION_AU = 97.7; describe('systemFramingDistanceAu', () => { it('fits the radius it is given in view, with room around it', () => { @@ -114,7 +118,11 @@ describe('systemFramingDistanceAu', () => { }); describe('the grid and the framing together', () => { - /** What the scene actually composes: rings from the orbits, then a distance from the rings. */ + /** + * The grid's half of what the scene composes: rings from the orbits, then a distance from the outer + * ring. The scene frames the larger of that ring and the furthest aphelion (`outermostRadiusAu`), + * which the Eris test below frames where it runs past the ring, and the scene's own spec checks. + */ function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } { const rings = systemGridRingsAu(outermostOrbitAu); const ring = rings[rings.length - 1]; @@ -124,14 +132,15 @@ describe('the grid and the framing together', () => { const VIEWPORTS: SystemViewport[] = [ { fovDegrees: 50, aspect: 1.78 }, { fovDegrees: 50, aspect: 1 }, - { fovDegrees: 50, aspect: 0.6 } + { fovDegrees: 50, aspect: 0.6 }, + { fovDegrees: 50, aspect: 390 / 844 } // a phone held upright ]; it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => { // The whole point of framing against the grid rather than the orbits: before this, 368 of // the 371 systems in the datasets drew a grid wider than the view that was meant to hold it. for (const viewport of VIEWPORTS) { - for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) { + for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, SOLAR_TO_ERIS, { outermost: 1 }, { outermost: 12.4 }]) { const { ring, frame } = fit(outermost, viewport); expect(ring).toBeLessThan(frame); expect(ring / frame).toBeLessThan(0.93); @@ -139,6 +148,14 @@ describe('the grid and the framing together', () => { } }); + it('leaves Eris’s aphelion its whole margin in every window shape, a phone held upright included', () => { + // The scene frames the aphelion where it runs past the ring. Under the old 500 AU ceiling the + // phone would hold it at 0.907 of the half-width instead of 1 / 1.12 = 0.893. + for (const viewport of VIEWPORTS) { + expect(ERIS_APHELION_AU / systemFrameRadiusAu(systemFramingDistanceAu(ERIS_APHELION_AU, viewport), viewport)).toBeLessThan(0.9); + } + }); + it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => { for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) { expect(fit(outermost).ring).toBeGreaterThan(outermost); diff --git a/src/app/features/galaxy-system/system-framing.ts b/src/app/features/galaxy-system/system-framing.ts index 42fd8c4..2118d72 100644 --- a/src/app/features/galaxy-system/system-framing.ts +++ b/src/app/features/galaxy-system/system-framing.ts @@ -53,13 +53,17 @@ const MIN_FRAMING_DISTANCE_AU = 0.06; /** * Ceiling on the framing distance, so a distant companion does not push the star to a dot. * - * Generous enough to frame the solar system out to Pluto in any window shape, which needs 120 AU - * on a landscape display and 140 on a portrait one once the camera's real field of view is - * accounted for. Only genuinely pathological systems reach it now — the handful with - * directly-imaged companions hundreds of AU out — and those still arrive framed on their inner - * region, with the orbit controls reaching far enough to pull back to the rest. + * Generous enough to frame the solar system out to Eris in any window a reader holds: Eris's + * aphelion, 97.7 AU, the furthest it draws, needs 235 AU on a landscape display and 508 on a 390 + * by 844 phone once the camera's real field of view is accounted for, and 600 holds it down to an + * aspect of 0.39. At 500, framed on the 80 AU grid ring inside that aphelion, a phone arrived with + * Eris's orbit 3.5 px from the edge; at 200, which framed Pluto's 40 AU ring, a portrait window + * arrived with Eris off screen, and a phone with Makemake too. Only genuinely pathological systems + * reach it now — the handful with directly-imaged companions hundreds of AU out — and those still + * arrive framed on their inner region, with the orbit controls reaching far enough to pull back + * to the rest. */ -const MAX_FRAMING_DISTANCE_AU = 200; +const MAX_FRAMING_DISTANCE_AU = 600; /** * How much of the view's tighter half-extent a giant's disc may take on arrival: inside the ring @@ -145,8 +149,9 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport * was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the * grid overflowing the frame in 368 of the 371 systems the datasets contain. * - * Callers pass the outermost thing actually drawn, which is the reference grid's outer ring - * rather than the outermost orbit — the ring is always the wider of the two, by construction. + * Callers pass the outermost thing actually drawn: the reference grid's outer ring, which runs past + * every semi-major axis by construction, or an eccentric orbit's aphelion where that runs past the + * ring, as Eris's does. */ export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT, starRadiusAu = 0): number { // A giant drawn at its own radius can be wider than the system around it — Betelgeuse's 584 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 b83c6b8..641bef2 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -1,11 +1,21 @@ -import * as THREE from 'three/webgpu'; -import { describe, expect, it } from 'vitest'; +/// -import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants'; -import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; +import { readFileSync } from 'node:fs'; +import * as THREE from 'three/webgpu'; +import { describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2, ttMinusUtSeconds } from '../../shared/astro/constants'; +import { keplerRates } from '../../shared/astro/kepler'; +import { eclipticToEquatorial, laplacePlaneToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; +import { orientationAt } from '../../shared/astro/rotational-elements'; import { BodyRecord } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { SystemOrbitsRenderer } from './system-orbits-renderer'; +import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog'; +import { bodyMarkerRadiusAu } from './system-framing'; + +/** The clock's UT date that names a TDB one: TT - UT, which moves by under a second a year, earlier. */ +const utOf = (jdTdb: number): number => jdTdb - ttMinusUtSeconds(jdTdb) / 86400; /** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */ const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154; @@ -164,7 +174,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => { argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD - } + }, + rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test' }; it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => { @@ -201,7 +212,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => { // A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move. const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } }; const renderer = new SystemOrbitsRenderer([atEquinox], []); - renderer.update(DEFAULT_EPOCH_JD); + // The clock's UT date whose TDB is the elements' epoch. + renderer.update(utOf(DEFAULT_EPOCH_JD)); const p = renderer.members[0].marker.position; expect(p.x).toBeCloseTo(1, 6); @@ -234,7 +246,9 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => { name: 'Jupiter', kind: 'planet', radiusKm: 69911, - orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD } + orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD }, + rates: keplerRates(5.2, GM_SUN_AU3_PER_DAY2), + orbitSource: 'test' }; /** The grid and the tethers are the only line objects the renderer adds outside a pivot. */ @@ -373,8 +387,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => { }); }); -describe('rotation', () => { - /** Earth, near enough: a day of 23.934 h, tipped 23.44 degrees off its orbit. */ +describe('rotation without IAU elements', () => { + /** A body with a day of 23.934 h and no pole: Eris, Haumea and Makemake are drawn this way. */ function spinning(overrides: Partial = {}): BodyRecord { return { id: 'earth', @@ -383,8 +397,8 @@ describe('rotation', () => { kind: 'planet', radiusKm: 6371, orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD }, + rates: keplerRates(1, GM_SUN_AU3_PER_DAY2), orbitSource: 'test', rotationPeriodHours: 23.934, - obliquityDeg: 23.4392911, ...overrides }; } @@ -423,22 +437,23 @@ describe('rotation', () => { return axis.normalize().dot(new THREE.Vector3(0, 0, 1).applyQuaternion(renderer.referenceFrame)); } - it('turns Venus backwards, as Horizons gives it: a negative rate and an obliquity past 90', () => { - // Both say retrograde, in two conventions. Applied together they cancelled into a forward - // turn, which is how Venus and Uranus used to be drawn. - const venus = spinning({ id: 'venus', rotationPeriodHours: -5832.54, obliquityDeg: 177.3 }); - - expect(spinSense(spinning())).toBeGreaterThan(0.9); - expect(spinSense(venus)).toBeLessThan(-0.9); + it('turns it about its orbit’s normal, backwards for a negative period', () => { + expect(spinSense(spinning({ rotationPeriodHours: -23.934 }))).toBeLessThan(-0.99); + expect(spinSense(spinning({ rotationPeriodHours: 23.934 }))).toBeGreaterThan(0.99); }); - it('reads the sign of the period only where no obliquity says which way the pole points', () => { - expect(spinSense(spinning({ rotationPeriodHours: -23.934, obliquityDeg: undefined }))).toBeLessThan(-0.9); - expect(spinSense(spinning({ rotationPeriodHours: 23.934, obliquityDeg: undefined }))).toBeGreaterThan(0.9); + it('turns Nereid, as shipped, once in the 11.594 hours Kepler measured: a sixth of a turn in 1.93 hours', () => { + const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8')); + const renderer = new SystemOrbitsRenderer(shipped.filter((body) => body.id === 'neptune' || body.id === 'nereid'), []); + const nereid = renderer.members.find((member) => member.id === 'nereid')!.marker; + renderer.update(DEFAULT_EPOCH_JD); + const start = nereid.quaternion.clone(); + renderer.update(DEFAULT_EPOCH_JD + 11.594 / 6 / 24); + expect((nereid.quaternion.angleTo(start) * 180) / Math.PI).toBeCloseTo(60, 1); }); it('leaves a body with no published rotation still', () => { - // Titan: Horizons states no period for it, and an invented one would be a claim. + // Hyperion, which tumbles: an invented period 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(); @@ -448,10 +463,132 @@ describe('rotation', () => { }); }); +describe('outermostRadiusAu', () => { + function drawn(axis: number, eccentricity: number): BodyRecord { + return { + id: 'eris', systemStarId: 0, name: 'Eris', kind: 'dwarf', radiusKm: 1163, orbitSource: 'test', + orbit: { semiMajorAxisAu: axis, eccentricity, inclinationDeg: 44, longitudeOfAscendingNodeDeg: 36, argumentOfPeriapsisDeg: 151, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD }, + rates: keplerRates(axis, GM_SUN_AU3_PER_DAY2) + }; + } + + it('reaches as far as an eccentric orbit goes past the grid: Eris’s aphelion, 97.7 AU, not the 80 AU ring', () => { + const renderer = new SystemOrbitsRenderer([drawn(67.934, 0.4382)], []); + expect(renderer.outermostRadiusAu).toBeCloseTo(67.934 * 1.4382, 9); + renderer.dispose(); + }); + + it('reaches an exoplanet’s aphelion too: HD 20782 b’s, 1.66 times its 1.6 AU ring', () => { + // The most eccentric of the 303 exoplanet systems with an orbit past their ring, counted on + // exoplanets.json (a = 1.3649 AU, e = 0.95). + const renderer = new SystemOrbitsRenderer([], [exoplanet({ id: 'HD 20782 b', name: 'HD 20782 b', orbit: { semiMajorAxisAu: 1.3649, eccentricity: 0.95 } })]); + expect(renderer.outermostRadiusAu).toBeCloseTo(1.3649 * 1.95, 9); + renderer.dispose(); + }); + + it('is the grid’s outer ring where every orbit stays inside it', () => { + const renderer = new SystemOrbitsRenderer([drawn(30, 0.01)], []); + expect(renderer.outermostRadiusAu).toBe(35); + renderer.dispose(); + }); +}); + +describe('photographs', () => { + it('puts them on their bodies once loaded, one a frame, so the GPU is not handed every map at once, and in their own colours', () => { + // Ids no other test here draws, since the loaded textures are shared through the cache. + const ids = ['ganymede', 'callisto']; + const records: BodyRecord[] = ids.map((id, index) => ({ + id, systemStarId: 0, name: id, kind: 'planet', radiusKm: 2500, orbitSource: 'test', + orbit: { semiMajorAxisAu: 1 + index, eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD }, + rates: keplerRates(1 + index, GM_SUN_AU3_PER_DAY2) + })); + const renderer = new SystemOrbitsRenderer(records, []); + const materials = (): THREE.MeshStandardMaterial[] => renderer.members.map((member) => (member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial); + const maps = (): Array => materials().map((material) => material.map); + const colours = (): number[] => materials().map((material) => material.color.getHex()); + + renderer.update(DEFAULT_EPOCH_JD); + expect(maps()).toEqual([null, null]); // not loaded yet: jsdom never loads an image + // Until then each is its kind's flat colour, a planet's pale blue. + expect(colours()).toEqual([new THREE.Color(0.55, 0.75, 1).getHex(), new THREE.Color(0.55, 0.75, 1).getHex()]); + + for (const id of ids) { + loadCachedTexture(bodyTexturePath(id)!).image = { width: 2, height: 1 }; + } + renderer.update(DEFAULT_EPOCH_JD); + expect(maps().filter(Boolean)).toHaveLength(1); + renderer.update(DEFAULT_EPOCH_JD); + expect(maps()).toEqual(ids.map((id) => loadCachedTexture(bodyTexturePath(id)!))); + // The material multiplies its map by its colour: left pale blue, every planet's photograph would + // be tinted, Mars's red cut by 45 per cent. + expect(colours()).toEqual([0xffffff, 0xffffff]); + renderer.dispose(); + }); +}); + +describe('markers', () => { + it('draws every body on the one sphere, scaled to its radius, and leaves that sphere when a system is left', () => { + const records: BodyRecord[] = [2500, 60000].map((radiusKm, index) => ({ + id: `body-${index}`, systemStarId: 0, name: `Body ${index}`, kind: 'planet', radiusKm, orbitSource: 'test', + orbit: { semiMajorAxisAu: 1 + index, eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD }, + rates: keplerRates(1 + index, GM_SUN_AU3_PER_DAY2) + })); + const renderer = new SystemOrbitsRenderer(records, [exoplanet({ radiusEarth: 1.1 })], undefined, 1); + const meshes = renderer.members.map((member) => member.marker as THREE.Mesh); + expect(new Set(meshes.map((mesh) => mesh.geometry)).size).toBe(1); + [2500, 60000, 1.1 * 6371].forEach((radiusKm, index) => { + const sphere = meshes[index].geometry as THREE.SphereGeometry; + expect(meshes[index].scale.x * sphere.parameters.radius).toBeCloseTo(bodyMarkerRadiusAu(radiusKm), 12); + }); + const disposed = vi.fn(); + meshes[0].geometry.addEventListener('dispose', disposed); + renderer.dispose(); + expect(disposed).not.toHaveBeenCalled(); + }); +}); + +describe('derived surfaces', () => { + const maps = (renderer: SystemOrbitsRenderer): Array => + renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).map); + const nextTask = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + const twoPlanets = (): SystemOrbitsRenderer => + new SystemOrbitsRenderer([], [exoplanet({ radiusEarth: 1.1 }), exoplanet({ id: 'TRAPPIST-1 c', name: 'TRAPPIST-1 c', radiusEarth: 1.0 })], undefined, 1); + + it('paints them after the system is built, one a task, so entering a system is not held up, and in their own colours', async () => { + const colours = (renderer: SystemOrbitsRenderer): number[] => + renderer.members.map((member) => ((member.marker as THREE.Mesh).material as THREE.MeshStandardMaterial).color.getHex()); + const renderer = twoPlanets(); + expect(maps(renderer)).toEqual([null, null]); + // Until then each is its kind's flat colour, an exoplanet's magenta. + expect(colours(renderer)).toEqual([new THREE.Color(0.85, 0.4, 0.85).getHex(), new THREE.Color(0.85, 0.4, 0.85).getHex()]); + await nextTask(); + expect(maps(renderer).filter(Boolean)).toHaveLength(1); + await nextTask(); + expect(maps(renderer).every(Boolean)).toBe(true); + // Left magenta, every derived surface would be multiplied by it, its green cut by 60 per cent. + expect(colours(renderer)).toEqual([0xffffff, 0xffffff]); + renderer.dispose(); + }); + + it('builds a body still waiting for its surface without three warning of an undefined map', () => { + const warn = vi.spyOn(console, 'warn'); + twoPlanets().dispose(); + expect(warn.mock.calls.flat().join(' ')).not.toContain("parameter 'map'"); + warn.mockRestore(); + }); + + it('paints nothing once the system is left', async () => { + const renderer = twoPlanets(); + renderer.dispose(); + await nextTask(); + expect(maps(renderer)).toEqual([null, null]); + }); +}); + describe('exoplanet size without a measured radius', () => { const radiusOf = (overrides: Partial): number => { const renderer = new SystemOrbitsRenderer([], [exoplanet(overrides)], undefined, 1); - return ((renderer.members[0].marker as THREE.Mesh).geometry as THREE.SphereGeometry).parameters.radius; + return renderer.members[0].marker.userData['radiusAu']; }; const EARTH_AU = 6371 / 149597870.7; @@ -490,3 +627,407 @@ describe("SystemOrbitsRenderer's star light", () => { renderer.dispose(); }); }); + +describe('solar-system bodies against Horizons', () => { + // The records the app ships, read from bodies.json with their IAU rotational elements, and + // Horizons' own positions for them (ICRF, AU; heliocentric for the planets, planet-centred for the + // moons) at dates across 1950-2100, so the whole path — the ETL's reading of the mean elements, + // their rates, the Laplace planes and the scene's frame — is checked against JPL's ephemeris rather + // than against itself. A hand copy of the records stood here, and an ETL that dropped Standish's a, + // e and i rates or Io's and Europa's backward periapses passed the whole suite on the data it + // wrote. Horizons' dates are TDB and the renderer's are the clock's UT, so each is handed over + // TT - UT earlier: 69.184 s today, 29 in 1950. + const SHIPPED: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8')); + // Mimas and Phobos among them for the terms of their IAU W that are motion along the orbit: the + // Mimas-Tethys libration and Phobos's tidal acceleration (see `orbitalTermsOfPrimeMeridian`). + const IDS = ['earth', 'jupiter', 'saturn', 'neptune', 'pluto', 'moon', 'io', 'europa', 'titan', 'triton', 'uranus', 'titania', 'charon', 'venus', 'mars', 'mimas', 'phobos']; + // Each ceiling sits just above what these elements measure on that date: Earth 0.003 degrees, + // Jupiter 0.063, Saturn 0.164, Pluto 0.054, the Moon 0.72 (no mean ellipse has its evection or + // variation), Io 0.021, Europa 0.036, Titan 0.014, Triton 0.137, Titania 0.62 (against Uranus's + // equator, 120 years from its 1980 epoch), Charon 0.37, Mimas 2.24 on 2026 May 27, when its libration has it + // 44 degrees ahead of its mean motion (43.3 without the term), and Phobos 1.25 in 2100 (11.1 without its + // tidal acceleration). + const HORIZONS: Array<[id: string, jd: number, x: number, y: number, z: number, maxDeg: number]> = [ + ['earth', 2488069.5, -0.1574071329883954, 0.890666220858489, 0.3859132211165683, 0.02], + // AD 3000, the end of the clock's window and of Standish's fit: the Earth-Moon barycentre and + // Saturn's, 0.005 and 0.065 degrees out. Without Standish's rates for a, e and i they were 0.129 + // and 0.412, which no date between 1950 and 2100 shows (at most 0.036, Saturn in 2100). + ['earth', 2816787.5, 0.06574092668156256, 0.9022934196570718, 0.3887693148519465, 0.02], + ['saturn', 2816787.5, 8.434780522117482, 3.87565654130078, 1.235068259814154, 0.1], + ['jupiter', 2433282.5, 3.406605247558555, -3.425997624196318, -1.551719750032203, 0.1], + ['saturn', 2478938.5, -3.51309768447752, -8.723317933082274, -3.452662390556131, 0.25], + ['pluto', 2442413.5, -29.2488165026956, -7.1421817246801, 6.58403957591589, 0.1], + ['moon', 2469807.5, 0.00240364781322315, 0.0006554283236619424, 0.0004472719300783614, 2], + ['io', 2433282.5, 0.0004488349204269952, 0.002519633434577752, 0.00120678715190893, 0.05], + ['europa', 2433282.5, 0.004084372287322533, -0.001665375585011311, -0.0007673072324795899, 0.1], + ['titan', 2488069.5, 0.007800850235156121, -0.001556932380983438, -0.0006078959246502567, 0.05], + ['triton', 2488069.5, -0.001421151845853369, -0.0001894510477241482, 0.001888790702926415, 0.2], + ['titania', 2488069.5, -0.00151919968294745, -0.0003387914082135071, 0.002465657830788125, 0.75], + ['charon', 2488069.5, -0.00003046411046017432, -0.000009404114448552256, 0.0001270457155789907, 0.5], + ['mimas', 2461187.5, -3.840685116962088e-4, 1.182766232573268e-3, -2.006928678577268e-5, 3], + ['phobos', 2488069.5, 4.269855297288105e-5, -2.759158950396543e-5, -3.816269137755616e-5, 1.5], + ]; + + function record(id: string): BodyRecord { + const { kind, orbit, rates, laplacePole, parentBodyId, massRatio, rotationalElements } = SHIPPED.find((body) => body.id === id)!; + return { id, systemStarId: 0, name: id, radiusKm: 1000, orbitSource: 'test', kind, orbit, rates, laplacePole, parentBodyId, massRatio, rotationalElements }; + } + + const renderer = new SystemOrbitsRenderer(IDS.map(record), []); + + for (const [id, jd, x, y, z, maxDeg] of HORIZONS) { + it(`puts ${id} within ${maxDeg} degrees of Horizons on JD ${jd}`, () => { + renderer.update(utOf(jd)); + const drawn = renderer.members.find((member) => member.id === id)!.marker.position; + const angleDeg = (drawn.angleTo(new THREE.Vector3(x, y, z)) * 180) / Math.PI; + expect(angleDeg).toBeLessThan(maxDeg); + }); + } + + it('puts Pluto where Horizons has it round its barycentre with Charon, 2 131 km out and opposite Charon', () => { + // Horizons, Pluto (999) from the Pluto-system barycentre (9), on JD 2488069.5 TDB (2100). + const horizons = new THREE.Vector3(0.000003313612032581019, 0.000001023040948538272, -0.00001381793390079716); + renderer.update(utOf(2488069.5)); + const charon = renderer.members.find((member) => member.id === 'charon')!.marker; + const barycentre = charon.parent!.position; + const pluto = renderer.members.find((member) => member.id === 'pluto')!.marker.position.clone().sub(barycentre); + const charonFromBarycentre = charon.position; + + expect((pluto.angleTo(horizons) * 180) / Math.PI).toBeLessThan(0.5); + expect(pluto.length() * 149597870.7).toBeCloseTo(horizons.length() * 149597870.7, -1); + // Opposite, at the inverse of their mass ratio. + expect((pluto.angleTo(charonFromBarycentre) * 180) / Math.PI).toBeCloseTo(180, 6); + expect(charonFromBarycentre.length() / pluto.length()).toBeCloseTo(1 / 0.1220485755631374, 6); + }); + + it('draws Pluto’s own orbit round the barycentre, in the plane it is going round in', () => { + const charon = renderer.members.find((member) => member.id === 'charon')!.marker; + const [charonLine, plutoLine] = charon.parent!.children.filter((child) => child.name === 'orbit-line'); + for (const days of [0, 3000, 30000]) { + renderer.update(DEFAULT_EPOCH_JD + days); + const pluto = renderer.members.find((member) => member.id === 'pluto')!.marker.position.clone().sub(charon.parent!.position); + const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(plutoLine.quaternion); + expect(Math.abs(pluto.clone().normalize().dot(normal))).toBeLessThan(1e-9); + // A near-circle 2 131 km across, a ninth of Charon's. + expect(Math.abs(plutoLine.scale.x) * 0.00013095774631236113).toBeCloseTo(pluto.length(), 8); + expect(charonLine.scale.x / Math.abs(plutoLine.scale.x)).toBeCloseTo(1 / 0.1220485755631374, 9); + } + }); + + it('turns a planet’s drawn orbit with its node, so Mars stays on its own line two thousand years out', () => { + // At AD 1 a line fixed at J2000 has Mars 3.3 million km from it, 0.5 million out of its plane. + const mars = renderer.members.find((member) => member.id === 'mars')!.marker; + const line = renderer.object.children[renderer.object.children.indexOf(mars) - 1]; + expect(line.name).toBe('orbit-line'); + for (const days of [0, -730000]) { + renderer.update(DEFAULT_EPOCH_JD + days); + const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion); + expect(Math.abs(mars.position.clone().normalize().dot(normal))).toBeLessThan(1e-9); + } + }); + + /** How far a top-level body is from its own drawn orbit line, in AU: from the nearest of its chords. */ + function offLineAu(id: string): number { + const marker = renderer.members.find((member) => member.id === id)!.marker; + const line = renderer.object.children[renderer.object.children.indexOf(marker) - 1] as THREE.Line; + expect(line.name).toBe('orbit-line'); + line.updateMatrixWorld(); + const position = line.geometry.getAttribute('position'); + const vertex = (index: number): THREE.Vector3 => new THREE.Vector3().fromBufferAttribute(position, index).applyMatrix4(line.matrixWorld); + const chord = new THREE.Line3(); + const closest = new THREE.Vector3(); + let nearest = Number.POSITIVE_INFINITY; + for (let index = 0; index + 1 < position.count; index++) { + nearest = Math.min(nearest, chord.set(vertex(index), vertex(index + 1)).closestPointToPoint(marker.position, true, closest).distanceTo(marker.position)); + } + return nearest; + } + + it('redraws a planet’s orbit as its axis and eccentricity drift, so Saturn and Mars stay on their lines at AD 1', () => { + // What is left is the 128 chords' own sag from the true ellipse, which depends on where the + // planet falls between two points: at most 0.0032 AU for Saturn, near aphelion, and 0.00055 for + // Mars. Measured 0.0017 AU for Saturn and 0.0005 for Mars at AD 1, and 0.0011 for Saturn at + // J2000. Drawn at J2000's shape at AD 1, the lines were 0.054 AU from Saturn and 0.0022 from Mars. + const saturnLine = renderer.object.children[renderer.object.children.indexOf(renderer.members.find((member) => member.id === 'saturn')!.marker) - 1] as THREE.Line; + renderer.update(DEFAULT_EPOCH_JD); + const drawnVersion = (saturnLine.geometry.getAttribute('position') as THREE.BufferAttribute).version; + for (const [id, days, maxAu] of [['saturn', -730000, 0.0035], ['mars', -730000, 0.0006], ['saturn', 0, 0.0035]] as const) { + renderer.update(DEFAULT_EPOCH_JD + days); + expect(offLineAu(id)).toBeLessThan(maxAu); + if (days !== 0) { + // Handed to the GPU again, which uploads a buffer only when its version rises: the points + // rewritten on the CPU alone leave J2000's ellipse on screen. + expect((saturnLine.geometry.getAttribute('position') as THREE.BufferAttribute).version).toBeGreaterThan(drawnVersion); + } + } + }); + + it('turns the Moon’s drawn orbit with its node, so the Moon stays on its own line', () => { + // Half the node's 18.6-year turn on, the ellipse drawn at the epoch has the Moon 10 degrees off + // its plane at the worst. + const moon = renderer.members.find((member) => member.id === 'moon')!.marker; + const line = moon.parent!.children.find((child) => child.name === 'orbit-line')!; + for (const days of [0, 1700, 3397, 3400]) { + renderer.update(DEFAULT_EPOCH_JD + days); + const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion); + expect(Math.abs(moon.position.clone().normalize().dot(normal))).toBeLessThan(1e-9); + } + }); + + const JUNE_1_2025_NOON_UTC = 2460828.0; + + /** + * The tilt of a body's drawn spin from the orbit it is drawn going round, in degrees: its angular + * velocity, read off the sphere a quarter of an hour apart, against its orbit line's normal. Past + * 90 is a body turning backwards against its orbit. + */ + function drawnObliquity(id: string): number { + const marker = renderer.members.find((member) => member.id === id)!.marker; + const line = renderer.object.children[renderer.object.children.indexOf(marker) - 1]; + expect(line.name).toBe('orbit-line'); + renderer.update(JUNE_1_2025_NOON_UTC); + const start = marker.quaternion.clone(); + renderer.update(JUNE_1_2025_NOON_UTC + 0.01); + const turn = marker.quaternion.clone().multiply(start.invert()); + const spin = new THREE.Vector3(turn.x, turn.y, turn.z).multiplyScalar(Math.sign(turn.w)); + return (spin.angleTo(new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion)) * 180) / Math.PI; + } + + it('turns Venus, Uranus and Pluto backwards against their orbits, at the tilts Horizons gives the first two', () => { + // Pluto's Horizons page gives no tilt; 119.6 is the one its IAU pole makes with its orbit, so for + // Pluto this checks that its pole and W are drawn as the kernel gives them, not the pole itself. + // The IAU names a planet's north pole by the side of the solar system it lies on, so Venus's W + // and Uranus's run backwards; Pluto's pole follows the right-hand rule instead, and points + // south. Either way the spin read off the drawn sphere is past 90 degrees from the orbit's pole. + expect(drawnObliquity('venus')).toBeCloseTo(177.3, 0); + expect(drawnObliquity('uranus')).toBeCloseTo(97.77, 0); + expect(drawnObliquity('pluto')).toBeCloseTo(119.6, 0); + expect(drawnObliquity('earth')).toBeCloseTo(23.44, 0); + }); + + /** + * Where on its drawn sphere a body faces a point, as east longitude and latitude on its map: read + * from the texture coordinates where a ray from that point meets the sphere, so the map's own + * convention is part of what is measured. + */ + function facing(id: string, point: THREE.Vector3): { eastDeg: number; latDeg: number } { + const marker = renderer.members.find((member) => member.id === id)!.marker as THREE.Mesh; + const centre = worldPosition(id); + const towards = point.clone().sub(centre).normalize(); + const radius = marker.userData['radiusAu']; + const hit = new THREE.Raycaster(centre.clone().addScaledVector(towards, radius * 4), towards.clone().negate()).intersectObject(marker)[0]; + return { eastDeg: (hit.uv!.x - 0.5) * 360, latDeg: (hit.uv!.y - 0.5) * 180 }; + } + + function worldPosition(id: string): THREE.Vector3 { + const marker = renderer.members.find((member) => member.id === id)!.marker; + marker.updateWorldMatrix(true, false); + return marker.getWorldPosition(new THREE.Vector3()); + } + + /** Degrees between two longitudes, the short way round. */ + const apart = (a: number, b: number): number => Math.abs(((((a - b) % 360) + 540) % 360) - 180); + + /** Where the IAU puts a body's prime meridian at a TDB date, in the scene. */ + function iauPrimeMeridian(id: string, jdTdb: number): THREE.Vector3 { + const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(SHIPPED.find((body) => body.id === id)!.rotationalElements!, jdTdb); + const w = (primeMeridianDeg * Math.PI) / 180; + const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, { raDeg: poleRaDeg, decDeg: poleDecDeg }); + return new THREE.Vector3(meridian.x, meridian.y, meridian.z); + } + + /** The drawn sphere's longitude 0 on its equator: +X of the sphere as `SphereGeometry` wraps its map. */ + function drawnPrimeMeridian(id: string): THREE.Vector3 { + return new THREE.Vector3(1, 0, 0).applyQuaternion(renderer.members.find((member) => member.id === id)!.marker.quaternion); + } + + it('turns Jupiter at AD 1000 by its W at that date’s TT, 1 574 s after the UT the clock names', () => { + // Espenak and Meeus's ΔT for JD 2086307.5, 1 January 1000 in the Julian calendar, where TT - UT was 23 times what it is today: held + // at today's 69 s, Jupiter was drawn 15 degrees short of its W. + const jdUt = 2086307.5; + renderer.update(jdUt); + expect((drawnPrimeMeridian('jupiter').angleTo(iauPrimeMeridian('jupiter', jdUt + 1574.1 / 86400)) * 180) / Math.PI).toBeLessThan(0.01); + }); + + it('turns Earth by the UT the clock names, which is its turning: at AD 1000 the Sun stands over Horizons’ point', () => { + // Horizons' sub-solar longitude from the Sun (observer quantity 14, TIME_TYPE=UT) on JD 2086455, + // 1.0510 E, is Earth as it was 8.454 minutes before. Turned by the IAU's W at UT + 69.184 s, as it + // was, the drawn face was 2.3 degrees off (2.0 at UT itself); taken at TDB, which turns it ΔT + // (6.6 degrees) further the same way, 8.6. + renderer.update(2086455 - 8.45437443 / 1440); + expect(apart(facing('earth', new THREE.Vector3()).eastDeg, 1.05101)).toBeLessThan(0.15); + }); + + it('lights Earth where the Sun really stands: within 4 degrees of Greenwich at noon UTC', () => { + // The equation of time is all that separates them: on 1 June 2025 it puts the Sun over 0.53 W, + // and the drawn sphere has it over 0.52 W. + renderer.update(JUNE_1_2025_NOON_UTC); + expect(Math.abs(facing('earth', new THREE.Vector3()).eastDeg)).toBeLessThan(4); + }); + + // Horizons' sub-Earth latitude on Saturn (observer quantity 14, from Earth's centre), which is + // planetodetic: taken back to planetocentric through the flattening, it is the angle the rings are + // opened to Earth by. Measured: 26.963, 0.075 and -7.764 degrees drawn, against 26.966, 0.042 and + // -7.813. + const SATURN_FLATTENING = 0.09796; + const RING_OPENING: Array<[date: string, jd: number, planetodeticDeg: number]> = [ + ['16 October 2017, near their widest', 2458042.5, 32.017423], + ['23 March 2025, as Earth crossed their plane', 2460757.5, 0.051359], + ['24 September 2026, the south face turned to Earth', 2461307.5, -9.571756] + ]; + + const saturnRingMesh = (): THREE.Mesh => renderer.members.find((member) => member.id === 'saturn')!.marker.children[0] as THREE.Mesh; + + /** The ring's face normal in the scene, read off its own geometry rather than its transform. */ + function ringNormal(ring: THREE.Mesh): THREE.Vector3 { + ring.updateWorldMatrix(true, false); + return new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld); + } + + for (const [date, jd, planetodeticDeg] of RING_OPENING) { + it(`opens Saturn's rings to Earth as far as Horizons has them on ${date}`, () => { + renderer.update(jd); + const normal = ringNormal(saturnRingMesh()); + const toEarth = worldPosition('earth').sub(worldPosition('saturn')).normalize(); + const openingDeg = (Math.asin(normal.dot(toEarth)) * 180) / Math.PI; + const expectedDeg = (Math.atan((1 - SATURN_FLATTENING) ** 2 * Math.tan((planetodeticDeg * Math.PI) / 180)) * 180) / Math.PI; + expect(Math.abs(openingDeg - expectedDeg)).toBeLessThan(0.1); + }); + } + + it('picks Saturn through its rings', () => { + renderer.update(JUNE_1_2025_NOON_UTC); + const ring = saturnRingMesh(); + const normal = ringNormal(ring); + const inRingPlane = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['position'], 0).transformDirection(ring.matrixWorld); + // Straight down onto the B ring, 100 000 km out: nowhere near the planet itself. + const onRing = worldPosition('saturn').addScaledVector(inRingPlane, 100000 / 149597870.7); + const [hit] = new THREE.Raycaster(onRing.clone().addScaledVector(normal, 0.01), normal.clone().negate()).intersectObjects(renderer.pickableObjects); + expect(hit.object).toBe(ring); + expect(renderer.memberForObject(hit.object)?.id).toBe('saturn'); + }); + + // Horizons' observer quantities 14 and 15 at 2025-06-01 12:00 UTC, from Earth's centre (from the + // Sun's, for Earth): the sub-observer and sub-solar longitude and latitude, east-positive for + // Earth and the Moon and west-positive for Mars and Jupiter, as each is printed. Horizons gives + // each body as it was when the light now arriving left it, so it is drawn that much earlier. Its + // latitudes are planetodetic, on the body's flattened figure, which a sphere does not have, so the + // drawn latitude is put on that figure before they are compared: without it they differ by what + // the flattening makes of them, 0.14 degrees on Earth, 0.26 on Mars and 0.33 on Jupiter. + // + // Measured: every longitude within 0.09 degrees and every latitude within 0.03, but for the + // Moon's face towards Earth, 0.70 and 0.09 out because its mean orbit is (its evection alone is + // 1.27 degrees); its face towards the Sun is within 0.002. Io's face towards Jupiter is 0.012 out: + // with its orbit taken at the clock's UTC and its spin at TDB it was 0.175, the 69 s between them. + const SUB_POINTS: Array<[id: string, observer: string | undefined, lightMinutes: number, west: boolean, flattening: number, observerLon: number, observerLat: number, sunLon: number, sunLat: number, maxObserverDeg: number]> = [ + ['earth', undefined, 8.43351424, false, 1 / 298.257, 1.5855, 22.261204, 1.579501, 22.260426, 0.1], + ['mars', 'earth', 14.13295841, true, 1 - 3376.2 / 3396.19, 307.365389, 21.27653, 269.287887, 25.451264, 0.1], + ['moon', 'earth', 0.02150549, false, 0, 7.256763, -3.462104, 116.285934, 1.503004, 0.8], + ['jupiter', 'earth', 50.70337676, true, 1 - 66854 / 71492, 251.139846, 2.58787, 247.855871, 2.572658, 0.1], + ['io', 'jupiter', 0.02340584, true, 0, 359.964094, -0.002537, 355.673108, 2.26528, 0.05] + ]; + + for (const [id, observer, lightMinutes, west, flattening, observerLon, observerLat, sunLon, sunLat, maxObserverDeg] of SUB_POINTS) { + it(`faces ${observer ?? 'the Sun'} and the Sun with the points Horizons gives on ${id}`, () => { + renderer.update(JUNE_1_2025_NOON_UTC - lightMinutes / 1440); + const seen = facing(id, observer ? worldPosition(observer) : new THREE.Vector3()); + const lit = facing(id, new THREE.Vector3()); + const east = (longitude: number): number => (west ? -longitude : longitude); + const planetodetic = (latDeg: number): number => (Math.atan(Math.tan((latDeg * Math.PI) / 180) / (1 - flattening) ** 2) * 180) / Math.PI; + expect(apart(seen.eastDeg, east(observerLon))).toBeLessThan(maxObserverDeg); + expect(Math.abs(planetodetic(seen.latDeg) - observerLat)).toBeLessThan(maxObserverDeg); + expect(apart(lit.eastDeg, east(sunLon))).toBeLessThan(0.1); + expect(Math.abs(planetodetic(lit.latDeg) - sunLat)).toBeLessThan(0.05); + }); + } +}); + +describe('locked moons across the clock’s window', () => { + // As shipped, pole, W and all: the IAU gives each a W fitted near the present, and its rate is + // not quite its orbit's, nor Iapetus's pole a line for twenty centuries. + const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8')); + const renderer = new SystemOrbitsRenderer( + shipped.filter((body) => ['jupiter', 'saturn', 'uranus', 'neptune', 'europa', 'ganymede', 'callisto', 'mimas', 'rhea', 'iapetus', 'miranda', 'triton', 'proteus'].includes(body.id)), + [] + ); + + /** Degrees between two lines, the way a spin axis and an orbit normal are compared: Miranda turns backwards against the IAU's pole. */ + function linesApartDeg(a: THREE.Vector3, b: THREE.Vector3): number { + return (Math.acos(Math.min(1, Math.abs(a.clone().normalize().dot(b.clone().normalize())))) * 180) / Math.PI; + } + + /** A moon's drawn spin axis and the normal of its drawn orbit line, in the scene's ICRF frame. */ + function axisAndOrbitNormal(id: string): { axis: THREE.Vector3; normal: THREE.Vector3 } { + const moon = renderer.members.find((member) => member.id === id)!.marker; + const line = moon.parent!.children.find((child) => child.name === 'orbit-line')!; + return { axis: new THREE.Vector3(0, 1, 0).applyQuaternion(moon.quaternion), normal: new THREE.Vector3(0, 0, 1).applyQuaternion(line.quaternion) }; + } + + /** East longitude, on its map, of the point on a moon's drawn sphere that faces its planet. */ + function facingPlanet(id: string): number { + const moon = renderer.members.find((member) => member.id === id)!.marker; + // Its position is from the planet, which is its pivot; SphereGeometry wraps u = atan2(z, -x) / 2 pi. + const toPlanet = moon.position.clone().negate().applyQuaternion(moon.quaternion.clone().invert()); + const u = Math.atan2(toPlanet.z, -toPlanet.x) / (2 * Math.PI); + return ((((u - 0.5) * 360) % 360) + 540) % 360 - 180; + } + + it('keeps Proteus, Miranda, Mimas and Iapetus facing their planets at AD 1 and AD 3000', () => { + // Measured: Proteus 2.6 degrees at most over AD 1-3000, Miranda 2.4, Mimas 8.9, Iapetus 16 (9.4 + // of it the lag of the row its orbit is drawn from). On the IAU's own W and Iapetus's straight + // pole they were 146, 23, 49 and 87 degrees at AD 1. + for (const jd of [1721425.5, 2816787.4]) { + renderer.update(jd); + expect(Math.abs(facingPlanet('proteus'))).toBeLessThan(3); + expect(Math.abs(facingPlanet('miranda'))).toBeLessThan(3); + expect(Math.abs(facingPlanet('mimas'))).toBeLessThan(9.5); + expect(Math.abs(facingPlanet('iapetus'))).toBeLessThan(16.5); + } + }); + + it('keeps the axes of Mimas and Iapetus on their drawn orbits’ normals, as a Cassini state holds them, at AD 1, today and AD 3000', () => { + // Measured over AD 1-3000: Mimas 0.44 degrees at most, Iapetus 0.74. With Iapetus's pole on + // its Laplace pole, 8.3 off at every date. + for (const jd of [1721425.5, 2460676.5, 2816787.4]) { + renderer.update(jd); + for (const id of ['mimas', 'iapetus']) { + const { axis, normal } = axisAndOrbitNormal(id); + expect(linesApartDeg(axis, normal)).toBeLessThan(1); + } + } + }); + + it('turns the poles of Europa, Ganymede, Callisto, Rhea, Miranda and Triton round with their drawn nodes, at AD 1, today and AD 3000', () => { + // Each pole goes round on a term of its node's angle, re-rated to the node's drawn rate (see + // `lockedToOrbit`), each node at JPL's current rate. Measured at these dates: at most 0.23 + // degrees (Miranda). On the IAU's rates Rhea is 0.73, Miranda 0.51 and Triton 0.42, and on the + // archived table's node periods Callisto 0.48 and Miranda 0.42. + for (const jd of [1721425.5, 2460676.5, 2816787.4]) { + renderer.update(jd); + for (const id of ['europa', 'ganymede', 'callisto', 'rhea', 'miranda', 'triton']) { + const { axis, normal } = axisAndOrbitNormal(id); + expect(linesApartDeg(axis, normal), id).toBeLessThan(0.25); + } + } + }); + + it('draws Miranda’s orbit, and turns its axis, where Horizons has its orbit in 1601 and 2390', () => { + // Horizons' osculating orbit normal (ura184, ICRF), averaged over three of Miranda's orbits about + // each date; it wobbles 0.01 degrees about that. The drawn node turns at JPL's current 17.787-year + // period (see `nodePeriodYears` in the ETL); on the archived table's 17.727, which the IAU's pole + // was once turned after too, the drawn orbit was 2.1 degrees from Horizons' at both dates and the + // axis 2.4 at 1601. A date this far back is TDB less some two minutes; the node moves 0.004 degrees in that. + const HORIZONS_NORMALS: Array<[jd: number, raDeg: number, decDeg: number]> = [ + [2305813.5, 72.83137, 16.17526], + [2594102.5, 81.27691, 17.43782] + ]; + for (const [jd, raDeg, decDeg] of HORIZONS_NORMALS) { + renderer.update(jd); + const [ra, dec] = [(raDeg * Math.PI) / 180, (decDeg * Math.PI) / 180]; + const horizons = new THREE.Vector3(Math.cos(dec) * Math.cos(ra), Math.cos(dec) * Math.sin(ra), Math.sin(dec)); + const { axis, normal } = axisAndOrbitNormal('miranda'); + expect(linesApartDeg(normal, horizons)).toBeLessThan(0.5); + expect(linesApartDeg(axis, horizons)).toBeLessThan(0.5); + } + }); +}); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 89a009a..4c12f1a 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -1,14 +1,15 @@ import * as THREE from 'three/webgpu'; import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance'; -import { gmForParent } from '../../shared/astro/constants'; import { PlanetAppearance } from '../../shared/astro/planet-appearance'; import { planetTexture } from '../../shared/rendering/procedural-planet-texture'; -import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog'; -import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler'; +import { bodyTexturePath, loadCachedTexture, saturnRing } from '../../shared/rendering/texture-catalog'; +import { isPropagatableOrbit, keplerRates, meanElementsAt, orbitEllipsePoints, positionAtEpoch, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler'; import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; +import { tdbFromUtc } from '../../shared/astro/constants'; import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar'; -import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; +import { BodyRecord, MeanElementRates, OrbitalElements, RotationalElements } from '../../shared/models/body.model'; +import { bodyOrientation, poleFrame } from '../../shared/rendering/body-orientation'; import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing'; import { PolarGridPlane, TetherField } from './grid-plane'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; @@ -46,11 +47,38 @@ const SYSTEM_TETHER_OPACITY = 0.3; /** * Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the - * obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons - * against the ecliptic, so this is their frame. + * obliquity about the shared vernal-equinox axis. The planets' and the Moon's mean elements are + * given against the J2000 ecliptic, so this is their frame. */ const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD); +/** + * Rotation carrying a moon's element frame into the scene: its local Laplace plane where JPL + * gives one, the ecliptic otherwise. {@link poleFrame} builds it from the axes + * `laplacePlaneToEquatorial` sends, so the scene and the ETL's check against Horizons share the + * one conversion. + */ +function moonFrame(body: BodyRecord): THREE.Quaternion { + return body.laplacePole ? poleFrame(body.laplacePole) : ECLIPTIC_FRAME.clone(); +} + +const X_AXIS = new THREE.Vector3(1, 0, 0); +const Z_AXIS = new THREE.Vector3(0, 0, 1); +const scratchTurn = new THREE.Quaternion(); + +/** + * Sets `target` to the rotation carrying an orbit's own plane, periapsis along +X, into the + * scene: the argument of periapsis, then the inclination, then the node, as + * `positionAtTrueAnomaly` turns a point, and then the frame the elements are measured in. + */ +function orientOrbit(target: THREE.Quaternion, elements: OrbitalElements, frame: THREE.Quaternion): THREE.Quaternion { + return target + .copy(frame) + .multiply(scratchTurn.setFromAxisAngle(Z_AXIS, elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD)) + .multiply(scratchTurn.setFromAxisAngle(X_AXIS, elements.inclinationDeg * DEG_TO_RAD)) + .multiply(scratchTurn.setFromAxisAngle(Z_AXIS, elements.argumentOfPeriapsisDeg * DEG_TO_RAD)); +} + /** * Rotation carrying the frame an **exoplanet's** elements are measured in into the scene. * @@ -95,21 +123,18 @@ function colorForKind(kind: SystemMemberKind): THREE.Color { /** Marks orbit lines so the whole layer can be toggled without touching the bodies. */ const ORBIT_LINE_NAME = 'orbit-line'; +/** + * The orbit's ellipse, drawn in its own plane and turned into place by the line's quaternion (see + * {@link orientOrbit}), which `update` sets again each tick: a node and a periapsis that turn cost + * a quaternion rather than a new geometry. The Moon's node goes right round in 18.6 years, so an + * ellipse fixed at one date has the Moon up to 2 sin 5.16° of its distance, 69 000 km, off its own + * line nine years on. + * + * The shape is redrawn by {@link reshapeOrbitLine} as the planets' axes and eccentricities drift. + */ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line { - const points = orbitEllipsePoints(elements); - const positions = new Float32Array(points.length * 3); - const scratch = new THREE.Vector3(); - points.forEach((point, index) => { - // Elements are measured against their source's own reference plane; `frame` rotates that - // plane into the scene's equatorial one. - const { x, y, z } = scratch.set(point.x, point.y, point.z).applyQuaternion(frame); - positions[index * 3] = x; - positions[index * 3 + 1] = y; - positions[index * 3 + 2] = z; - }); - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('position', new THREE.BufferAttribute(ellipseInItsPlane(elements, new Float32Array((ORBIT_LINE_SEGMENTS + 1) * 3)), 3)); const material = new THREE.LineBasicMaterial({ color: colorForKind(kind), @@ -119,9 +144,50 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame const line = new THREE.Line(geometry, material); line.name = ORBIT_LINE_NAME; + line.userData = { semiMajorAxisAu: elements.semiMajorAxisAu, eccentricity: elements.eccentricity }; + orientOrbit(line.quaternion, elements, frame); return line; } +const ORBIT_LINE_SEGMENTS = 128; + +/** The orbit's ellipse in its own plane, periapsis along +X, written into `positions`. */ +function ellipseInItsPlane(elements: OrbitalElements, positions: Float32Array): Float32Array { + orbitEllipsePoints({ ...elements, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0 }, ORBIT_LINE_SEGMENTS).forEach((point, index) => { + positions[index * 3] = point.x; + positions[index * 3 + 1] = point.y; + positions[index * 3 + 2] = point.z; + }); + return positions; +} + +/** + * How far, in AU, an orbit's drawn ellipse may be from its current one before it is drawn again: + * well under the 128 chords' own sag from the true curve, at most 0.00055 AU for Mars and 0.0032 + * for Saturn, near aphelion, where points spaced evenly in true anomaly lie furthest apart. + */ +const ORBIT_RESHAPE_AU = 1e-4; + +/** + * Draws an orbit line's ellipse again once the axis and eccentricity it was drawn with have drifted + * from `elements`' by more than {@link ORBIT_RESHAPE_AU}. Standish's rates move Saturn's + * eccentricity 0.0064 in twenty centuries, and left at J2000's, the line passed 0.056 AU, 8.4 + * million km, from Saturn at AD 1; Jupiter 0.016 AU there, Pluto 0.021 at AD 3000. The moons' and + * the exoplanets' elements carry no such rates, so their lines are drawn once. + */ +function reshapeOrbitLine(line: THREE.Line, elements: OrbitalElements): void { + const drawn = line.userData as { semiMajorAxisAu: number; eccentricity: number }; + const driftAu = Math.abs(elements.semiMajorAxisAu - drawn.semiMajorAxisAu) + elements.semiMajorAxisAu * Math.abs(elements.eccentricity - drawn.eccentricity); + if (driftAu <= ORBIT_RESHAPE_AU) { + return; + } + const position = line.geometry.getAttribute('position') as THREE.BufferAttribute; + ellipseInItsPlane(elements, position.array as Float32Array); + position.needsUpdate = true; + line.geometry.computeBoundingSphere(); + line.userData = { semiMajorAxisAu: elements.semiMajorAxisAu, eccentricity: elements.eccentricity }; +} + /** * A marker sphere, surfaced with the body's own photograph where one has ever been taken, and * with a texture derived from its measurements where none has — and lit by its star either way, @@ -131,21 +197,56 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame * drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across * was indistinguishable from its average colour and, once the camera closed in, was a blur. A * marker can now fill the frame, so it takes the real image at the size the detail page uses. + * + * A derived texture is not painted here but handed to `deferSurface`, which paints it after the + * system is built: at about 4.4 ms each, the twenty bodies the solar system gained with its dwarf + * planets and smaller moons lengthened the task that enters it from 78-94 ms to 177-228. Until + * then the body is its kind's flat colour. + * + * A photograph is handed to `deferPhotograph`, which puts it on the body once it has loaded, one a + * frame: a texture is copied to the GPU in the first frame that draws it, and the 28 maps, which + * arrive within 40 ms of each other, made that one frame a 160-210 ms task on entering the Sun's + * system (copyExternalImageToTexture, about 38 megapixels of JPEG: nine maps at 2048 by 1024, the + * Sun's among them, Jupiter's at 3840 by 1920, and eighteen smaller). + * + * Every marker is the one unit sphere, {@link MARKER_SPHERE}, scaled to the body's radius, which + * it also keeps as `userData.radiusAu`: built one a body, the 38 spheres of the Sun's system took + * 12 ms of the 15 ms the renderer took to build and, with their upload, made a return to the + * system a long task of 52 to 70 ms, where the 18 bodies before had made none. */ -function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh { - const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS); +function buildMarker( + id: string | undefined, + kind: SystemMemberKind, + radiusKm: number | undefined, + appearance: PlanetAppearance | undefined, + deferSurface: (paint: () => void) => void, + deferPhotograph: (material: THREE.MeshStandardMaterial, texture: THREE.Texture) => void +): THREE.Mesh { const photograph = id ? bodyTexturePath(id) : undefined; - // 128 by 64 for the derived texture, not the detail page's 512 by 256: that size costs about - // 60 ms a body on the main thread, 360 ms on entering a six-planet system, for a disc that is a - // few pixels across until the camera is on top of it. - const map = photograph ? loadCachedTexture(photograph) : appearance ? planetTexture(appearance, { width: 128, height: 64 }) : undefined; + // null, not undefined, until there is one: three warns "parameter 'map' has value of + // undefined" for every body built so, eleven of them on entering the Sun's system. const material = new THREE.MeshStandardMaterial({ - map, - color: map ? 0xffffff : colorForKind(kind), + map: null, + color: colorForKind(kind), roughness: 1, metalness: 0 }); - return new THREE.Mesh(geometry, material); + if (photograph) { + deferPhotograph(material, loadCachedTexture(photograph)); + } else if (appearance) { + deferSurface(() => { + // 128 by 64, not the detail page's 512 by 256: that size costs about 60 ms a body on the + // main thread, for a disc that is a few pixels across until the camera is on top of it. + material.map = planetTexture(appearance, { width: 128, height: 64 }); + material.color.set(0xffffff); + material.needsUpdate = true; + }); + } + const marker = new THREE.Mesh(MARKER_SPHERE, material); + const radiusAu = bodyMarkerRadiusAu(radiusKm); + marker.scale.setScalar(radiusAu); + marker.userData = { radiusAu }; + return marker; } /** @@ -182,6 +283,8 @@ function starLight(temperatureK: number | null | undefined): THREE.PointLight { */ const MARKER_WIDTH_SEGMENTS = 64; const MARKER_HEIGHT_SEGMENTS = 32; +/** Shared by every marker of every system, so it is never disposed; see `buildMarker`. */ +const MARKER_SPHERE = new THREE.SphereGeometry(1, MARKER_WIDTH_SEGMENTS, MARKER_HEIGHT_SEGMENTS); /** * A drawn radius, in Earth radii, for an exoplanet that has a mass and no measured radius — 1 076 @@ -203,31 +306,21 @@ 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. + * How a body the IAU gives no rotational elements for is turned at a given date — Eris, Haumea, + * Makemake and Nereid, whose periods are measured (Makemake's only to a factor of two, see its + * spec in `fetchSolarSystem.ts`) and whose poles are not: at its own sidereal rate, about + * its orbit's normal, backwards for a negative period. None of them has an obliquity, so none is + * applied. The phase is arbitrary: each body starts at its elements' epoch in the shortest + * rotation of +Y onto its axis, and turns from there. Exoplanets have no published rotation at + * all, and are left still. * - * The obliquity fixes how far the pole leans from the orbit normal, and nothing more: which way - * it leans needs the pole's right ascension, which the Horizons pages this reads do not carry. The - * lean is taken about the orbit's ascending node because that is the one line the elements name, - * not because the data says so — so the tilt is real and its azimuth is not. Likewise the phase: - * each body starts at its elements' epoch (2025-01-01 here) in an arbitrary orientation, the - * shortest rotation of +Y onto its axis, and turns from there. The rate and the sense are real; - * the face towards the camera is not. - * - * Horizons states a retrograde spin twice over, in two conventions: an obliquity past 90 degrees - * (Venus 177.3, Uranus 97.8) and a negative rate. Either one alone turns the body backwards, and - * both together cancel into a forward turn — which is how Venus and Uranus were drawn. Where an - * obliquity is given it carries the sense, and the period is taken as a magnitude; the sign of the - * period is only read for a body with no obliquity at all. + * Every other body is turned by {@link bodyOrientation}. */ -function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, obliquityDeg: number | undefined, epochJd: number): THREE.Quaternion { +function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, daysSinceEpoch: 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 ?? 0) * DEG_TO_RAD) - .applyQuaternion(frame); - const period = obliquityDeg === undefined ? rotationPeriodHours : Math.abs(rotationPeriodHours); - const turns = ((epochJd - elements.epochJd) * HOURS_PER_DAY) / period; + const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination)).applyQuaternion(frame); + const turns = (daysSinceEpoch * HOURS_PER_DAY) / rotationPeriodHours; return new THREE.Quaternion() .setFromUnitVectors(SPIN_AXIS, axis) .multiply(new THREE.Quaternion().setFromAxisAngle(SPIN_AXIS, turns * 2 * Math.PI)); @@ -237,27 +330,34 @@ interface TrackedTopLevelBody { id: string; kind: SystemMemberKind; elements: OrbitalElements; - gmAu3PerDay2: number; + rates: MeanElementRates; marker: THREE.Mesh; + orbitLine: THREE.Line; /** Rotation from this body's own element frame into the scene's equatorial one. */ 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; + rotationalElements?: RotationalElements; } interface TrackedMoon { id: string; elements: OrbitalElements; - gmAu3PerDay2: number; + rates: MeanElementRates; marker: THREE.Mesh; + orbitLine: THREE.Line; frame: THREE.Quaternion; pivot: THREE.Group; parentId: string; rotationPeriodHours?: number; - obliquityDeg?: number; + rotationalElements?: RotationalElements; + /** + * Where the moon and its planet go round a barycentre outside the planet (Charon): the moon's + * mass over the planet's, and the planet's own small orbit round that point. + */ + barycentre?: { massRatio: number; parentOrbitLine: THREE.Line }; } /** @@ -277,10 +377,12 @@ export class SystemOrbitsRenderer { */ readonly referenceFrame: THREE.Quaternion; /** - * Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost - * orbit — is the widest thing the system draws, so it is what the camera has to frame. + * How far (AU) from the star the system draws anything, or 0 where it draws nothing: what the + * camera has to frame. The reference grid's outer ring, which runs 15 per cent past the largest + * semi-major axis, unless an eccentric orbit reaches further at its aphelion — Eris's, 97.7 AU, + * does past the solar system's 80 AU ring, and some orbit does in 303 of the 1 190 exoplanet systems. */ - readonly gridOuterRadiusAu: number; + readonly outermostRadiusAu: number; private readonly topLevelBodies: TrackedTopLevelBody[] = []; private readonly moons: TrackedMoon[] = []; @@ -292,6 +394,22 @@ export class SystemOrbitsRenderer { * following them each tick costs no allocation at all. */ private tetherPoints: readonly THREE.Vector3[] = []; + /** Derived surfaces still to paint, one a task, once the constructor is done; see `buildMarker`. */ + private readonly surfacesToPaint: Array<() => void> = []; + private surfaceTimer?: ReturnType; + private readonly deferSurface = (paint: () => void): void => { + this.surfacesToPaint.push(paint); + this.surfaceTimer ??= setTimeout(this.paintNextSurface, 0); + }; + private readonly paintNextSurface = (): void => { + this.surfacesToPaint.shift()?.(); + this.surfaceTimer = this.surfacesToPaint.length > 0 ? setTimeout(this.paintNextSurface, 0) : undefined; + }; + /** Photographs still to put on their bodies, one a frame once loaded; see `buildMarker`. */ + private readonly photographsToShow: Array<{ material: THREE.MeshStandardMaterial; texture: THREE.Texture }> = []; + private readonly deferPhotograph = (material: THREE.MeshStandardMaterial, texture: THREE.Texture): void => { + this.photographsToShow.push({ material, texture }); + }; constructor( bodies: readonly BodyRecord[], @@ -310,10 +428,11 @@ export class SystemOrbitsRenderer { const members: SystemMember[] = []; const topLevelBodiesById = new Map(); - const topLevelAxes = [ - ...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu), - ...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!) - ].filter((axis) => Number.isFinite(axis) && axis > 0); + const topLevelOrbits = [ + ...bodies.filter((body) => !body.parentBodyId).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu, eccentricity: orbit.eccentricity })), + ...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map(({ orbit }) => ({ axis: orbit.semiMajorAxisAu!, eccentricity: orbit.eccentricity ?? 0 })) + ].filter(({ axis }) => Number.isFinite(axis) && axis > 0); + const topLevelAxes = topLevelOrbits.map(({ axis }) => axis); this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0; for (const body of bodies) { @@ -328,7 +447,16 @@ 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), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg }); + const tracked = this.addTopLevelBody(body.id, kind, body.orbit, body.rates, body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, elements: body.rotationalElements }); + if (body.id === 'saturn') { + // A child of the sphere, so it lies in the equator the IAU pole turns the sphere into and + // is scaled with it where the marker is held to its pixel floor. Jupiter's, Uranus's and + // Neptune's rings are left out: dark, narrow or dusty, they are too faint to see here. + // In the sphere's own units, its radius being 1. + const ring = saturnRing(body.radiusKm, 1); + tracked.marker.add(ring); + this.trackDisposable(ring.geometry, ring.material as THREE.Material); + } members.push({ id: body.id, kind, marker: tracked.marker }); } @@ -341,7 +469,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), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg }); + const moon = this.addMoon(body.id, body.orbit, body.rates, body.radiusKm, parentTracked, moonFrame(body), appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, elements: body.rotationalElements }, body.massRatio); members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id }); } @@ -359,26 +487,26 @@ export class SystemOrbitsRenderer { const elements = resolveOrbitalElements(exoplanet.orbit); const radiusEarth = exoplanet.radiusEarth ?? radiusFromMassEarth(exoplanet.massEarth); const radiusKm = radiusEarth ? radiusEarth * EARTH_RADIUS_KM : undefined; - // Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and - // most exoplanet hosts are red dwarfs a fraction of the Sun's mass. + // Not the Sun's: that assumes a solar-mass host for every system, and most exoplanet hosts + // are red dwarfs a fraction of the Sun's mass. const gm = resolveGravitationalParameter({ semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu, periodDays: exoplanet.periodDays, hostStarMassSolar: exoplanet.hostStarMassSolar }); - const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar)); + const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, keplerRates(elements.semiMajorAxisAu, gm), radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar)); members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker }); } this.members = members; // Which plane the system is read against follows from where its elements came from. Only the - // Sun has Horizons bodies and no system has both, so this is a choice between the two rather + // Sun has JPL bodies and no system has both, so this is a choice between the two rather // than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise. this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame; const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu); - this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0; + this.outermostRadiusAu = Math.max(rings.length > 0 ? rings[rings.length - 1] : 0, ...topLevelOrbits.map(({ axis, eccentricity }) => axis * (1 + eccentricity))); if (rings.length > 0) { this.grid = new PolarGridPlane({ ringRadii: rings, @@ -406,14 +534,24 @@ export class SystemOrbitsRenderer { this.object.add(starLight(hostTemperatureK)); } - /** Recomputes every marker's position for the given Julian date. Call once per tick. */ + /** + * Recomputes every marker's position for the given Julian date, UTC as the map's clock gives it: + * the orbits are taken at its TDB, as the spins are. Call once per tick. + */ update(epochJd: number): void { + this.showNextPhotograph(); + const jdTdb = tdbFromUtc(epochJd); for (const body of this.topLevelBodies) { - const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd); + const current = meanElementsAt(body.elements, body.rates, jdTdb); + const orbital = positionAtEpoch(current); 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, epochJd)); + orientOrbit(body.orbitLine.quaternion, current, body.frame); + reshapeOrbitLine(body.orbitLine, current); + if (body.rotationalElements) { + bodyOrientation(body.rotationalElements, epochJd, body.marker.quaternion, body.id === 'earth'); + } else if (body.rotationPeriodHours) { + body.marker.quaternion.copy(spinFor(current, body.frame, body.rotationPeriodHours, jdTdb - body.elements.epochJd)); } } @@ -423,10 +561,22 @@ export class SystemOrbitsRenderer { continue; } moon.pivot.position.copy(parent.position); - const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd); + const current = meanElementsAt(moon.elements, moon.rates, jdTdb); + const orbital = positionAtEpoch(current); 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, epochJd)); + orientOrbit(moon.orbitLine.quaternion, current, moon.frame); + if (moon.barycentre) { + // The planet's elements place the pair's barycentre, which is where the pivot is: the + // planet sits the moon's share of their separation back from it, the moon the rest out. + const { massRatio, parentOrbitLine } = moon.barycentre; + parent.marker.position.copy(parent.position).addScaledVector(moon.marker.position, -massRatio / (1 + massRatio)); + moon.marker.position.multiplyScalar(1 / (1 + massRatio)); + parentOrbitLine.quaternion.copy(moon.orbitLine.quaternion); + } + if (moon.rotationalElements) { + bodyOrientation(moon.rotationalElements, epochJd, moon.marker.quaternion); + } else if (moon.rotationPeriodHours) { + moon.marker.quaternion.copy(spinFor(current, moon.frame, moon.rotationPeriodHours, jdTdb - moon.elements.epochJd)); } } @@ -435,9 +585,24 @@ export class SystemOrbitsRenderer { this.tethers?.setTargets(this.tetherPoints); } - /** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */ + /** Puts the first photograph that has loaded on its body: one texture for the GPU a frame. */ + private showNextPhotograph(): void { + const index = this.photographsToShow.findIndex(({ texture }) => texture.image); + if (index < 0) { + return; + } + const [{ material, texture }] = this.photographsToShow.splice(index, 1); + material.map = texture; + material.color.set(0xffffff); + material.needsUpdate = true; + } + + /** + * Looks up which system member a marker object belongs to (e.g. from a raycast hit), or a part + * of one: a ray through Saturn's rings picks Saturn. + */ memberForObject(object: THREE.Object3D): SystemMember | undefined { - return this.members.find((member) => member.marker === object); + return this.members.find((member) => member.marker === object || member.marker === object.parent); } /** All marker objects, for raycasting. */ @@ -461,10 +626,15 @@ export class SystemOrbitsRenderer { } dispose(): void { + clearTimeout(this.surfaceTimer); + this.surfacesToPaint.length = 0; + this.photographsToShow.length = 0; this.grid?.dispose(); this.tethers?.dispose(); for (const { geometry, material } of this.disposables) { - geometry.dispose(); + if (geometry !== MARKER_SPHERE) { + geometry.dispose(); + } material.dispose(); } // Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old @@ -479,19 +649,19 @@ export class SystemOrbitsRenderer { id: string, kind: SystemMemberKind, elements: OrbitalElements, - gmAu3PerDay2: number, + rates: MeanElementRates, radiusKm: number | undefined, frame: THREE.Quaternion, appearance?: PlanetAppearance, - rotation?: { periodHours?: number; obliquityDeg?: number } + rotation?: { periodHours?: number; elements?: RotationalElements } ): TrackedTopLevelBody { const orbitLine = buildOrbitLine(elements, kind, frame); - const marker = buildMarker(id, kind, radiusKm, appearance); + const marker = buildMarker(id, kind, radiusKm, appearance, this.deferSurface, this.deferPhotograph); this.object.add(orbitLine, marker); 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(), rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg }; + const tracked: TrackedTopLevelBody = { id, kind, elements, rates, marker, orbitLine, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, rotationalElements: rotation?.elements }; this.topLevelBodies.push(tracked); return tracked; } @@ -499,22 +669,36 @@ export class SystemOrbitsRenderer { private addMoon( id: string, elements: OrbitalElements, - gmAu3PerDay2: number, + rates: MeanElementRates, radiusKm: number | undefined, parent: TrackedTopLevelBody, frame: THREE.Quaternion, appearance?: PlanetAppearance, - rotation?: { periodHours?: number; obliquityDeg?: number } + rotation?: { periodHours?: number; elements?: RotationalElements }, + massRatio?: number ): TrackedMoon { const pivot = new THREE.Group(); const orbitLine = buildOrbitLine(elements, 'moon', frame); - const marker = buildMarker(id, 'moon', radiusKm, appearance); + const marker = buildMarker(id, 'moon', radiusKm, appearance, this.deferSurface, this.deferPhotograph); pivot.add(orbitLine, marker); this.object.add(pivot); 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, rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg }; + let barycentre: TrackedMoon['barycentre']; + if (massRatio !== undefined) { + // Both orbits are the relative one, scaled: the moon's by the planet's share of the mass, + // the planet's by the moon's share and turned half round, since it is always opposite. + // Charon's then spans 17 460 km of radius, Pluto's 2 131, and neither passes through Pluto. + orbitLine.scale.setScalar(1 / (1 + massRatio)); + const parentOrbitLine = buildOrbitLine(elements, parent.kind, frame); + parentOrbitLine.scale.setScalar(-massRatio / (1 + massRatio)); + pivot.add(parentOrbitLine); + this.trackDisposable(parentOrbitLine.geometry, parentOrbitLine.material as THREE.Material); + barycentre = { massRatio, parentOrbitLine }; + } + + const moon: TrackedMoon = { id, elements, rates, marker, orbitLine, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, rotationalElements: rotation?.elements, barycentre }; this.moons.push(moon); return moon; } diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts index 1438892..a0ce7ee 100644 --- a/src/app/features/hud/hud-dock.component.spec.ts +++ b/src/app/features/hud/hud-dock.component.spec.ts @@ -1,11 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { BookmarksStore } from '../../shared/state/bookmarks.store'; +import { TimeStore } from '../../shared/state/time.store'; import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component'; +// jsdom has no matchMedia, which the dock reads once, at import: this one answers from `viewport`. +const viewport = vi.hoisted(() => { + const state = { wide: true }; + window.matchMedia = (() => ({ get matches() { return state.wide; } })) as unknown as typeof window.matchMedia; + return state; +}); + class EmptyDataLoaderService { loadStars() { return Promise.resolve({ stars: [], positions: new Float32Array(0) }); @@ -76,6 +84,17 @@ describe('HudDockComponent', () => { expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']); }); + it('offers the clock alone, as a Clock tab, to a surface with no layers', () => { + fixture.componentRef.setInput('clock', true); + fixture.componentRef.setInput('defaultTab', 'display'); + fixture.detectChanges(); + expect(tabNames()).toEqual(['Search', 'Bookmarks', 'Clock']); + const panel = host().querySelector('#dock-panel-display')!; + expect(panel.querySelector('[role="radiogroup"][aria-label="Clock rate"]')).not.toBeNull(); + expect(panel.querySelector('#clock-date')).not.toBeNull(); + expect(panel.textContent).not.toContain('Layers'); + }); + it('opens the default tab on mount and renders the readout from its inputs', () => { setReadout(); fixture.componentRef.setInput('defaultTab', 'readout'); @@ -141,7 +160,7 @@ describe('HudDockComponent', () => { fixture.componentRef.setInput('defaultTab', 'display'); fixture.detectChanges(); const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`); - expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']); + expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false', 'Backwards=false']); }); it('says how to keep a place, rather than showing an empty list', () => { @@ -366,4 +385,168 @@ describe('HudDockComponent', () => { expect(host().querySelector('#route-to')!.value).toBe('Sirius'); expect(host().querySelector('#route-range')!.value).toBe('6'); }); + + describe('clock', () => { + let time: TimeStore; + + beforeEach(() => { + time = TestBed.inject(TimeStore); + fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY); + fixture.componentRef.setInput('defaultTab', 'display'); + fixture.detectChanges(); + }); + + afterEach(() => vi.unstubAllEnvs()); + + function button(name: string): HTMLButtonElement { + return [...host().querySelectorAll('#dock-panel-display button')].find((b) => b.textContent?.trim() === name)!; + } + + function radio(name: string): HTMLInputElement { + return [...host().querySelectorAll('#dock-panel-display label')].find((l) => l.textContent?.trim() === name)!.querySelector('input')!; + } + + it('runs the same rates backwards, and keeps the direction when the rate changes', () => { + button('Backwards').click(); + fixture.detectChanges(); + expect(time.rate()).toBe(-1); + expect(button('Backwards').getAttribute('aria-pressed')).toBe('true'); + // Still real time: the direction is not a fifth rate. + expect(radio('Real time').checked).toBe(true); + + radio('1 d/s').click(); + fixture.detectChanges(); + expect(time.rate()).toBe(-86_400); + expect(radio('1 d/s').checked).toBe(true); + + button('Backwards').click(); + fixture.detectChanges(); + expect(time.rate()).toBe(86_400); + expect(button('Backwards').getAttribute('aria-pressed')).toBe('false'); + }); + + it('opens the date field on the clock’s date, named and held to the window the elements hold for', () => { + const field = host().querySelector('#clock-date')!; + + expect(field.type).toBe('datetime-local'); + expect(field.value).toBe(time.date().toISOString().slice(0, 16)); + expect(host().querySelector('label[for="clock-date"]')?.textContent?.trim()).toBe('Date (UTC)'); + expect(field.min).toBe('0001-01-01T00:00'); + expect(field.max).toBe('3000-01-01T00:00'); + expect(host().querySelector(`#${field.getAttribute('aria-describedby')}`)?.textContent).toContain('AD 1 to AD 3000'); + }); + + it('jumps the clock to the date submitted, read as UTC', () => { + // Five and a half hours from UTC, so a field read as local time lands elsewhere: in UTC itself, + // where CI runs, the two readings are the same instant and this could not tell them apart. + vi.stubEnv('TZ', 'Asia/Kolkata'); + const field = host().querySelector('#clock-date')!; + field.value = '2020-12-21T18:00'; + button('Go').click(); + fixture.detectChanges(); + + expect(time.date().toISOString().slice(0, 16)).toBe('2020-12-21T18:00'); + expect(time.atNow()).toBe(false); + + // Back to now puts the field back on the present too, not on the date left behind. + button('Back to now').click(); + fixture.detectChanges(); + expect(time.atNow()).toBe(true); + expect(host().querySelector('#clock-date')!.value).toBe(time.date().toISOString().slice(0, 16)); + }); + + it('folds the sheet away on a phone once a date is set, so the system it covered can be seen', () => { + viewport.wide = false; + try { + host().querySelector('#clock-date')!.value = '2020-12-21T18:00'; + button('Go').click(); + fixture.detectChanges(); + expect(host().querySelector('#dock-panel-display')).toBeNull(); + } finally { + viewport.wide = true; + } + }); + + it('hands focus to the tab that folded, not to the page, so Enter opens the panel again', () => { + viewport.wide = false; + try { + const field = host().querySelector('#clock-date')!; + field.focus(); + field.value = '2020-12-21T18:00'; + button('Go').click(); + fixture.detectChanges(); + expect(document.activeElement?.id).toBe('dock-tab-display'); + } finally { + viewport.wide = true; + } + }); + + it('keeps the date strip on screen on a phone: the tabs give way to it, and scroll', () => { + // At 360 px the system view's five tabs take 397 px, and pushed the strip past the right + // edge, where nothing scrolls: after Go on a phone the date was nowhere on screen. + fixture.componentRef.setInput('date', '2020-12-21'); + fixture.componentRef.setInput('range', '417 AU'); + fixture.detectChanges(); + const tabs = host().querySelector('[role="tablist"]')!.classList; + expect(tabs.contains('min-w-0') && tabs.contains('overflow-x-auto')).toBe(true); + // Its own scrollbar, where the browser draws one, thin and dark: a desktop's default was a + // light bar 15 px tall across the dock. + expect(tabs.contains('scheme-dark') && tabs.contains('[scrollbar-width:thin]')).toBe(true); + expect(host().querySelector('[data-testid="hud-date"]')!.classList.contains('shrink-0')).toBe(true); + // The range keeps its width where it is shown, or '417 AU' wraps and the row grows 20 px; on a + // phone it is not shown, where at the present it took the Display tab out of sight. + const range = [...host().querySelectorAll('p')].find((p) => p.textContent?.includes('Range'))!.classList; + expect(range.contains('shrink-0') && range.contains('max-sm:hidden')).toBe(true); + }); + + it('brings the tab that matters back into view once the date strip has narrowed the tabs', () => { + // jsdom lays nothing out; what is checked is which tab is asked to be in view, and when. + const scrolled: string[] = []; + HTMLElement.prototype.scrollIntoView = function (this: HTMLElement) { + scrolled.push(this.id); + }; + try { + // On a phone: the tab focus went back to, which after Go sat wholly out of sight. + viewport.wide = false; + host().querySelector('#clock-date')!.value = '2020-12-21T18:00'; + button('Go').click(); + fixture.detectChanges(); + scrolled.length = 0; + fixture.componentRef.setInput('date', '2020-12-21'); + fixture.detectChanges(); + expect(scrolled).toEqual(['dock-tab-display']); + // On a wider window, where the panel stays open: its tab, focus being in the panel. + viewport.wide = true; + fixture.componentRef.setInput('date', ''); + fixture.detectChanges(); + tab('Display').click(); + fixture.detectChanges(); + host().querySelector('#clock-date')!.focus(); + scrolled.length = 0; + fixture.componentRef.setInput('date', '2020-12-21'); + fixture.detectChanges(); + expect(scrolled).toEqual(['dock-tab-display']); + } finally { + viewport.wide = true; + delete (HTMLElement.prototype as Partial).scrollIntoView; + } + }); + + it('keeps the panel open on a wide screen, where it covers little of the scene', () => { + host().querySelector('#clock-date')!.value = '2020-12-21T18:00'; + button('Go').click(); + fixture.detectChanges(); + expect(host().querySelector('#dock-panel-display')).not.toBeNull(); + }); + + it('fills the date field again with the clock’s date when the panel is opened again', () => { + time.setDate(new Date('2020-12-21T18:00Z')); + fixture.componentInstance.toggleTab('display'); + fixture.detectChanges(); + fixture.componentInstance.toggleTab('display'); + fixture.detectChanges(); + + expect(host().querySelector('#clock-date')!.value).toBe('2020-12-21T18:00'); + }); + }); }); diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index fc00fcc..a7ce49c 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -1,4 +1,5 @@ import { + afterRenderEffect, ChangeDetectionStrategy, Component, computed, @@ -13,7 +14,7 @@ import { } from '@angular/core'; import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store'; -import { TIME_RATES, TimeStore } from '../../shared/state/time.store'; +import { CLOCK_WINDOW, TIME_RATES, TimeStore } from '../../shared/state/time.store'; import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component'; import { SearchComponent } from '../search/search.component'; import { @@ -268,34 +269,36 @@ function isWideViewport(): boolean { aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3" > -

Layers

-
- @for (layer of layers; track layer.key) { - - } -
+ @if (display()) { +

Layers

+
+ @for (layer of layers; track layer.key) { + + } +
+ } -

Clock

+

Clock

+ @for (rate of timeRates; track rate.secondsPerSecond) { } + @if (!time.atNow()) { }
+ +
+ + + + +

+ AD 1 to AD 3000, where the planets’ elements hold. +

+
} } @@ -364,7 +416,15 @@ function isWideViewport(): boolean { }
-
+ +
@for (tab of tabs(); track tab) {