Files
star-map/src/app/shared/rendering/texture-catalog.ts
T
SenrokaiandClaude Opus 5.5 48fd6fd0e5 Stop telling the reader that the hundred directly imaged exoplanets were never imaged
Every exoplanet card without a map ended "Not an observation — no image of this world exists.",
and 395b613 wrote that rule into a comment ("Only an exoplanet has never been imaged") and a test.
The NASA Exoplanet Archive flags 102 planets as detected by imaging (ima_flag), all 102 of them in
exoplanets.json: HR 8799 b to e (Marois et al. 2008), bet Pic b, 51 Eri b, AF Lep b, and bet Pic c
and eps Ind A b, found by radial velocity and imaged since. The review read the sentence on the
live pages of HR 8799 b, 51 Eri b and bet Pic b.

The ETL now asks the archive for those names in a query of its own, cached apart from the main
table so the other 6 252 planets stay on the snapshot they were built from, and carries
`imaged: true` on the matching records. exoplanets.json changes by that field on 102 records and
nothing else (compared record by record). Their cards now end "Not an observation — it has been
imaged only as a point of light beside its star, and no map of it exists."; the rest keep "no
image of this world exists". The provenance comment and the texture catalogue's comment say which
is which.

Checked live on :4301: HR 8799 b and eps Ind A b carry the new sentence, Kepler-22 b the old one.

Tests: body-view-model.spec 'says a directly imaged exoplanet was seen as a point of light, not
that no image of it exists'; the old test is renamed 'says an exoplanet the archive does not flag
as imaged has no image'. Unit controls, each failing that test only (1 failed, 840 passed): the
provenance ignoring the flag; the view model dropping it. build.ts now requires at least 95
imaged planets (measured 102); control, the full ETL with the join made on the host's name
instead of the planet's, fails with "Only 0 exoplanets are marked as imaged".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:50:04 +02:00

155 lines
7.0 KiB
TypeScript

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`.
*
* Only surface *maps* belong here: equirectangular images, twice as wide as tall, that wrap a
* sphere. Everything else — every exoplanet, since the few imaged were seen only as points of
* light, and every moon
* or dwarf planet with no such map in the repository — falls through to
* `procedural-planet-texture.ts`, which derives a surface from the body's own measured size,
* mass, orbit and host star instead.
*
* Io, Pluto, Titan and Deimos used to be listed with square photographs of them: pictures of a
* lit disc against black sky, not maps, which wrapped round a sphere put black sky on a fifth to a
* third of the surface. All four are now global mosaics like the other moons'.
*
* Provenance (CC BY 4.0 Solar System Scope, via Wikimedia Commons — see each file's Commons page
* for the original credit line): mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/
* saturn-ring/skybox — Solar System Scope texture pack; jupiter — Solar System Scope 8k pack.
*
* The moons', Ceres's and Pluto's are public-domain mission mosaics from USGS Astrogeology and the
* PDS, each put in the same frame — longitude 0 in the middle, east to the right — and each
* measured, in `assets/textures/README.md`. Where a probe saw only part of a body (Pluto, Charon,
* Triton, Phoebe, the Galilean poles), the rest is a flat grey, never invented terrain.
*/
const BODY_TEXTURE_PATHS: Record<string, string> = {
mercury: 'assets/textures/bodies/mercury.jpg',
venus: 'assets/textures/bodies/venus.jpg',
earth: 'assets/textures/bodies/earth.jpg',
mars: 'assets/textures/bodies/mars.jpg',
jupiter: 'assets/textures/bodies/jupiter.jpg',
saturn: 'assets/textures/bodies/saturn.jpg',
uranus: 'assets/textures/bodies/uranus.jpg',
neptune: 'assets/textures/bodies/neptune.jpg',
moon: 'assets/textures/bodies/moon.jpg',
phobos: 'assets/textures/bodies/phobos.jpg',
deimos: 'assets/textures/bodies/deimos.jpg',
io: 'assets/textures/bodies/io.jpg',
europa: 'assets/textures/bodies/europa.jpg',
ganymede: 'assets/textures/bodies/ganymede.jpg',
callisto: 'assets/textures/bodies/callisto.jpg',
mimas: 'assets/textures/bodies/mimas.jpg',
enceladus: 'assets/textures/bodies/enceladus.jpg',
tethys: 'assets/textures/bodies/tethys.jpg',
dione: 'assets/textures/bodies/dione.jpg',
rhea: 'assets/textures/bodies/rhea.jpg',
titan: 'assets/textures/bodies/titan.jpg',
iapetus: 'assets/textures/bodies/iapetus.jpg',
phoebe: 'assets/textures/bodies/phoebe.jpg',
triton: 'assets/textures/bodies/triton.jpg',
ceres: 'assets/textures/bodies/ceres.jpg',
pluto: 'assets/textures/bodies/pluto.jpg',
charon: 'assets/textures/bodies/charon.jpg'
};
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
export const SUN_TEXTURE_PATH = 'assets/textures/bodies/sun.jpg';
export const SATURN_RING_TEXTURE_PATH = 'assets/textures/bodies/saturn_ring.png';
export const MILKY_WAY_SKYBOX_PATH = 'assets/textures/skybox/milkyway.jpg';
/** True for the handful of bodies that have real photographic atmospheres worth glowing. */
const ATMOSPHERE_BY_ID: Record<string, THREE.ColorRepresentation> = {
venus: 0xf3dfa6,
earth: 0x7fb8ff,
mars: 0xd9a066,
jupiter: 0xe8d3ad,
saturn: 0xe0d2a8,
uranus: 0x9fe8e8,
neptune: 0x5b7fff,
titan: 0xf0b25c
};
export function bodyTexturePath(id: string): string | undefined {
return BODY_TEXTURE_PATHS[id];
}
export function atmosphereColorFor(id: string): THREE.ColorRepresentation | undefined {
return ATMOSPHERE_BY_ID[id];
}
/**
* The radii, in km from Saturn's centre, that `saturn_ring.png`'s left and right edges stand for.
*
* The strip runs straight out from its left edge to its right, and read off its alpha the ring
* edges fall where one scale puts them: the C ring's inner edge (74 490 km) at 91 of its 1 280 px,
* the B ring's inner edge (92 000) at 404.5 and outer (117 580) at 860, the A ring's outer edge
* (136 775) at 1 204 and the F ring (140 180) at 1 267.5 — all within 1.8 px of 55.9 km a pixel.
* The one miss is the Cassini Division's outer edge (122 170), which the strip draws 30 px (1 700
* km) too far in. The edges are not the 74 500 and 140 220 km of the C ring and the F ring: sized to
* those, the B ring's inner edge would sit 3 300 km out.
*/
export const SATURN_RING_INNER_KM = 69_400;
export const SATURN_RING_OUTER_KM = 141_000;
/**
* Saturn's rings, flat in the equator of a sphere built round +Y — its XZ plane — and sized
* against the planet as drawn: `drawnRadius` for Saturn's `planetRadiusKm`, so the rings keep their
* true proportion to the planet wherever it is drawn and however it is scaled.
*
* `RingGeometry`'s own UVs wrap round the angle, so u is set to the distance from the centre instead,
* which is the way the strip runs. Lit, from both faces: the face turned to the Sun is lit by the
* height of the Sun above the ring plane, and the other falls dark. Nothing in the app casts a
* shadow, so neither the planet on the rings nor the rings on the planet do.
*/
export function saturnRing(planetRadiusKm: number, drawnRadius: number): THREE.Mesh {
const unitsPerKm = drawnRadius / planetRadiusKm;
const inner = SATURN_RING_INNER_KM * unitsPerKm;
const outer = SATURN_RING_OUTER_KM * unitsPerKm;
const geometry = new THREE.RingGeometry(inner, outer, 128, 1).rotateX(-Math.PI / 2);
const position = geometry.attributes['position'];
const uv = geometry.attributes['uv'];
const vertex = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
uv.setXY(i, THREE.MathUtils.clamp((vertex.length() - inner) / (outer - inner), 0, 1), 1);
}
// The strip's own alpha is the rings' opacity: dense in the B ring, thin in the C ring.
const material = new THREE.MeshStandardMaterial({
map: loadCachedTexture(SATURN_RING_TEXTURE_PATH),
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
roughness: 1,
metalness: 0
});
return new THREE.Mesh(geometry, material);
}
const textureLoader = new THREE.TextureLoader();
const loadedTextures = new Map<string, THREE.Texture>();
/**
* Loads (and caches) a texture by asset path, applying `colorSpace` so JPEG/PNG source
* photography matches Three.js's expected sRGB working space. Non-blocking: the texture is
* returned immediately and updates in place once the image data arrives (or errors, which is
* logged rather than thrown so a slow/unavailable network never breaks the scene).
*/
export function loadCachedTexture(path: string): THREE.Texture {
const cached = loadedTextures.get(path);
if (cached) {
return cached;
}
const texture = textureLoader.load(
path,
undefined,
undefined,
(error) => console.error(`Failed to load texture "${path}".`, error)
);
texture.colorSpace = THREE.SRGBColorSpace;
loadedTextures.set(path, texture);
return texture;
}