Derive a surface for every body that was never photographed
Fifteen bodies here have a real photograph. Every exoplanet does not, and never will on current instruments — none has ever been imaged — and nor do several of the solar system's own moons. Those all shared one crude stand-in: a few noisy bands tinted by category, cached per colour, so every exoplanet in the app was literally the same picture. They now get a surface reasoned from what has actually been measured. The chain is standard at every link. A 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 more than tenfold, and M dwarfs are what most nearby planet hosts are. Luminosity and the semi-major axis then give an equilibrium temperature, mass and radius give a bulk density, and size, temperature and density together give a class of world. Checked against the solar system the temperatures land on Earth 255 K, Jupiter 112 K, Neptune 46 K, all within a kelvin or two of published values, and 51 Pegasi b comes out at 1227 K against a published 1200. Each class carries a palette reasoned from its chemistry — methane absorbs red light, which is why the ice giants are blue — and a structure: 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, which is the clearest visible consequence of the whole chain. 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 byte array rather than a canvas — a pure function, testable, with no 2D context to be unavailable. Two things the derivation cannot do, both stated on screen next to the measurements it rests on. Equilibrium temperature ignores greenhouse warming and internal heat, so Venus comes out at 300 K against a real surface of 737 K and Io, kept molten by tides, classifies as ice. And these are illustrations: reasoned, but not observations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Record<PlanetClass, string>> = {
|
||||
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<Record<PlanetClass, PlanetPalette>> = {
|
||||
// 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)
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<Record<SpectralClass, number>> = {
|
||||
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);
|
||||
}
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, THREE.DataTexture>();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -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<string, THREE.CanvasTexture>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user