diff --git a/.junie/plans/nasa-star-map.md b/.junie/plans/nasa-star-map.md index c5743f2..bf28aba 100644 --- a/.junie/plans/nasa-star-map.md +++ b/.junie/plans/nasa-star-map.md @@ -135,6 +135,8 @@ interface DeepSkyRecord { - `SystemOrbitsRenderer` — draws orbit ellipses and planet/exoplanet markers for the currently focused star system, using the Kepler propagator. - `KeplerPropagator` (`shared/astro/kepler.ts`) — pure function(s) converting `OrbitalElements` + epoch → Cartesian position; independently unit-testable. - `BodyDetailSceneComponent` — separate route/component with its own dedicated scene for a close-up view of one selected body, plus an `InfoPanelComponent` showing its data. +- `planet-appearance.ts` / `stellar.ts` (`shared/astro/`) — derives a host star's luminosity from its catalogued magnitude and distance, a planet's equilibrium temperature and bulk density from that, and a class of world from those. +- `procedural-planet-texture.ts` (`shared/rendering/`) — paints an equirectangular surface from that derivation: zonal bands for a fluid envelope, fractal terrain for a solid one, polar caps sized by temperature. A pure function over a byte array, no canvas. - `SearchComponent` — text search across `stars-index.json`, `bodies.json`, `exoplanets.json`; on match, dispatches a navigation action. - `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galactic' | 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view. - `MilkyWayRenderer` / `milky-way-model.ts` — the Galaxy itself as an instanced particle cloud scattered around the structural model in `shared/astro/galaxy.ts`, crossfaded against the catalogued star field by camera distance. @@ -297,3 +299,10 @@ The same plane-and-tether reading aid the outer scales got, applied to a single - Add `systemGridRingsAu`: ring radii snapped to a 1-2-5 ladder so a distance can be read off, at any of the four orders of magnitude real systems span. - Draw the grid and the body tethers in the system's own reference plane — the ecliptic for the solar system, the plane of the sky otherwise — dashed, so it is never mistaken for an orbit. - Frame the camera against that same plane, so an exoplanet system is presented face-on rather than edge-on. + +### ✓ Step 9: Give every body a surface +Real photography where it exists, and a surface reasoned from measurements where it does not. +- Derive host-star luminosity from apparent magnitude, parallax distance and a bolometric correction; validate against published values for real stars. +- Derive equilibrium temperature and bulk density from it, and classify each world by size, temperature and density; validate against the solar system's own bodies. +- Paint the surface procedurally from that class, seeded per body so it is stable between visits, and apply it in both the body-detail view and the system-view markers. +- State the derivation and its limits on screen, next to the measurements it rests on. diff --git a/README.md b/README.md index cbf8a2c..73191d1 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ drop line from each body, so eccentricity and inclination read against a circula instead of having to be inferred from a shape in space. **Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet, -with real photography where NASA/ESA/USGS imagery exists. +with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's +own measurements where it does not. See "On surfaces that were never photographed" below. **Search** — name search across stars, solar-system bodies and exoplanets, navigating to the same place an in-scene click would. @@ -87,6 +88,48 @@ same place an in-scene click would. - **No backend.** Every dataset is baked at build time into `src/assets/data/` and served as a static asset. Nothing queries an astronomy API at runtime. +### 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. + +Those bodies get a surface reasoned from what *has* been measured, in a chain that is worth +following because every link is standard: + +1. The host star's **luminosity** comes from its catalogued apparent magnitude and its + parallax distance — that pair is exactly an absolute magnitude — plus a bolometric correction + for its spectral class. The correction is not optional: an M dwarf radiates most of its light + in the infrared, so its visual magnitude understates it by more than tenfold, and M dwarfs are + what most nearby planet hosts are. Good to about a factor of two, which matters less than it + sounds: temperature goes as the fourth root. +2. Luminosity and the planet's semi-major axis give its **equilibrium temperature**, the standard + blackbody balance. Checked against the solar system it lands on Earth 255 K, Jupiter 112 K, + Neptune 46 K — all within a kelvin or two of published values — and puts 51 Pegasi b at + 1227 K against a published 1200. +3. Published mass and radius give **bulk density**, which is the difference between a ball of + iron, of rock, of water and of hydrogen. +4. Size fixes the family, temperature the state within it, and density overrides both at the + extremes. That yields a class — molten, scorched, iron-rich, rocky, temperate, ice, sub- + Neptune, ice giant, gas giant, hot gas giant — each with a palette reasoned from its chemistry. + Methane absorbs red light, which is why the ice giants are blue; ammonia cloud tops are cream + and ochre; silicate cloud decks over a glowing interior are why hot Jupiters are drawn deep red. +5. The surface is then painted from that class: **zonal bands** for a body with a fluid envelope, + because a rapidly rotating atmosphere organises into them, and fractal **terrain** for one + with a solid surface. Polar caps grow and shrink with the derived temperature. + +The generator samples three-dimensional noise along the sphere rather than a flat field, so +there is no seam to stitch at the antimeridian and no pinching at the poles, and it writes into +a plain byte array rather than a canvas — which makes it a pure function with no DOM to depend on. +Each body's surface is seeded from its own id, so it looks the same on every visit. + +The limits are worth stating. Equilibrium temperature ignores greenhouse warming and internal +heat, which is why Venus comes out at 300 K against a real surface of 737 K, and why Io — kept +molten by tidal heating — classifies as ice. Luminosity classes are often missing from the star +catalogue, so a red giant read as a dwarf will come out too bright. And the surfaces are +illustrations throughout: the info panel says so on every body that has one, next to the +measurements it was reasoned from. + ### On the Galaxy model Every other dataset here is measured. The Galaxy is the exception, and not for want of trying: diff --git a/e2e/camera-flight.spec.ts b/e2e/camera-flight.spec.ts index 6746974..ae73f28 100644 --- a/e2e/camera-flight.spec.ts +++ b/e2e/camera-flight.spec.ts @@ -4,6 +4,11 @@ import { backButtonLocator, clickCanvasUntilSystemEntered } from './support/wait test.describe('Camera-flight transitions (click-to-select)', () => { test('clicking the star at the view center flies into its system, and the back button flies back out to the galaxy overview', async ({ page }) => { + // A software-rendered bootstrap, a click-until-selected poll of up to 15 seconds, and two + // multi-second camera flights, all while the other specs share the same CPU. The default + // per-test budget covers that only just, and stopped covering it once a second flight-heavy + // spec started running alongside this one. + test.setTimeout(90_000); await page.goto('/'); const canvas = page.getByTestId('scene-canvas'); 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 ee2f969..3e2d868 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -6,8 +6,12 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService } from '../../core/engine/engine.service'; +import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance'; +import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance'; +import { luminositySolar } from '../../shared/astro/stellar'; +import { planetTexture } from '../../shared/rendering/procedural-planet-texture'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; -import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, proceduralBodyTexture, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; +import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { BodyRecord } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; @@ -15,13 +19,6 @@ import { NavigationStore } from '../../shared/state/navigation.store'; import { BodyDetailViewModel } from './body-detail.model'; import { InfoPanelComponent } from './info-panel.component'; -const KIND_COLORS: Record = { - planet: 0x8cbfff, - moon: 0xbfbfbf, - dwarf: 0xccb28c, - exoplanet: 0xd966d9 -}; - /** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */ const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']); const GLOW_SCALE = 2.6; @@ -131,19 +128,24 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { kind: body.kind, hostStarName: hostStar?.name ?? 'Unknown star', radiusKm: body.radiusKm, - orbit: body.orbit + orbit: body.orbit, + appearance: appearanceForBody(body, this.bodies, this.luminosityOf(hostStar)), + hasPhotography: bodyTexturePath(body.id) !== undefined }); this.navigationStore.selectStar(body.systemStarId); } else if (exoplanet) { + const hostStar = this.stars.find((star) => star.id === exoplanet.hostStarId); this.viewModel.set({ id: exoplanet.id, name: exoplanet.name, kind: 'exoplanet', hostStarName: exoplanet.hostStarName, - radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * 6371 : undefined, + radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined, massEarth: exoplanet.massEarth, discoveryYear: exoplanet.discoveryYear, - orbit: exoplanet.orbit + orbit: exoplanet.orbit, + appearance: appearanceForExoplanet(exoplanet, this.luminosityOf(hostStar)), + hasPhotography: bodyTexturePath(exoplanet.id) !== undefined }); if (exoplanet.hostStarId !== null) { this.navigationStore.selectStar(exoplanet.hostStarId); @@ -161,20 +163,33 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { } } + /** + * The host star's luminosity in solar units, from its own catalogued magnitude and distance. + * `null` for an exoplanet whose host never cross-referenced to the star catalogue, which + * leaves its planets with no derived temperature rather than a guessed one. + */ + private luminosityOf(star: StarRecord | undefined): number | null { + if (!star) { + return null; + } + return luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType }); + } + private applyViewModelToScene(): void { const viewModel = this.viewModel(); if (!viewModel || !this.planetMaterial) { return; } + // 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. const realTexturePath = bodyTexturePath(viewModel.id); - const texture = realTexturePath ? loadCachedTexture(realTexturePath) : proceduralBodyTexture(KIND_COLORS[viewModel.kind]); - this.planetMaterial.map = texture ?? null; - // A texture (real photo or procedural stand-in) supplies its own color; a plain white base - // keeps that color true instead of tinting it through `KIND_COLORS` a second time. If no - // texture is available at all (e.g. canvas rendering unsupported), fall back to the flat kind color. - this.planetMaterial.color.set(texture ? 0xffffff : KIND_COLORS[viewModel.kind]); - this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) ? 0.55 : 0.85; + 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. + this.planetMaterial.color.set(0xffffff); + // 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; this.disposeRing(); @@ -275,7 +290,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { const geometry = new THREE.SphereGeometry(1, 64, 48); const viewModel = this.viewModel(); this.planetMaterial = new THREE.MeshStandardMaterial({ - color: viewModel ? KIND_COLORS[viewModel.kind] : 0xffffff, + // White, always: the map that arrives a moment later carries the colour, whether it is a + // photograph or a surface derived from the body's own measurements. + color: 0xffffff, roughness: 0.85, metalness: 0.05 }); diff --git a/src/app/features/body-detail/body-detail.model.ts b/src/app/features/body-detail/body-detail.model.ts index 2e4cb94..89bebd8 100644 --- a/src/app/features/body-detail/body-detail.model.ts +++ b/src/app/features/body-detail/body-detail.model.ts @@ -1,3 +1,4 @@ +import { PlanetAppearance } from '../../shared/astro/planet-appearance'; import { OrbitalElements } from '../../shared/models/body.model'; export type BodyDetailKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet'; @@ -16,4 +17,13 @@ export interface BodyDetailViewModel { massEarth?: number; discoveryYear?: number; orbit: Partial; + /** + * What this world is inferred to look like, and the quantities that inference rests on. Always + * present — every body has measurements enough to place it somewhere — but its individual + * fields are nullable, since a body whose host star is not in the catalogue has no derived + * temperature. + */ + appearance: PlanetAppearance; + /** True when a real photograph is being shown rather than the derived surface. */ + hasPhotography: boolean; } diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 1029704..21e8ef8 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -2,6 +2,7 @@ import { DecimalPipe } from '@angular/common'; import { Component, input } from '@angular/core'; import { Router } from '@angular/router'; +import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance'; import { BodyDetailViewModel } from './body-detail.model'; const KIND_LABELS: Record = { @@ -60,6 +61,22 @@ const KIND_LABELS: Record = {
{{ body().discoveryYear }}
} + +

Derived

+
+
Class
+
{{ classLabel() }}
+ @if (body().appearance.equilibriumTemperatureK !== null) { +
Equilibrium temp.
+
{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} K
+ } + @if (body().appearance.bulkDensityGramsPerCm3 !== null) { +
Bulk density
+
{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} g/cm³
+ } +
+ +

{{ surfaceProvenance() }}

`, imports: [DecimalPipe] @@ -73,6 +90,25 @@ export class InfoPanelComponent { return KIND_LABELS[this.body().kind]; } + classLabel(): string { + return PLANET_CLASS_LABELS[this.body().appearance.planetClass]; + } + + /** + * Says plainly which of the two the viewer is looking at. The derived surface is a reasoned + * illustration, and a panel of real measurements sitting next to it is exactly the context in + * which it could be mistaken for another one. + */ + surfaceProvenance(): string { + if (this.body().hasPhotography) { + return 'Surface: NASA/ESA/USGS photography.'; + } + const temperature = this.body().appearance.equilibriumTemperatureK; + return temperature === null + ? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. 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.'; + } + goBack(): void { void this.router.navigate(['/']); } 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 e1f7c71..2db32ee 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -5,6 +5,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { dateToJulianDate } from '../../shared/astro/constants'; import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy'; +import { luminositySolar } from '../../shared/astro/stellar'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService } from '../../core/engine/engine.service'; import { BodyRecord } from '../../shared/models/body.model'; @@ -691,7 +692,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // The star's own position is the line of sight to it, which is the plane the archive // measures exoplanet inclinations against. The Sun sits at the origin and has no // exoplanets, so it has no meaningful direction and the renderer falls back. - this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }); + // The star's luminosity, derived from its own catalogued magnitude and distance, is what + // decides how hot each body in the system is — and so what each of them looks like. + const hostLuminosity = luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType }); + this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity); this.systemGroup.add(this.systemRenderer.object); // Sized against this system's innermost orbit, so the star never swallows its own planets. diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 6d40da4..6a98e4c 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -1,6 +1,9 @@ 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 { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture'; import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler'; import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; @@ -110,9 +113,19 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame return new THREE.Line(geometry, material); } -function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number): THREE.Mesh { +/** + * A marker sphere, surfaced with the body's own derived appearance rather than a flat category + * colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is + * a consequence of its measurements rather than of which list it came from. + * + * The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what + * survives is essentially its average colour, and generating it costs well under a millisecond. + */ +function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh { const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12); - const material = new THREE.MeshBasicMaterial({ color: colorForKind(kind) }); + const material = appearance + ? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) }) + : new THREE.MeshBasicMaterial({ color: colorForKind(kind) }); return new THREE.Mesh(geometry, material); } @@ -172,7 +185,13 @@ export class SystemOrbitsRenderer { bodies: readonly BodyRecord[], exoplanets: readonly ExoplanetRecord[], /** Direction from the Sun to this system's host star, equatorial — the exoplanet line of sight. */ - hostStarDirection?: CartesianCoordinates + hostStarDirection?: CartesianCoordinates, + /** + * The host star's luminosity in solar units, which is what sets how hot each body in the + * system is and therefore what it looks like. Omitted for a host that is not in the star + * catalogue, leaving its bodies classified on size and density alone. + */ + hostLuminositySolar?: number | null ) { const members: SystemMember[] = []; const topLevelBodiesById = new Map(); @@ -198,7 +217,7 @@ export class SystemOrbitsRenderer { } // A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'. const kind: SystemMemberKind = body.kind; - const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME); + const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); members.push({ id: body.id, kind, marker: tracked.marker }); } @@ -211,7 +230,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); + const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); members.push({ id: body.id, kind: 'moon', marker: moon.marker }); } @@ -235,7 +254,7 @@ export class SystemOrbitsRenderer { periodDays: exoplanet.periodDays, hostStarMassSolar: exoplanet.hostStarMassSolar }); - const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame); + const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar)); members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker }); } @@ -325,10 +344,11 @@ export class SystemOrbitsRenderer { elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, - frame: THREE.Quaternion + frame: THREE.Quaternion, + appearance?: PlanetAppearance ): TrackedTopLevelBody { const orbitLine = buildOrbitLine(elements, kind, frame); - const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu); + const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); this.object.add(orbitLine, marker); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); @@ -344,11 +364,12 @@ export class SystemOrbitsRenderer { gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody, - frame: THREE.Quaternion + frame: THREE.Quaternion, + appearance?: PlanetAppearance ): TrackedMoon { const pivot = new THREE.Group(); const orbitLine = buildOrbitLine(elements, 'moon', frame); - const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu); + const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance); pivot.add(orbitLine, marker); this.object.add(pivot); this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); diff --git a/src/app/shared/astro/body-appearance.spec.ts b/src/app/shared/astro/body-appearance.spec.ts new file mode 100644 index 0000000..4d49c4f --- /dev/null +++ b/src/app/shared/astro/body-appearance.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { BodyRecord } from '../models/body.model'; +import { ExoplanetRecord } from '../models/exoplanet.model'; +import { appearanceForBody, appearanceForExoplanet, heliocentricDistanceAu } from './body-appearance'; +import { DEFAULT_EPOCH_JD } from './constants'; + +const ORBIT = { eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD }; + +const JUPITER: BodyRecord = { id: 'jupiter', systemStarId: 0, name: 'Jupiter', kind: 'planet', radiusKm: 69911, orbit: { ...ORBIT, semiMajorAxisAu: 5.204 } }; +/** Europa's own orbit is around Jupiter: 671,000 km, which is 0.00449 AU. */ +const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 } }; +const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 } }; +const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' }; + +const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN]; + +describe('heliocentricDistanceAu', () => { + it('uses a planet own orbit', () => { + expect(heliocentricDistanceAu(JUPITER, BODIES)).toBeCloseTo(5.204, 6); + }); + + it('uses a moon parent orbit, not the moon own', () => { + // The load-bearing case: Europa's own semi-major axis is 0.0045 AU. Fed to an equilibrium + // temperature it would put Europa closer to the Sun than Mercury and boil it. + expect(heliocentricDistanceAu(EUROPA, BODIES)).toBeCloseTo(5.204, 6); + }); + + it('has no answer for a moon whose parent is missing', () => { + expect(heliocentricDistanceAu(ORPHAN, BODIES)).toBeUndefined(); + }); +}); + +describe('appearanceForBody', () => { + it('derives an ice world for a moon of Jupiter, at Jupiter distance', () => { + const europa = appearanceForBody(EUROPA, BODIES, 1); + + expect(europa.equilibriumTemperatureK).toBeCloseTo(112, -0.5); + expect(europa.planetClass).toBe('icy'); + }); + + it('would have melted that same moon if it used the moon own orbit', () => { + // Pinning the bug the parent lookup exists to avoid, so it cannot come back silently. + const wrong = appearanceForBody({ ...EUROPA, parentBodyId: undefined }, BODIES, 1); + expect(wrong.equilibriumTemperatureK!).toBeGreaterThan(2000); + expect(wrong.planetClass).not.toBe('icy'); + }); + + it('derives Earth as temperate with a polar cap', () => { + const earth = appearanceForBody(EARTH, BODIES, 1); + + expect(earth.planetClass).toBe('temperate'); + expect(earth.equilibriumTemperatureK).toBeCloseTo(255, -0.5); + expect(earth.polarCapExtentDeg).toBeGreaterThan(0); + }); + + it('has no density for a solar-system body, since Horizons publishes no masses', () => { + expect(appearanceForBody(EARTH, BODIES, 1).bulkDensityGramsPerCm3).toBeNull(); + }); + + it('still classifies a body when the host luminosity is unknown', () => { + const earth = appearanceForBody(EARTH, BODIES, null); + expect(earth.equilibriumTemperatureK).toBeNull(); + expect(earth.planetClass).toBe('rocky'); + }); +}); + +describe('appearanceForExoplanet', () => { + const KEPLER_186F: ExoplanetRecord = { id: 'Kepler-186 f', hostStarId: 1, hostStarName: 'Kepler-186', name: 'Kepler-186 f', radiusEarth: 1.17, orbit: { semiMajorAxisAu: 0.432 } }; + + it('derives a temperature from the host star output and the published orbit', () => { + // A quarter of a solar luminosity at 0.432 AU: cool, but not frozen. + const derived = appearanceForExoplanet(KEPLER_186F, 0.04); + expect(derived.equilibriumTemperatureK).toBeGreaterThan(150); + expect(derived.equilibriumTemperatureK).toBeLessThan(250); + }); + + it('derives a density where both a radius and a mass are published', () => { + const withMass = appearanceForExoplanet({ ...KEPLER_186F, massEarth: 1.4 }, 0.04); + expect(withMass.bulkDensityGramsPerCm3).toBeCloseTo((5.51 * 1.4) / Math.pow(1.17, 3), 4); + }); + + it('falls back to size alone for a host that never cross-referenced to the catalogue', () => { + // 5685 of the 6319 archive records have no matching HYG star. None of them is rendered in a + // system, but any of them can still be opened from search. + const derived = appearanceForExoplanet({ ...KEPLER_186F, hostStarId: null }, null); + expect(derived.equilibriumTemperatureK).toBeNull(); + expect(derived.planetClass).toBe('rocky'); + }); + + it('is stable per planet, so a world keeps its face between visits', () => { + expect(appearanceForExoplanet(KEPLER_186F, 0.04).seed).toBe(appearanceForExoplanet(KEPLER_186F, 0.04).seed); + }); +}); diff --git a/src/app/shared/astro/body-appearance.ts b/src/app/shared/astro/body-appearance.ts new file mode 100644 index 0000000..bb8e719 --- /dev/null +++ b/src/app/shared/astro/body-appearance.ts @@ -0,0 +1,55 @@ +import { BodyRecord } from '../models/body.model'; +import { ExoplanetRecord } from '../models/exoplanet.model'; +import { EARTH_RADIUS_KM, PlanetAppearance, planetAppearance } from './planet-appearance'; + +/** + * Adapters from the two record shapes this app carries to the appearance derivation. + * + * They exist because the two sources publish different things. The Exoplanet Archive gives a + * radius and a mass in Earth units and a semi-major axis around the host star. Horizons gives a + * radius in kilometres, no mass at all, and — for a moon — a semi-major axis around its + * *planet* rather than around the Sun. Feeding a moon's own orbit into an equilibrium + * temperature would put Europa a few thousandths of an AU from the Sun and melt it. + */ + +/** + * Distance from the host star at which a body actually sits, in AU. + * + * For a moon that is its planet's distance, not its own: the tiny orbit around the planet is + * irrelevant to how much starlight reaches it, and using it would be off by three orders of + * magnitude. + */ +export function heliocentricDistanceAu(body: BodyRecord, bodies: readonly BodyRecord[]): number | undefined { + if (!body.parentBodyId) { + return body.orbit.semiMajorAxisAu; + } + return bodies.find((candidate) => candidate.id === body.parentBodyId)?.orbit.semiMajorAxisAu; +} + +/** + * Appearance of a solar-system body. + * + * Horizons publishes no masses, so these worlds have no derived density and are classified on + * size and temperature alone. That is enough for what it decides here: at Jupiter's distance + * from the Sun the question is never whether a moon is rock or iron, it is whether its surface + * is ice, and the temperature answers that on its own. + */ +export function appearanceForBody(body: BodyRecord, bodies: readonly BodyRecord[], hostLuminositySolar: number | null | undefined): PlanetAppearance { + return planetAppearance({ + id: body.id, + radiusEarth: body.radiusKm ? body.radiusKm / EARTH_RADIUS_KM : undefined, + semiMajorAxisAu: heliocentricDistanceAu(body, bodies), + hostLuminositySolar + }); +} + +/** Appearance of an exoplanet, from the archive's published radius, mass and orbit. */ +export function appearanceForExoplanet(exoplanet: ExoplanetRecord, hostLuminositySolar: number | null | undefined): PlanetAppearance { + return planetAppearance({ + id: exoplanet.id, + radiusEarth: exoplanet.radiusEarth, + massEarth: exoplanet.massEarth, + semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu, + hostLuminositySolar + }); +} diff --git a/src/app/shared/astro/planet-appearance.spec.ts b/src/app/shared/astro/planet-appearance.spec.ts new file mode 100644 index 0000000..9b86d88 --- /dev/null +++ b/src/app/shared/astro/planet-appearance.spec.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; + +import { + bulkDensityGramsPerCm3, + classifyPlanet, + EARTH_DENSITY_G_PER_CM3, + equilibriumTemperatureK, + paletteFor, + planetAppearance, + PLANET_CLASS_LABELS, + PlanetClass, + polarCapExtentDeg, + seedFromId +} from './planet-appearance'; + +const EARTH_RADII = { mercury: 0.383, venus: 0.949, earth: 1, mars: 0.532, jupiter: 10.97, saturn: 9.14, uranus: 3.98, neptune: 3.86, europa: 0.245, phobos: 0.0017 }; + +describe('bulkDensityGramsPerCm3', () => { + it('gives Earth its own density, by construction', () => { + expect(bulkDensityGramsPerCm3(1, 1)).toBeCloseTo(EARTH_DENSITY_G_PER_CM3, 9); + }); + + it('separates a ball of iron from a ball of hydrogen, which is what it is for', () => { + // Mercury is 5.4 g/cm3 and mostly core; Saturn is 0.69 and would float. + expect(bulkDensityGramsPerCm3(0.055, EARTH_RADII.mercury)).toBeCloseTo(5.4, 0); + expect(bulkDensityGramsPerCm3(95.2, EARTH_RADII.saturn)).toBeCloseTo(0.69, 1); + }); + + it('has no answer without both numbers', () => { + expect(bulkDensityGramsPerCm3(undefined, 1)).toBeNull(); + expect(bulkDensityGramsPerCm3(1, undefined)).toBeNull(); + expect(bulkDensityGramsPerCm3(0, 1)).toBeNull(); + expect(bulkDensityGramsPerCm3(1, -1)).toBeNull(); + }); +}); + +describe('equilibriumTemperatureK', () => { + // The published equilibrium temperatures, which this must reproduce to be worth anything. + it.each([ + ['Earth', 1, 1, 255], + ['Mars', 1, 1.524, 206], + ['Jupiter', 1, 5.204, 112], + ['Neptune', 1, 30.07, 46] + ])('reproduces the published equilibrium temperature of %s', (_name, luminosity, semiMajorAxisAu, expected) => { + expect(equilibriumTemperatureK(luminosity, semiMajorAxisAu)).toBeCloseTo(expected, -0.5); + }); + + it('follows the inverse square root of distance', () => { + const near = equilibriumTemperatureK(1, 1)!; + const far = equilibriumTemperatureK(1, 4)!; + expect(near / far).toBeCloseTo(2, 6); + }); + + it('follows the fourth root of luminosity, which is why a rough luminosity still serves', () => { + const dim = equilibriumTemperatureK(1, 1)!; + const bright = equilibriumTemperatureK(16, 1)!; + expect(bright / dim).toBeCloseTo(2, 6); + }); + + it('puts a hot Jupiter where a hot Jupiter is', () => { + // 51 Pegasi b: 0.052 AU from a slightly super-solar star, published near 1200 K. + expect(equilibriumTemperatureK(1.3, 0.052)!).toBeGreaterThan(1000); + }); + + it('cools a world as its albedo rises, as a fourth root', () => { + expect(equilibriumTemperatureK(1, 1, 0.8)!).toBeLessThan(equilibriumTemperatureK(1, 1, 0)!); + }); + + it('has no answer without a star or an orbit', () => { + expect(equilibriumTemperatureK(null, 1)).toBeNull(); + expect(equilibriumTemperatureK(1, undefined)).toBeNull(); + expect(equilibriumTemperatureK(0, 1)).toBeNull(); + }); +}); + +describe('classifyPlanet', () => { + /** Every solar-system body this app carries, at its real size and equilibrium temperature. */ + it.each<[string, { radiusEarth?: number; massEarth?: number; equilibriumTemperatureK?: number }, PlanetClass]>([ + ['Mercury', { radiusEarth: EARTH_RADII.mercury, massEarth: 0.055, equilibriumTemperatureK: 410 }, 'scorched'], + ['Earth', { radiusEarth: 1, massEarth: 1, equilibriumTemperatureK: 255 }, 'temperate'], + ['Mars', { radiusEarth: EARTH_RADII.mars, massEarth: 0.107, equilibriumTemperatureK: 206 }, 'temperate'], + ['Jupiter', { radiusEarth: EARTH_RADII.jupiter, massEarth: 317.8, equilibriumTemperatureK: 112 }, 'gasGiant'], + ['Saturn', { radiusEarth: EARTH_RADII.saturn, massEarth: 95.2, equilibriumTemperatureK: 82 }, 'gasGiant'], + ['Uranus', { radiusEarth: EARTH_RADII.uranus, massEarth: 14.5, equilibriumTemperatureK: 58 }, 'iceGiant'], + ['Neptune', { radiusEarth: EARTH_RADII.neptune, massEarth: 17.1, equilibriumTemperatureK: 46 }, 'iceGiant'], + ['Europa', { radiusEarth: EARTH_RADII.europa, equilibriumTemperatureK: 112 }, 'icy'], + ['51 Peg b', { massEarth: 193.9, equilibriumTemperatureK: 1227 }, 'hotGasGiant'], + ['GJ 1214 b', { radiusEarth: 2.733, massEarth: 8.4, equilibriumTemperatureK: 596 }, 'subNeptune'] + ])('puts %s in the right class', (_name, measurements, expected) => { + expect(classifyPlanet(measurements)).toBe(expected); + }); + + it('tells a gas giant from an ice giant by size, since temperature cannot', () => { + // Jupiter is 110 K and Neptune is 47 K: both freezing, and the difference between them is + // how much hydrogen they hold, not how cold they are. + const cold = { equilibriumTemperatureK: 100 }; + expect(classifyPlanet({ ...cold, radiusEarth: EARTH_RADII.jupiter })).toBe('gasGiant'); + expect(classifyPlanet({ ...cold, radiusEarth: EARTH_RADII.neptune })).toBe('iceGiant'); + }); + + it('calls any giant hot once it is hot, whichever kind it was', () => { + for (const radiusEarth of [EARTH_RADII.jupiter, EARTH_RADII.neptune]) { + expect(classifyPlanet({ radiusEarth, equilibriumTemperatureK: 1400 })).toBe('hotGasGiant'); + } + }); + + it('lets density override temperature at both extremes', () => { + // Iron whatever the weather... + expect(classifyPlanet({ radiusEarth: 1, massEarth: 1.6, equilibriumTemperatureK: 255 })).toBe('iron'); + // ...and too light to be rock means ice, even where rock would be solid. + expect(classifyPlanet({ radiusEarth: 1.5, massEarth: 1, equilibriumTemperatureK: 250 })).toBe('icy'); + }); + + it('melts a rocky world that is hot enough', () => { + expect(classifyPlanet({ radiusEarth: 1, equilibriumTemperatureK: 1500 })).toBe('lava'); + }); + + it('refuses to call a 11 km moon temperate on the strength of its orbital distance', () => { + // Phobos sits at Mars's distance and so at Mars's temperature, and is an airless rock. + expect(classifyPlanet({ radiusEarth: EARTH_RADII.phobos, equilibriumTemperatureK: 206 })).toBe('rocky'); + }); + + it('falls back to size alone when the host star is unknown', () => { + expect(classifyPlanet({ radiusEarth: 1 })).toBe('rocky'); + expect(classifyPlanet({ radiusEarth: EARTH_RADII.jupiter })).toBe('gasGiant'); + expect(classifyPlanet({ radiusEarth: 2.5 })).toBe('subNeptune'); + }); + + it('classifies from a mass alone, for the planets only radial velocity has seen', () => { + expect(classifyPlanet({ massEarth: 300 })).toBe('gasGiant'); + expect(classifyPlanet({ massEarth: 15 })).toBe('iceGiant'); + expect(classifyPlanet({ massEarth: 4 })).toBe('subNeptune'); + expect(classifyPlanet({ massEarth: 1 })).toBe('rocky'); + }); + + it('prefers radius over mass, since radius is what the classes are defined by', () => { + // A puffy planet as massive as Neptune but the size of Jupiter is a gas giant. + expect(classifyPlanet({ radiusEarth: EARTH_RADII.jupiter, massEarth: 15 })).toBe('gasGiant'); + }); + + it('always returns a class, whatever it is given', () => { + expect(classifyPlanet({})).toBe('rocky'); + }); +}); + +describe('polarCapExtentDeg', () => { + it('grows caps as a world cools, which is the visible consequence of the derived temperature', () => { + const warm = polarCapExtentDeg('temperate', 280)!; + const cool = polarCapExtentDeg('temperate', 230)!; + const cold = polarCapExtentDeg('temperate', 190)!; + expect(warm).toBeLessThan(cool); + expect(cool).toBeLessThan(cold); + }); + + it('covers a frozen world entirely and leaves a warm one bare', () => { + expect(polarCapExtentDeg('icy', 100)).toBe(90); + expect(polarCapExtentDeg('rocky', 400)).toBeNull(); + }); + + it('gives Earth a cap that stops well short of the tropics', () => { + const earth = polarCapExtentDeg('temperate', 255)!; + expect(earth).toBeGreaterThan(5); + expect(earth).toBeLessThan(45); + }); + + it('does not put ice on a world where ice is not the question', () => { + for (const planetClass of ['gasGiant', 'hotGasGiant', 'iceGiant', 'subNeptune', 'lava'] as PlanetClass[]) { + expect(polarCapExtentDeg(planetClass, 100)).toBeNull(); + } + }); + + it('has no answer without a temperature', () => { + expect(polarCapExtentDeg('temperate', null)).toBeNull(); + expect(polarCapExtentDeg('temperate', undefined)).toBeNull(); + }); +}); + +describe('paletteFor', () => { + const ALL_CLASSES = Object.keys(PLANET_CLASS_LABELS) as PlanetClass[]; + + it('has a palette and a label for every class', () => { + for (const planetClass of ALL_CLASSES) { + expect(paletteFor(planetClass)).toBeDefined(); + expect(PLANET_CLASS_LABELS[planetClass].length).toBeGreaterThan(0); + } + }); + + it('keeps every channel inside the displayable range', () => { + for (const planetClass of ALL_CLASSES) { + const palette = paletteFor(planetClass); + for (const tone of [palette.low, palette.mid, palette.high, palette.cap]) { + for (const channel of tone) { + expect(channel).toBeGreaterThanOrEqual(0); + expect(channel).toBeLessThanOrEqual(1); + } + } + expect(palette.contrast).toBeGreaterThan(0); + expect(palette.contrast).toBeLessThanOrEqual(1); + } + }); + + it('bands the worlds with a fluid envelope and gives terrain to the ones with a surface', () => { + for (const planetClass of ['gasGiant', 'hotGasGiant', 'iceGiant', 'subNeptune'] as PlanetClass[]) { + expect(paletteFor(planetClass).structure).toBe('banded'); + } + for (const planetClass of ['lava', 'scorched', 'iron', 'rocky', 'temperate', 'icy'] as PlanetClass[]) { + expect(paletteFor(planetClass).structure).toBe('terrain'); + } + }); + + it('makes an ice giant blue and a hot giant red, following what each is made of', () => { + // Methane absorbs red light, which is exactly why Uranus and Neptune look the way they do. + const iceGiant = paletteFor('iceGiant').mid; + expect(iceGiant[2]).toBeGreaterThan(iceGiant[0]); + const hot = paletteFor('hotGasGiant').mid; + expect(hot[0]).toBeGreaterThan(hot[2]); + }); +}); + +describe('seedFromId', () => { + it('is stable, so a world looks the same on every visit', () => { + expect(seedFromId('Kepler-186 f')).toBe(seedFromId('Kepler-186 f')); + }); + + it('separates bodies that differ only slightly in name', () => { + expect(seedFromId('TRAPPIST-1 e')).not.toBe(seedFromId('TRAPPIST-1 f')); + }); + + it('stays a non-negative 32-bit integer', () => { + for (const id of ['', 'a', 'Kepler-186 f', 'HD 209458 b']) { + const seed = seedFromId(id); + expect(Number.isInteger(seed)).toBe(true); + expect(seed).toBeGreaterThanOrEqual(0); + expect(seed).toBeLessThan(2 ** 32); + } + }); +}); + +describe('planetAppearance', () => { + it('derives the whole chain from published measurements', () => { + const earth = planetAppearance({ id: 'earth', radiusEarth: 1, massEarth: 1, semiMajorAxisAu: 1, hostLuminositySolar: 1 }); + + expect(earth.planetClass).toBe('temperate'); + expect(earth.equilibriumTemperatureK).toBeCloseTo(255, -0.5); + expect(earth.bulkDensityGramsPerCm3).toBeCloseTo(EARTH_DENSITY_G_PER_CM3, 6); + expect(earth.polarCapExtentDeg).toBeGreaterThan(0); + expect(earth.palette.structure).toBe('terrain'); + }); + + it('reports what it could not derive as null rather than guessing it', () => { + const unknown = planetAppearance({ id: 'x', radiusEarth: 1 }); + expect(unknown.equilibriumTemperatureK).toBeNull(); + expect(unknown.bulkDensityGramsPerCm3).toBeNull(); + expect(unknown.polarCapExtentDeg).toBeNull(); + expect(unknown.planetClass).toBe('rocky'); + }); +}); diff --git a/src/app/shared/astro/planet-appearance.ts b/src/app/shared/astro/planet-appearance.ts new file mode 100644 index 0000000..240e5c0 --- /dev/null +++ b/src/app/shared/astro/planet-appearance.ts @@ -0,0 +1,339 @@ +/** + * What a world probably looks like, derived from what has been measured about it. + * + * No exoplanet's surface has ever been imaged, and a handful of the solar system's own moons + * have no usable photograph in this app's asset set either. Rather than paint those bodies a + * flat colour chosen by category, this module reasons from the numbers that *are* published — + * radius, mass, orbital distance, and the host star's luminosity — to a temperature, a bulk + * density, and from those to a class of world with a palette and a surface structure. + * + * The chain is: mass and radius give density, which separates rock from ice from gas; the star's + * luminosity and the orbital distance give an equilibrium temperature, which decides whether + * that material is molten, solid, or frozen. Both steps are standard, and both are stated on + * screen — this produces a *derived* appearance, never a claim about an observation. + */ + +/** Equilibrium temperature of a body at 1 AU from the Sun with zero albedo, in kelvin. */ +export const SOLAR_EQUILIBRIUM_TEMPERATURE_K = 278.6; + +/** + * Default Bond albedo where none is published, which is all of them. The solar system's rocky + * bodies cluster near this: Earth 0.31, Mars 0.25, Mercury 0.09, the Moon 0.11, and the giants + * 0.29-0.5. Anything in that range moves the temperature by a few per cent, since it enters as + * a fourth root. + */ +export const DEFAULT_BOND_ALBEDO = 0.3; + +export const EARTH_RADIUS_KM = 6371; +/** Earth's mean bulk density, in g/cm3 — the reference every other world is compared against. */ +export const EARTH_DENSITY_G_PER_CM3 = 5.51; + +/** + * Class boundaries in Earth radii. + * + * Below the rocky ceiling a world cannot hold onto hydrogen. Above the gas-giant floor it is + * mostly hydrogen and helium. Between sit the ice giants — Uranus and Neptune are both close to + * 3.9 Earth radii — and below those the sub-Neptunes, the commonest kind of planet found and the + * one with no solar-system example at all. + */ +const ROCKY_MAX_RADIUS_EARTH = 1.8; +const ICE_GIANT_MIN_RADIUS_EARTH = 3.5; +const GAS_GIANT_MIN_RADIUS_EARTH = 6; + +/** + * The same ladder in Earth masses, for the ~1400 planets with a published mass and no radius — + * mostly radial-velocity detections, which measure mass and never see a transit. + */ +const SUB_NEPTUNE_MIN_MASS_EARTH = 2; +const ICE_GIANT_MIN_MASS_EARTH = 8; +const GAS_GIANT_MIN_MASS_EARTH = 50; + +/** + * Temperature boundaries in kelvin. + * + * The temperate band is the conservative one: Earth's equilibrium temperature is 255 K and + * Mars's is 206 K, both well inside it, while Venus's 300 K sits outside — which is the right + * answer here, since equilibrium temperature deliberately ignores the greenhouse effect that + * takes Venus's actual surface to 737 K. + */ +const LAVA_MIN_K = 1200; +const SCORCHED_MIN_K = 400; +const TEMPERATE_MIN_K = 175; +const TEMPERATE_MAX_K = 290; +const HOT_GIANT_MIN_K = 900; + +/** + * Smallest world that gets called temperate, in Earth radii — about 1900 km, near the size below + * which a body cannot hold an atmosphere at all. Without it, Phobos comes out "temperate" on the + * strength of Mars's orbital distance, which is true of its temperature and absurd of the 11 km + * airless rock itself. + */ +const TEMPERATE_MIN_RADIUS_EARTH = 0.3; + +/** Density boundaries in g/cm3, either side of the rocky band. */ +const IRON_MIN_DENSITY = 7.5; +const VOLATILE_MAX_DENSITY = 3; + +/** + * Bulk density in g/cm3 from a mass in Earth masses and a radius in Earth radii. + * + * This is the single most informative derived quantity about a planet: it is the difference + * between a ball of iron, a ball of rock, a ball of water and a ball of hydrogen, and it comes + * straight out of two published numbers with no modelling in between. + */ +export function bulkDensityGramsPerCm3(massEarth: number | undefined, radiusEarth: number | undefined): number | null { + if (!massEarth || !radiusEarth || massEarth <= 0 || radiusEarth <= 0) { + return null; + } + return EARTH_DENSITY_G_PER_CM3 * (massEarth / Math.pow(radiusEarth, 3)); +} + +/** + * Equilibrium temperature in kelvin: the temperature at which a body re-radiates exactly the + * starlight it absorbs. + * + * `T = 278.6 K * (L/Lsun)^(1/4) * (a/AU)^(-1/2) * (1-A)^(1/4)`, the standard blackbody balance + * for a rapidly-rotating body. It ignores internal heat and any greenhouse effect, both of which + * push the real surface warmer — Venus's surface is 737 K against an equilibrium 232 K. It is + * nonetheless the right quantity here, because it is what decides the *state* of the material a + * world is made of, which is what its surface looks like. + */ +export function equilibriumTemperatureK( + luminositySolar: number | null | undefined, + semiMajorAxisAu: number | undefined, + bondAlbedo: number = DEFAULT_BOND_ALBEDO +): number | null { + if (!luminositySolar || !semiMajorAxisAu || luminositySolar <= 0 || semiMajorAxisAu <= 0) { + return null; + } + return SOLAR_EQUILIBRIUM_TEMPERATURE_K * Math.pow(luminositySolar, 0.25) * Math.pow(semiMajorAxisAu, -0.5) * Math.pow(1 - bondAlbedo, 0.25); +} + +/** + * The kinds of world this app distinguishes. Chosen to be the classes that actually look + * different from each other, and that the available measurements can actually separate. + */ +export type PlanetClass = 'lava' | 'scorched' | 'iron' | 'rocky' | 'temperate' | 'icy' | 'subNeptune' | 'iceGiant' | 'gasGiant' | 'hotGasGiant'; + +export interface PlanetMeasurements { + radiusEarth?: number; + massEarth?: number; + /** Equilibrium temperature, if it could be derived; `null`/absent when the star is unknown. */ + equilibriumTemperatureK?: number | null; +} + +/** + * Sorts a world into a class from its measurements. + * + * Size decides the family and temperature decides the state within it, which is the order the + * evidence actually supports: a radius separates a gas giant from a rock far more reliably than + * any temperature can, and temperature then separates a molten rock from a frozen one. + * + * With no temperature — the case for a planet whose host star is not in the star catalogue — + * every world falls back to the temperate-agnostic member of its family rather than being + * guessed at. + */ +export function classifyPlanet(measurements: PlanetMeasurements): PlanetClass { + const { radiusEarth, massEarth } = measurements; + const temperature = measurements.equilibriumTemperatureK ?? null; + const density = bulkDensityGramsPerCm3(massEarth, radiusEarth); + const family = sizeFamily(radiusEarth, massEarth); + + if (family === 'gasGiant' || family === 'iceGiant') { + // Temperature separates a hot giant from a cold one but not a gas giant from an ice giant: + // Jupiter's equilibrium temperature is 110 K and Neptune's is 47 K, both freezing. What + // actually distinguishes them is how much hydrogen they hold, which is what size measures. + return temperature !== null && temperature >= HOT_GIANT_MIN_K ? 'hotGasGiant' : family; + } + if (family === 'subNeptune') { + return 'subNeptune'; + } + + // Below the rocky ceiling. Density, where it is known, overrides temperature at both extremes: + // an iron-rich world reads metallic whatever its temperature, and one too light to be rock is + // an ice/water world even if it sits where rock would be solid. + if (density !== null && density >= IRON_MIN_DENSITY) { + return 'iron'; + } + if (temperature !== null && temperature >= LAVA_MIN_K) { + return 'lava'; + } + if (density !== null && density <= VOLATILE_MAX_DENSITY && (temperature === null || temperature < TEMPERATE_MAX_K)) { + return 'icy'; + } + if (temperature === null) { + return 'rocky'; + } + if (temperature >= SCORCHED_MIN_K) { + return 'scorched'; + } + if (temperature < TEMPERATE_MIN_K) { + return 'icy'; + } + const bigEnoughForAnAtmosphere = radiusEarth === undefined || radiusEarth >= TEMPERATE_MIN_RADIUS_EARTH; + return temperature <= TEMPERATE_MAX_K && bigEnoughForAnAtmosphere ? 'temperate' : 'rocky'; +} + +/** + * Which family a world's bulk puts it in, from a radius where one is published and from a mass + * where only that is. Radius is preferred: it is what the classes are actually defined by, and + * mass alone leaves a dense super-Earth and a puffy sub-Neptune indistinguishable. + */ +function sizeFamily(radiusEarth: number | undefined, massEarth: number | undefined): 'rocky' | 'subNeptune' | 'iceGiant' | 'gasGiant' { + if (radiusEarth !== undefined && radiusEarth > 0) { + if (radiusEarth >= GAS_GIANT_MIN_RADIUS_EARTH) { + return 'gasGiant'; + } + if (radiusEarth >= ICE_GIANT_MIN_RADIUS_EARTH) { + return 'iceGiant'; + } + return radiusEarth > ROCKY_MAX_RADIUS_EARTH ? 'subNeptune' : 'rocky'; + } + + const mass = massEarth ?? 0; + if (mass >= GAS_GIANT_MIN_MASS_EARTH) { + return 'gasGiant'; + } + if (mass >= ICE_GIANT_MIN_MASS_EARTH) { + return 'iceGiant'; + } + return mass >= SUB_NEPTUNE_MIN_MASS_EARTH ? 'subNeptune' : 'rocky'; +} + +/** Human-readable name for a class, for the info panel. */ +export const PLANET_CLASS_LABELS: Readonly> = { + lava: 'Molten rock', + scorched: 'Scorched rock', + iron: 'Iron-rich world', + rocky: 'Rocky world', + temperate: 'Temperate rock', + icy: 'Ice world', + subNeptune: 'Sub-Neptune', + iceGiant: 'Ice giant', + gasGiant: 'Gas giant', + hotGasGiant: 'Hot gas giant' +}; + +/** An RGB triple in 0-1, the form the texture generator and Three.js both want. */ +export type Rgb = readonly [number, number, number]; + +/** + * The palette and surface structure each class is drawn with. + * + * `low`/`mid`/`high` are the three tones the surface is built from — basin floor, general + * surface, highland or cloud top — and `cap` is the polar tone. Colours are reasoned from the + * chemistry each class implies: silicates and basalt are grey-brown, hot silicate cloud decks + * glow red, ammonia clouds are cream and ochre, and methane absorbs red light, which is exactly + * why Uranus and Neptune are the colour they are. + */ +export interface PlanetPalette { + readonly low: Rgb; + readonly mid: Rgb; + readonly high: Rgb; + readonly cap: Rgb; + /** `banded` for a fluid envelope with zonal flow, `terrain` for a solid surface. */ + readonly structure: 'banded' | 'terrain'; + /** How much the three tones separate, 0-1. Hazy worlds are flat, airless ones are stark. */ + readonly contrast: number; +} + +const PALETTES: Readonly> = { + // Basalt darkened almost to black, cut by exposed magma. Real molten silicate at 1500 K glows + // a dull orange-red, not the yellow-white of a much hotter furnace. + lava: { low: [0.09, 0.06, 0.06], mid: [0.24, 0.13, 0.1], high: [0.95, 0.35, 0.12], cap: [0.32, 0.12, 0.08], structure: 'terrain', contrast: 0.95 }, + // Baked rock with the volatiles long gone: Mercury's colour, which is what is left behind. + scorched: { low: [0.24, 0.2, 0.18], mid: [0.42, 0.36, 0.31], high: [0.6, 0.53, 0.46], cap: [0.45, 0.4, 0.36], structure: 'terrain', contrast: 0.8 }, + // A world dense enough to be mostly metal reads darker and greyer than silicate rock. + iron: { low: [0.16, 0.15, 0.16], mid: [0.33, 0.31, 0.32], high: [0.52, 0.5, 0.53], cap: [0.4, 0.39, 0.41], structure: 'terrain', contrast: 0.7 }, + rocky: { low: [0.25, 0.21, 0.18], mid: [0.45, 0.38, 0.31], high: [0.66, 0.58, 0.48], cap: [0.78, 0.78, 0.8], structure: 'terrain', contrast: 0.7 }, + // Where water can be liquid. Deliberately restrained: this is a temperature, not a detection. + temperate: { low: [0.12, 0.2, 0.32], mid: [0.3, 0.36, 0.36], high: [0.55, 0.52, 0.42], cap: [0.9, 0.93, 0.96], structure: 'terrain', contrast: 0.6 }, + icy: { low: [0.55, 0.62, 0.7], mid: [0.75, 0.81, 0.86], high: [0.92, 0.95, 0.98], cap: [0.97, 0.98, 1.0], structure: 'terrain', contrast: 0.45 }, + // The commonest planet found and the one with no solar-system example. A thick hydrogen haze + // over an unseen interior, so: banded, but with almost no contrast to band. + subNeptune: { low: [0.42, 0.47, 0.5], mid: [0.58, 0.63, 0.64], high: [0.72, 0.76, 0.75], cap: [0.62, 0.67, 0.68], structure: 'banded', contrast: 0.22 }, + // Methane absorbs red light; what comes back out is the blue-green of Uranus and Neptune. + iceGiant: { low: [0.13, 0.32, 0.55], mid: [0.24, 0.5, 0.72], high: [0.55, 0.78, 0.88], cap: [0.35, 0.6, 0.78], structure: 'banded', contrast: 0.45 }, + // Ammonia cloud tops over ochre organics: the Jupiter/Saturn palette. + gasGiant: { low: [0.45, 0.32, 0.22], mid: [0.72, 0.6, 0.44], high: [0.92, 0.87, 0.76], cap: [0.6, 0.52, 0.42], structure: 'banded', contrast: 0.7 }, + // Too hot for ammonia or water clouds; silicate and alkali-metal cloud decks over a glowing + // interior, which is why hot Jupiters are modelled as deep red rather than as bright ones. + hotGasGiant: { low: [0.28, 0.08, 0.07], mid: [0.55, 0.18, 0.12], high: [0.85, 0.42, 0.2], cap: [0.4, 0.14, 0.1], structure: 'banded', contrast: 0.6 } +}; + +export function paletteFor(planetClass: PlanetClass): PlanetPalette { + return PALETTES[planetClass]; +} + +/** + * Latitude, in degrees from the pole, that polar ice reaches down to — or `null` for a world + * where ice is not the question. + * + * Genuinely physical, and the clearest visible consequence of the derived temperature: caps + * grow as a world cools. They are absent above the point where water cannot be stable anywhere + * and cover the whole globe below the point where it cannot melt anywhere. + */ +export function polarCapExtentDeg(planetClass: PlanetClass, temperatureK: number | null | undefined): number | null { + if (temperatureK === null || temperatureK === undefined) { + return null; + } + if (planetClass !== 'temperate' && planetClass !== 'rocky' && planetClass !== 'icy') { + return null; + } + if (temperatureK >= TEMPERATE_MAX_K) { + return null; + } + if (temperatureK <= TEMPERATE_MIN_K) { + return 90; + } + // Linear between the two: nothing at the warm end, global at the cold end. + return 90 * ((TEMPERATE_MAX_K - temperatureK) / (TEMPERATE_MAX_K - TEMPERATE_MIN_K)); +} + +/** Everything the texture generator needs, and everything the info panel reports. */ +export interface PlanetAppearance { + readonly planetClass: PlanetClass; + readonly palette: PlanetPalette; + readonly equilibriumTemperatureK: number | null; + readonly bulkDensityGramsPerCm3: number | null; + readonly polarCapExtentDeg: number | null; + /** Stable per body, so a world looks the same on every visit. */ + readonly seed: number; +} + +/** Stable 32-bit hash of a body id, so the same world is generated identically every time. */ +export function seedFromId(id: string): number { + let hash = 2166136261; + for (let index = 0; index < id.length; index++) { + hash ^= id.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +/** + * The full derivation, from published measurements to a drawable appearance. + * + * `hostLuminositySolar` is the star's total output in solar units — see `stellar.ts`, which + * derives it from the star catalogue's own magnitude and distance. Without it there is no + * temperature, and the classification falls back to what size and density alone can say. + */ +export function planetAppearance(input: { + id: string; + radiusEarth?: number; + massEarth?: number; + semiMajorAxisAu?: number; + hostLuminositySolar?: number | null; +}): PlanetAppearance { + const temperature = equilibriumTemperatureK(input.hostLuminositySolar, input.semiMajorAxisAu); + const planetClass = classifyPlanet({ radiusEarth: input.radiusEarth, massEarth: input.massEarth, equilibriumTemperatureK: temperature }); + + return { + planetClass, + palette: paletteFor(planetClass), + equilibriumTemperatureK: temperature, + bulkDensityGramsPerCm3: bulkDensityGramsPerCm3(input.massEarth, input.radiusEarth), + polarCapExtentDeg: polarCapExtentDeg(planetClass, temperature), + seed: seedFromId(input.id) + }; +} diff --git a/src/app/shared/astro/stellar.spec.ts b/src/app/shared/astro/stellar.spec.ts new file mode 100644 index 0000000..ea7c8ca --- /dev/null +++ b/src/app/shared/astro/stellar.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { absoluteMagnitude, bolometricCorrection, luminositySolar, SOLAR_ABSOLUTE_MAGNITUDE_V, SOLAR_BOLOMETRIC_MAGNITUDE } from './stellar'; + +/** Real catalogue rows, with the published luminosity each one should reproduce. */ +const SIRIUS = { magnitude: -1.44, distancePc: 2.6371, spectralType: 'A0m...', publishedLuminosity: 25.4 }; +const VEGA = { magnitude: 0.03, distancePc: 7.68, spectralType: 'A0Vvar', publishedLuminosity: 40 }; +const PROXIMA = { magnitude: 11.01, distancePc: 1.2959, spectralType: 'M5Ve', publishedLuminosity: 0.0015 }; +const ALPHA_CEN_A = { magnitude: -0.01, distancePc: 1.3247, spectralType: 'G2V', publishedLuminosity: 1.52 }; + +describe('absoluteMagnitude', () => { + it('is the apparent magnitude at the reference distance of ten parsecs', () => { + expect(absoluteMagnitude(5, 10)).toBeCloseTo(5, 12); + }); + + it('brightens a star as it is placed further away for the same apparent magnitude', () => { + expect(absoluteMagnitude(5, 100)).toBeLessThan(absoluteMagnitude(5, 10)!); + }); + + it('reproduces the published absolute magnitude of Sirius', () => { + expect(absoluteMagnitude(SIRIUS.magnitude, SIRIUS.distancePc)).toBeCloseTo(1.45, 1); + }); + + it('has no answer at zero distance, which in this catalogue is the Sun', () => { + expect(absoluteMagnitude(-26.7, 0)).toBeNull(); + expect(absoluteMagnitude(5, -3)).toBeNull(); + expect(absoluteMagnitude(Number.NaN, 10)).toBeNull(); + }); +}); + +describe('bolometricCorrection', () => { + it('is never positive: a star always radiates outside the V band as well as in it', () => { + for (const type of ['O5V', 'B2V', 'A0V', 'F5V', 'G2V', 'K5V', 'M5V', 'M9V', 'Unknown', '']) { + expect(bolometricCorrection(type)).toBeLessThanOrEqual(0); + } + }); + + it('is small for the Sun and large for a red dwarf, which is the whole reason it is applied', () => { + // An M dwarf emits most of its light in the infrared: taking its V magnitude at face value + // understates it by more than a factor of ten. + expect(Math.abs(bolometricCorrection('G2V'))).toBeLessThan(0.2); + expect(bolometricCorrection('M5V')).toBeLessThan(-2); + }); + + it('reproduces the Sun own correction closely enough to close the loop on the zero point', () => { + // The two solar magnitudes differ by exactly this correction, so a solar twin must come out + // at one solar luminosity. + expect(SOLAR_ABSOLUTE_MAGNITUDE_V + bolometricCorrection('G2V')).toBeCloseTo(SOLAR_BOLOMETRIC_MAGNITUDE, 1); + }); + + it('deepens monotonically from F through M, following the shift into the infrared', () => { + const sequence = ['F0V', 'G0V', 'K0V', 'M0V', 'M5V'].map((type) => bolometricCorrection(type)); + for (let index = 1; index < sequence.length; index++) { + expect(sequence[index]).toBeLessThan(sequence[index - 1]); + } + }); + + it('falls back to a solar correction for an unclassified star rather than inventing one', () => { + expect(bolometricCorrection('Unknown')).toBeCloseTo(bolometricCorrection('G0V'), 6); + expect(bolometricCorrection(undefined)).toBeCloseTo(bolometricCorrection('G0V'), 6); + }); +}); + +describe('luminositySolar', () => { + it('returns exactly one for the Sun, which defines the unit', () => { + expect(luminositySolar({ magnitude: -26.7, distancePc: 0, spectralType: 'G2V' })).toBe(1); + }); + + it('lands within a factor of two of the published luminosity for real stars', () => { + // The documented tolerance. It is looser than it sounds: equilibrium temperature goes as the + // fourth root of this, so a factor of two is under a fifth in temperature. + for (const star of [SIRIUS, VEGA, PROXIMA, ALPHA_CEN_A]) { + const derived = luminositySolar(star)!; + const ratio = derived / star.publishedLuminosity; + expect(ratio).toBeGreaterThan(0.5); + expect(ratio).toBeLessThan(2); + } + }); + + it('gets a solar analogue essentially exactly right', () => { + // Alpha Centauri A is the nearest star to a second Sun there is, so this is the case where + // an error would be a mistake rather than a tolerance. + expect(luminositySolar(ALPHA_CEN_A)!).toBeCloseTo(ALPHA_CEN_A.publishedLuminosity, 0); + }); + + it('orders stars the way their published luminosities do', () => { + const derived = [PROXIMA, ALPHA_CEN_A, SIRIUS, VEGA].map((star) => luminositySolar(star)!); + for (let index = 1; index < derived.length; index++) { + expect(derived[index]).toBeGreaterThan(derived[index - 1]); + } + }); + + it('applies the bolometric correction rather than taking V at face value', () => { + // Without it a red dwarf comes out more than ten times too dim. + const uncorrected = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - absoluteMagnitude(PROXIMA.magnitude, PROXIMA.distancePc)!) / 2.5); + expect(luminositySolar(PROXIMA)!).toBeGreaterThan(uncorrected * 5); + }); + + it('clamps a pathological record instead of producing an absurd luminosity', () => { + const absurd = luminositySolar({ magnitude: -40, distancePc: 5000, spectralType: 'O5V' })!; + expect(Number.isFinite(absurd)).toBe(true); + expect(absurd).toBeLessThanOrEqual(1e7); + }); + + it('has no answer for a star with no usable distance', () => { + expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull(); + }); +}); diff --git a/src/app/shared/astro/stellar.ts b/src/app/shared/astro/stellar.ts new file mode 100644 index 0000000..04f847a --- /dev/null +++ b/src/app/shared/astro/stellar.ts @@ -0,0 +1,124 @@ +import { parseSpectralClass, SpectralClass } from './spectral'; + +/** + * Stellar luminosity, derived from the two things the star catalogue actually measures. + * + * Nothing here is a published luminosity: HYG carries apparent magnitude and a parallax, and + * the Exoplanet Archive columns that would give a host star's mass or effective temperature are + * not in the shipped dataset. What those two measurements do give, exactly, is absolute + * magnitude — and from there the bolometric correction below turns a V-band brightness into a + * total energy output, which is what a planet's temperature actually depends on. + */ + +/** The Sun's absolute magnitude in V — what the distance modulus below is measured against. */ +export const SOLAR_ABSOLUTE_MAGNITUDE_V = 4.83; + +/** + * The Sun's absolute *bolometric* magnitude, the IAU 2015 zero point. Distinct from the V-band + * figure above by the Sun's own bolometric correction, and it is the one the ratio is taken + * against — mixing the two would leave every luminosity 9% high. + */ +export const SOLAR_BOLOMETRIC_MAGNITUDE = 4.74; + +/** + * Bolometric corrections for main-sequence stars, at subclass 0 of each class (Pecaut & Mamajek + * 2013, rounded). Always negative: a star radiates outside the V band as well as in it, so its + * total output always exceeds what a visual magnitude alone implies. + * + * The correction matters most exactly where it is largest. An M dwarf emits the bulk of its + * light in the infrared, so taking its V magnitude at face value understates it by more than a + * factor of ten — and M dwarfs are what most of the nearby planet hosts are. + */ +const BOLOMETRIC_CORRECTION_ANCHORS: Readonly> = { + O: -4.0, + B: -3.0, + A: -0.25, + F: -0.01, + G: -0.06, + K: -0.24, + M: -1.21 +}; + +/** Correction at the cool end of class M, so the latest subclasses interpolate toward it. */ +const BEYOND_M_CORRECTION = -4.6; + +/** + * Range the derived luminosity is clamped to, in solar luminosities. + * + * A guard against the one systematic error this method cannot detect on its own: the + * corrections above assume a main-sequence star, and HYG often records a spectral class with no + * luminosity class at all. A red giant read as a K dwarf comes out hundreds of times too + * bright, which is a large error but not an unbounded one — these bounds simply keep a + * pathological record from producing a temperature of a million kelvin. + */ +const MIN_LUMINOSITY_SOLAR = 1e-6; +const MAX_LUMINOSITY_SOLAR = 1e7; + +/** + * Absolute magnitude from apparent magnitude and distance — the distance modulus. + * + * Returns `null` for a star at zero distance, which in this catalogue means the Sun: its + * apparent magnitude of -26.7 is a statement about how close it is, not about how bright it is, + * and the formula has no answer there. + */ +export function absoluteMagnitude(apparentMagnitude: number, distancePc: number): number | null { + if (!Number.isFinite(apparentMagnitude) || !Number.isFinite(distancePc) || distancePc <= 0) { + return null; + } + return apparentMagnitude - 5 * Math.log10(distancePc) + 5; +} + +/** + * Bolometric correction for a spectral type, interpolated between the class anchors. Falls back + * to the solar value when the catalogue records no usable classification, which biases a + * misclassified red dwarf dim rather than inventing a correction for it. + */ +export function bolometricCorrection(spectralType: string | null | undefined): number { + const parsed = parseSpectralClass(spectralType); + if (!parsed) { + return BOLOMETRIC_CORRECTION_ANCHORS.G; + } + + const { spectralClass, subclass } = parsed; + const classes = Object.keys(BOLOMETRIC_CORRECTION_ANCHORS) as SpectralClass[]; + const index = classes.indexOf(spectralClass); + const from = BOLOMETRIC_CORRECTION_ANCHORS[spectralClass]; + const to = index < classes.length - 1 ? BOLOMETRIC_CORRECTION_ANCHORS[classes[index + 1]] : BEYOND_M_CORRECTION; + const t = Math.min(Math.max(subclass, 0), 10) / 10; + + return from + (to - from) * t; +} + +/** Everything about a star that bears on how much light it puts out. */ +export interface StellarPhotometry { + /** Apparent visual magnitude, as catalogued. */ + magnitude: number; + /** Distance from the Sun in parsecs; `0` identifies the Sun itself. */ + distancePc: number; + spectralType?: string; +} + +/** + * Total luminosity in solar units. + * + * The Sun is returned as exactly 1 rather than derived — it is the definition of the unit, and + * it is the one star whose distance in this catalogue is zero. + * + * Accurate to roughly a factor of two for main-sequence stars, which is better than it sounds + * for what it is used for: a planet's equilibrium temperature goes as the fourth root of this, + * so even a factor of two moves a temperature by less than a fifth. + */ +export function luminositySolar(star: StellarPhotometry): number | null { + if (star.distancePc === 0) { + return 1; + } + + const absolute = absoluteMagnitude(star.magnitude, star.distancePc); + if (absolute === null) { + return null; + } + + const bolometric = absolute + bolometricCorrection(star.spectralType); + const luminosity = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - bolometric) / 2.5); + return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR); +} diff --git a/src/app/shared/rendering/procedural-planet-texture.spec.ts b/src/app/shared/rendering/procedural-planet-texture.spec.ts new file mode 100644 index 0000000..4695d9d --- /dev/null +++ b/src/app/shared/rendering/procedural-planet-texture.spec.ts @@ -0,0 +1,162 @@ +import * as THREE from 'three/webgpu'; +import { describe, expect, it } from 'vitest'; + +import { PlanetAppearance, PlanetClass, paletteFor, planetAppearance } from '../astro/planet-appearance'; +import { averageColor, planetTexture, renderPlanetTexture } from './procedural-planet-texture'; + +const SIZE = { width: 64, height: 32 }; +const ALL_CLASSES: PlanetClass[] = ['lava', 'scorched', 'iron', 'rocky', 'temperate', 'icy', 'subNeptune', 'iceGiant', 'gasGiant', 'hotGasGiant']; + +function appearanceOf(planetClass: PlanetClass, overrides: Partial = {}): PlanetAppearance { + return { + planetClass, + palette: paletteFor(planetClass), + equilibriumTemperatureK: 250, + bulkDensityGramsPerCm3: 5, + polarCapExtentDeg: null, + seed: 12345, + ...overrides + }; +} + +/** RGB of one texel, 0-255. */ +function texelAt(pixels: Uint8Array, width: number, column: number, row: number): [number, number, number] { + const offset = (row * width + column) * 4; + return [pixels[offset], pixels[offset + 1], pixels[offset + 2]]; +} + +function difference(a: readonly number[], b: readonly number[]): number { + return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]); +} + +describe('renderPlanetTexture', () => { + it('fills an opaque RGBA buffer of the requested size', () => { + const pixels = renderPlanetTexture(appearanceOf('rocky'), SIZE); + + expect(pixels).toHaveLength(SIZE.width * SIZE.height * 4); + for (let index = 3; index < pixels.length; index += 4) { + expect(pixels[index]).toBe(255); + } + }); + + it('is the same surface every time, so a world does not change between visits', () => { + const first = renderPlanetTexture(appearanceOf('gasGiant'), SIZE); + const second = renderPlanetTexture(appearanceOf('gasGiant'), SIZE); + expect(Array.from(second)).toEqual(Array.from(first)); + }); + + it('gives two different worlds two different surfaces', () => { + const a = renderPlanetTexture(appearanceOf('rocky', { seed: 1 }), SIZE); + const b = renderPlanetTexture(appearanceOf('rocky', { seed: 2 }), SIZE); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); + + it('wraps continuously around the seam, since the noise is sampled on the sphere', () => { + // The reason for sampling a solid field along the sphere rather than a plane: 2D noise would + // have to be stitched at this seam by hand, and would still pinch at the poles. + const pixels = renderPlanetTexture(appearanceOf('rocky'), { width: 256, height: 128 }); + for (const row of [10, 64, 120]) { + const left = texelAt(pixels, 256, 0, row); + const right = texelAt(pixels, 256, 255, row); + const neighbouring = texelAt(pixels, 256, 1, row); + // The two edge columns are neighbours on the sphere, so they must differ no more than any + // other adjacent pair does. + expect(difference(left, right)).toBeLessThanOrEqual(difference(left, neighbouring) + 12); + } + }); + + it('varies with latitude, which is what makes a banded world banded', () => { + const pixels = renderPlanetTexture(appearanceOf('gasGiant'), { width: 128, height: 64 }); + const column = 40; + let maximumStep = 0; + for (let row = 1; row < 64; row++) { + maximumStep = Math.max(maximumStep, difference(texelAt(pixels, 128, column, row), texelAt(pixels, 128, column, row - 1))); + } + expect(maximumStep).toBeGreaterThan(0); + }); + + it('paints a polar cap when the derived temperature calls for one, and not otherwise', () => { + const withCap = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: 40 }), SIZE); + const without = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: null }), SIZE); + + const pole = 0; + const equator = SIZE.height / 2; + // At the pole the capped world is markedly brighter; at the equator the two agree. + const capPole = texelAt(withCap, SIZE.width, 10, pole); + const barePole = texelAt(without, SIZE.width, 10, pole); + expect(capPole[0] + capPole[1] + capPole[2]).toBeGreaterThan(barePole[0] + barePole[1] + barePole[2] + 60); + expect(difference(texelAt(withCap, SIZE.width, 10, equator), texelAt(without, SIZE.width, 10, equator))).toBe(0); + }); + + it('grows the cap further toward the equator as the world gets colder', () => { + const brightnessAt = (extent: number, row: number): number => { + const pixels = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: extent }), SIZE); + const [r, g, b] = texelAt(pixels, SIZE.width, 20, row); + return r + g + b; + }; + const midLatitude = 6; + expect(brightnessAt(80, midLatitude)).toBeGreaterThan(brightnessAt(20, midLatitude)); + }); + + it('draws a banded world and a terrain world differently from the same seed', () => { + const banded = renderPlanetTexture(appearanceOf('gasGiant'), SIZE); + const terrain = renderPlanetTexture(appearanceOf('rocky'), SIZE); + expect(Array.from(banded)).not.toEqual(Array.from(terrain)); + }); + + it('keeps a hot giant red and an ice giant blue, end to end', () => { + const hot = averageColor(renderPlanetTexture(appearanceOf('hotGasGiant'), SIZE)); + const ice = averageColor(renderPlanetTexture(appearanceOf('iceGiant'), SIZE)); + + expect(hot.r).toBeGreaterThan(hot.b); + expect(ice.b).toBeGreaterThan(ice.r); + }); + + it('produces no NaN or out-of-range bytes for any class', () => { + for (const planetClass of ALL_CLASSES) { + const pixels = renderPlanetTexture(appearanceOf(planetClass, { polarCapExtentDeg: 30 }), SIZE); + for (const value of pixels) { + expect(Number.isInteger(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(255); + } + } + }); +}); + +describe('planetTexture', () => { + it('builds a data texture at the requested size, with no canvas involved', () => { + // A DataTexture rather than a CanvasTexture: the pixels are computed, not drawn, so this + // works in an environment with no 2D context at all — which is this one. + const texture = planetTexture(planetAppearance({ id: 'earth', radiusEarth: 1, massEarth: 1, semiMajorAxisAu: 1, hostLuminositySolar: 1 }), SIZE); + + expect(texture.image.width).toBe(SIZE.width); + expect(texture.image.height).toBe(SIZE.height); + expect(texture.image.data).toHaveLength(SIZE.width * SIZE.height * 4); + }); + + it('caches per body and size, so a system of planets is not re-rendered every frame', () => { + const appearance = planetAppearance({ id: 'mars', radiusEarth: 0.53, semiMajorAxisAu: 1.52, hostLuminositySolar: 1 }); + expect(planetTexture(appearance, SIZE)).toBe(planetTexture(appearance, SIZE)); + expect(planetTexture(appearance, SIZE)).not.toBe(planetTexture(appearance, { width: 32, height: 16 })); + }); + + it('wraps in longitude and clamps in latitude, matching what the sphere actually does', () => { + const texture = planetTexture(appearanceOf('icy'), SIZE); + expect(texture.wrapS).toBe(THREE.RepeatWrapping); + expect(texture.wrapT).toBe(THREE.ClampToEdgeWrapping); + }); +}); + +describe('averageColor', () => { + it('averages a uniform buffer to that colour', () => { + const pixels = new Uint8Array(16); + for (let index = 0; index < pixels.length; index += 4) { + pixels.set([255, 128, 0, 255], index); + } + const average = averageColor(pixels); + expect(average.r).toBeCloseTo(1, 6); + expect(average.g).toBeCloseTo(128 / 255, 6); + expect(average.b).toBeCloseTo(0, 6); + }); +}); diff --git a/src/app/shared/rendering/procedural-planet-texture.ts b/src/app/shared/rendering/procedural-planet-texture.ts new file mode 100644 index 0000000..1c64494 --- /dev/null +++ b/src/app/shared/rendering/procedural-planet-texture.ts @@ -0,0 +1,245 @@ +import * as THREE from 'three/webgpu'; + +import { PlanetAppearance, Rgb } from '../astro/planet-appearance'; + +/** + * Paints an equirectangular surface for a world from its derived appearance. + * + * Two things shape it, and both come out of the physics rather than out of taste. A body with a + * fluid envelope and no surface gets zonal *bands*, because a rapidly rotating atmosphere + * organises into them — that is why Jupiter looks the way it does. A body with a solid surface + * gets *terrain*, fractal highlands and basins, because that is what an impacted, eroded crust + * looks like at planetary scale. The polar caps then grow and shrink with the derived + * equilibrium temperature. + * + * Written against a plain `Uint8Array` rather than a canvas, which makes it a pure function: + * fully testable with no DOM, no 2D context to be unavailable, and no per-pixel draw calls. + */ + +/** Size for the body-detail view, where the surface fills the screen. */ +export const DETAIL_TEXTURE_WIDTH = 512; +export const DETAIL_TEXTURE_HEIGHT = 256; +/** + * Size for a system-view marker, which is a few pixels across. Deliberately tiny: a system can + * hold twenty bodies and they are all generated at once as the camera arrives, so this is the + * size at which that whole set costs less than a frame. + */ +export const MARKER_TEXTURE_WIDTH = 32; +export const MARKER_TEXTURE_HEIGHT = 16; + +const TERRAIN_OCTAVES = 4; +const ROUGHNESS_OCTAVES = 3; +const BAND_TURBULENCE_OCTAVES = 3; +/** Zonal bands per hemisphere, varied a little per body so no two giants are identical. */ +const MIN_BANDS = 7; +const MAX_BANDS = 14; + +/** Hash of three lattice coordinates and a seed to a value in [0, 1). */ +function hash3(x: number, y: number, z: number, seed: number): number { + let h = seed ^ Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(z | 0, 2147483647); + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} + +/** Hermite fade, so the interpolated field has no visible lattice creases. */ +function fade(t: number): number { + return t * t * (3 - 2 * t); +} + +/** + * Value noise sampled in three dimensions. + * + * Three rather than two on purpose: the texture is equirectangular, so 2D noise would have to + * be made to wrap by hand at the seam and would still pinch at the poles. Sampling a solid + * field along the sphere's own surface has neither problem — the field is continuous + * everywhere the sphere is. + */ +function valueNoise3(x: number, y: number, z: number, seed: number): number { + const xi = Math.floor(x); + const yi = Math.floor(y); + const zi = Math.floor(z); + const xf = fade(x - xi); + const yf = fade(y - yi); + const zf = fade(z - zi); + + const c000 = hash3(xi, yi, zi, seed); + const c100 = hash3(xi + 1, yi, zi, seed); + const c010 = hash3(xi, yi + 1, zi, seed); + const c110 = hash3(xi + 1, yi + 1, zi, seed); + const c001 = hash3(xi, yi, zi + 1, seed); + const c101 = hash3(xi + 1, yi, zi + 1, seed); + const c011 = hash3(xi, yi + 1, zi + 1, seed); + const c111 = hash3(xi + 1, yi + 1, zi + 1, seed); + + const x00 = c000 + (c100 - c000) * xf; + const x10 = c010 + (c110 - c010) * xf; + const x01 = c001 + (c101 - c001) * xf; + const x11 = c011 + (c111 - c011) * xf; + const y0 = x00 + (x10 - x00) * yf; + const y1 = x01 + (x11 - x01) * yf; + + return y0 + (y1 - y0) * zf; +} + +/** Fractal Brownian motion: octaves of value noise at doubling frequency, halving amplitude. */ +function fbm(x: number, y: number, z: number, seed: number, octaves: number): number { + let amplitude = 1; + let frequency = 1; + let sum = 0; + let total = 0; + + for (let octave = 0; octave < octaves; octave++) { + sum += amplitude * valueNoise3(x * frequency, y * frequency, z * frequency, seed + octave * 7919); + total += amplitude; + amplitude *= 0.5; + frequency *= 2; + } + + return sum / total; +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function mix(a: Rgb, b: Rgb, t: number): [number, number, number] { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +/** Three-stop ramp across the palette's low, mid and high tones. */ +function ramp(appearance: PlanetAppearance, t: number): [number, number, number] { + const { low, mid, high } = appearance.palette; + const clamped = clamp01(t); + return clamped < 0.5 ? mix(low, mid, clamped * 2) : mix(mid, high, (clamped - 0.5) * 2); +} + +/** Pulls a value toward or away from the midpoint, by the palette's contrast. */ +function applyContrast(value: number, contrast: number): number { + return clamp01(0.5 + (value - 0.5) * (0.4 + contrast * 1.2)); +} + +export interface PlanetTextureSize { + width: number; + height: number; +} + +/** + * Renders the surface into an RGBA byte array, row-major from the north pole down, ready to + * hand to a `DataTexture`. + */ +export function renderPlanetTexture(appearance: PlanetAppearance, size: PlanetTextureSize): Uint8Array { + const { width, height } = size; + const pixels = new Uint8Array(width * height * 4); + const { palette, seed } = appearance; + const banded = palette.structure === 'banded'; + // Band count is stable per body but not identical between bodies, so a system of giants does + // not read as the same planet drawn several times. + const bandCount = MIN_BANDS + (seed % (MAX_BANDS - MIN_BANDS + 1)); + const capExtent = appearance.polarCapExtentDeg; + + for (let row = 0; row < height; row++) { + // Texel centres, so the poles are sampled just inside the surface rather than exactly on it. + const v = (row + 0.5) / height; + const latitude = (0.5 - v) * Math.PI; + const cosLatitude = Math.cos(latitude); + const sinLatitude = Math.sin(latitude); + + for (let column = 0; column < width; column++) { + const u = (column + 0.5) / width; + const longitude = u * Math.PI * 2; + // The point on the unit sphere this texel maps to — the noise is sampled there. + const px = cosLatitude * Math.cos(longitude); + const py = sinLatitude; + const pz = cosLatitude * Math.sin(longitude); + + let tone: number; + if (banded) { + // Latitude, pushed around by turbulence, then folded into zonal bands. The turbulence is + // what makes a belt wander and braid rather than sit as a perfect stripe. + const turbulence = fbm(px * 2.2, py * 2.2, pz * 2.2, seed, BAND_TURBULENCE_OCTAVES) - 0.5; + // Stretched along longitude and squeezed in latitude, which is what shear does to a + // cloud: the streaks run round the planet rather than across it. + const fine = fbm(px * 6, py * 30, pz * 6, seed + 101, 3) - 0.5; + const warped = latitude + turbulence * 0.32 + fine * 0.05; + tone = 0.5 + 0.5 * Math.sin(warped * bandCount); + tone = tone * 0.85 + (fine + 0.5) * 0.15; + } else { + // Broad landmasses, then finer detail on top of them. The ridged term is the same noise + // folded about its midpoint, which turns smooth hills into creases — the difference + // between a surface that reads as cloud and one that reads as ground. + const continents = fbm(px * 2.4, py * 2.4, pz * 2.4, seed, TERRAIN_OCTAVES); + const roughness = fbm(px * 18, py * 18, pz * 18, seed + 313, ROUGHNESS_OCTAVES); + const ridged = 1 - Math.abs(2 * roughness - 1); + tone = continents * 0.68 + roughness * 0.18 + ridged * 0.14; + } + + let [r, g, b] = ramp(appearance, applyContrast(tone, palette.contrast)); + + if (capExtent !== null && capExtent > 0) { + // Caps are ragged rather than a clean circle: the same surface noise that shapes the + // terrain decides how far the ice reaches at each longitude. + const latitudeFromPoleDeg = 90 - (Math.abs(latitude) * 180) / Math.PI; + const edge = capExtent * (0.85 + 0.3 * fbm(px * 6, py * 6, pz * 6, seed + 977, 3)); + const coverage = clamp01((edge - latitudeFromPoleDeg) / Math.max(edge * 0.35, 1)); + if (coverage > 0) { + [r, g, b] = mix([r, g, b], palette.cap, coverage); + } + } + + const offset = (row * width + column) * 4; + pixels[offset] = Math.round(clamp01(r) * 255); + pixels[offset + 1] = Math.round(clamp01(g) * 255); + pixels[offset + 2] = Math.round(clamp01(b) * 255); + pixels[offset + 3] = 255; + } + } + + return pixels; +} + +const textureCache = new Map(); + +/** + * The rendered surface as a Three.js texture, cached per body and size. + * + * A `DataTexture` rather than a `CanvasTexture`: the pixels are computed rather than drawn, so + * there is no reason to route them through a 2D context that may not exist — which also means + * this works under a headless test environment where canvas rendering does not. + */ +export function planetTexture(appearance: PlanetAppearance, size: PlanetTextureSize = { width: DETAIL_TEXTURE_WIDTH, height: DETAIL_TEXTURE_HEIGHT }): THREE.DataTexture { + const key = `${appearance.seed}:${appearance.planetClass}:${appearance.polarCapExtentDeg ?? 'none'}:${size.width}x${size.height}`; + const cached = textureCache.get(key); + if (cached) { + return cached; + } + + const texture = new THREE.DataTexture(renderPlanetTexture(appearance, size), size.width, size.height, THREE.RGBAFormat); + texture.colorSpace = THREE.SRGBColorSpace; + // Wraps in longitude — the noise is continuous across the seam — but is clamped in latitude, + // where there is nothing beyond the pole to wrap to. + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + texture.needsUpdate = true; + + textureCache.set(key, texture); + return texture; +} + +/** Average colour of a rendered surface, for anything too small to show the texture itself. */ +export function averageColor(pixels: Uint8Array): THREE.Color { + let r = 0; + let g = 0; + let b = 0; + const count = pixels.length / 4; + + for (let index = 0; index < pixels.length; index += 4) { + r += pixels[index]; + g += pixels[index + 1]; + b += pixels[index + 2]; + } + + return new THREE.Color(r / count / 255, g / count / 255, b / count / 255); +} diff --git a/src/app/shared/rendering/texture-catalog.ts b/src/app/shared/rendering/texture-catalog.ts index 0099a9d..0d1d80d 100644 --- a/src/app/shared/rendering/texture-catalog.ts +++ b/src/app/shared/rendering/texture-catalog.ts @@ -2,9 +2,12 @@ import * as THREE from 'three/webgpu'; /** * Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time, - * keyed by the same ids used in `bodies.json`. Bodies without an entry here (most exoplanets, - * a few moons whose photo wasn't sourced this round, and any future body) fall back to - * `proceduralBodyTexture()` below rather than a flat color. + * keyed by the same ids used in `bodies.json`. + * + * This map is the whole of what has actually been photographed. Everything else — every + * exoplanet, since not one has ever been imaged, and the moons no probe returned a usable map + * of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's + * own measured size, mass, orbit and host star instead. * * Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia * Commons — see each file's Commons page for the original credit line): @@ -80,61 +83,3 @@ export function loadCachedTexture(path: string): THREE.Texture { loadedTextures.set(path, texture); return texture; } - -const proceduralTextureCache = new Map(); - -/** - * Generates a simple procedural surface for bodies with no real photograph available — mainly - * exoplanets, whose actual surfaces have never been directly imaged. This is an honest artistic - * stand-in (mottled bands tinted by the body's classification color), not a fabricated "real" - * texture, and is cached per color so repeated exoplanets of the same kind share one canvas. - * Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom - * environment with no canvas backend); callers should fall back to a flat material color. - */ -export function proceduralBodyTexture(baseColor: THREE.ColorRepresentation): THREE.CanvasTexture | undefined { - const key = new THREE.Color(baseColor).getHexString(); - const cached = proceduralTextureCache.get(key); - if (cached) { - return cached; - } - - const size = 256; - const canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; - const context = canvas.getContext('2d'); - if (!context) { - return undefined; - } - - const base = new THREE.Color(baseColor); - const light = base.clone().offsetHSL(0, -0.15, 0.14); - const dark = base.clone().offsetHSL(0, 0.05, -0.16); - - context.fillStyle = `#${base.getHexString()}`; - context.fillRect(0, 0, size, size); - - // A handful of horizontal-ish noisy bands, reminiscent of banded gas giants / mottled rock, - // without claiming to depict any specific real surface feature. - let seed = key.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) || 1; - const random = () => { - seed = (seed * 1103515245 + 12345) & 0x7fffffff; - return seed / 0x7fffffff; - }; - - const bandCount = 10; - for (let i = 0; i < bandCount; i++) { - const y = (i / bandCount) * size + random() * (size / bandCount) * 0.4; - const height = size / bandCount * (0.5 + random() * 0.6); - context.fillStyle = `#${(random() > 0.5 ? light : dark).getHexString()}`; - context.globalAlpha = 0.35 + random() * 0.25; - context.fillRect(0, y, size, height); - } - context.globalAlpha = 1; - - const texture = new THREE.CanvasTexture(canvas); - texture.colorSpace = THREE.SRGBColorSpace; - texture.wrapS = THREE.RepeatWrapping; - proceduralTextureCache.set(key, texture); - return texture; -}