Draw Saturn's rings in the system view, lit, at the radii their texture draws

Audit #47. The body page's rings already lie in Saturn's equator (76fb386),
but the system view drew Saturn as a bare sphere, and the page sized the rings
to 1.4-2.6 planet radii regardless of what saturn_ring.png draws where.

The strip runs straight out from its left edge to its right. Read off its
alpha, the C ring's inner edge (74 490 km) is at px 91 of 1 280, the B ring's
inner and outer edges (92 000 and 117 580 km) at 404.5 and 860, the A ring's
outer edge (136 775 km) at 1 204 and the F ring (140 180 km) at 1 267.5: one
scale of 55.9 km a pixel fits all five within 1.8 px, so the strip spans
69 400 to 141 000 km. Only the Cassini Division's outer edge misses, drawn
30 px (1 700 km) too far in. Sized to the brief's 74 500 and 140 220 km (the C
ring's inner edge and the F ring) instead, the B ring's inner edge would sit
3 300 km out.

saturnRing (texture-catalog.ts) now builds the rings for both views: flat in
the XZ plane of a sphere built round +Y, sized against the planet as drawn,
MeshStandardMaterial lit from both faces, the strip's own alpha as opacity
(the page used the texture as its own alphaMap too, multiplying its alpha by
its green channel, at 0.85 opacity). In the system view the ring is a child of
Saturn's marker, so the IAU pole turns it into the equator and the pixel floor
scales it with the planet; a ray through it picks Saturn (memberForObject
accepts a marker's child). On the page it now reaches 2.42 radii, not 2.6.
Jupiter's, Uranus's and Neptune's rings are left out: dark, narrow or dusty,
too faint to see at any scale drawn here.

Measured in the running app, the ring's opening to Earth against Horizons'
sub-Earth latitude on Saturn (planetodetic, taken back to planetocentric with
f = 0.09796): 26.963 against 26.966 degrees on 2017-10-16, 0.075 against
0.042 on 2025-03-23, the plane crossing, and -7.764 against -7.813 on
2026-09-24. The Sun stood 26.64, 0.70 and -7.50 degrees above the ring plane
on those dates. At the closest the system view allows, 0.05 AU, Saturn is
about 5 px in radius and its rings reach about 12 px; on the plane-crossing
date they vanish edge-on.

Tests: the three openings against frozen Horizons values and a pick through
the B ring (system-orbits-renderer.spec.ts); the rings' extent, their lying
in the sphere's equator, and the strip sampled outwards so its B ring starts
at 92 000 km and its A ring ends at 136 775 (texture-catalog.spec.ts). Unit
suite 792 -> 799 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 23:42:48 +02:00
co-authored by Claude Opus 5.5
parent 302fa963ad
commit dc20accfdf
6 changed files with 158 additions and 40 deletions
@@ -9,7 +9,7 @@ import { EngineService } from '../../core/engine/engine.service';
import { bodyPageView } from '../../shared/rendering/body-orientation';
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, saturnRing } from '../../shared/rendering/texture-catalog';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
@@ -192,8 +192,10 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
this.disposeRing();
this.disposeGlow();
if (this.scene) {
if (viewModel.id === 'saturn') {
this.ring = this.buildSaturnRing();
if (viewModel.id === 'saturn' && this.body) {
// Flat in the page's horizontal, which is Saturn's equator: the planet is drawn pole up, at
// unit radius. They used to reach 2.6 radii out; the outermost ring the texture draws is 2.42.
this.ring = saturnRing(this.body.radiusKm, 1);
this.scene.add(this.ring);
}
const atmosphereColor = atmosphereColorFor(viewModel.id);
@@ -204,39 +206,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
}
}
/**
* Saturn's rings, built from a real ring-transparency map. `RingGeometry`'s default UVs wrap
* around the angle rather than the radius, so the per-vertex U is remapped to distance from
* center — the standard fix for sampling a radially-varying ring texture correctly.
*/
private buildSaturnRing(): THREE.Mesh {
const geometry = new THREE.RingGeometry(1.4, 2.6, 128, 1);
const position = geometry.attributes['position'];
const uv = geometry.attributes['uv'];
const vertex = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
const radialFraction = THREE.MathUtils.clamp((vertex.length() - 1.4) / (2.6 - 1.4), 0, 1);
uv.setXY(i, radialFraction, 1);
}
const ringTexture = loadCachedTexture(SATURN_RING_TEXTURE_PATH);
const material = new THREE.MeshBasicMaterial({
map: ringTexture,
alphaMap: ringTexture,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
depthWrite: false
});
const ring = new THREE.Mesh(geometry, material);
// Flat in Saturn's equator, which is the page's horizontal: the planet is drawn pole up. The
// 17 degrees they used to lean put them out of the equator they orbit in.
ring.rotation.x = Math.PI / 2;
return ring;
}
private disposeRing(): void {
if (!this.ring) {
return;
@@ -513,6 +513,7 @@ describe('solar-system bodies against Horizons', () => {
earth: {poleRaDeg: [0, -0.641, 0], poleDecDeg: [90, -0.557, 0], primeMeridianDeg: [190.147, 360.9856235, 0]},
mars: {poleRaDeg: [317.269202, -0.10927547, 0], poleDecDeg: [54.432516, -0.05827105, 0], primeMeridianDeg: [176.049863, 350.891982443297, 0], terms: [{angleDeg: [79.398797, 0.5042615, 0], ra: 0.419057, dec: 0, pm: 0}, {angleDeg: [166.325722, 0.5042615, 0], ra: 0, dec: 1.591274, pm: 0}, {angleDeg: [95.391654, 0.5042615, 0], ra: 0, dec: 0, pm: 0.584542}]},
jupiter: {poleRaDeg: [268.056595, -0.006499, 0], poleDecDeg: [64.495303, 0.002413, 0], primeMeridianDeg: [284.95, 870.536, 0]},
saturn: {poleRaDeg: [40.589, -0.036, 0], poleDecDeg: [83.537, -0.004, 0], primeMeridianDeg: [38.9, 810.7939024, 0]},
uranus: {poleRaDeg: [257.311, 0, 0], poleDecDeg: [-15.175, 0, 0], primeMeridianDeg: [203.81, -501.1600928, 0]},
pluto: {poleRaDeg: [132.993, 0, 0], poleDecDeg: [-6.163, 0, 0], primeMeridianDeg: [302.695, 56.3625225, 0]},
moon: {poleRaDeg: [269.9949, 0.0031, 0], poleDecDeg: [66.5392, 0.013, 0], primeMeridianDeg: [38.3213, 13.17635815, -1.4e-12], terms: [{angleDeg: [125.045, -1935.5364525], ra: -3.8787, dec: 1.5419, pm: 3.561}, {angleDeg: [250.089, -3871.072905], ra: -0.1204, dec: 0.0239, pm: 0.1208}, {angleDeg: [260.008, 475263.3328725], ra: 0.07, dec: -0.0278, pm: -0.0642}, {angleDeg: [176.625, 487269.629985], ra: -0.0172, dec: 0.0068, pm: 0.0158}, {angleDeg: [357.529, 35999.0509575], ra: 0, dec: 0, pm: 0.0252}]},
@@ -651,6 +652,48 @@ describe('solar-system bodies against Horizons', () => {
expect(Math.abs(facing('earth', new THREE.Vector3()).eastDeg)).toBeLessThan(4);
});
// Horizons' sub-Earth latitude on Saturn (observer quantity 14, from Earth's centre), which is
// planetodetic: taken back to planetocentric through the flattening, it is the angle the rings are
// opened to Earth by. Measured: 26.963, 0.075 and -7.764 degrees drawn, against 26.966, 0.042 and
// -7.813.
const SATURN_FLATTENING = 0.09796;
const RING_OPENING: Array<[date: string, jd: number, planetodeticDeg: number]> = [
['16 October 2017, near their widest', 2458042.5, 32.017423],
['23 March 2025, as Earth crossed their plane', 2460757.5, 0.051359],
['24 September 2026, the south face turned to Earth', 2461307.5, -9.571756]
];
const saturnRingMesh = (): THREE.Mesh => renderer.members.find((member) => member.id === 'saturn')!.marker.children[0] as THREE.Mesh;
/** The ring's face normal in the scene, read off its own geometry rather than its transform. */
function ringNormal(ring: THREE.Mesh): THREE.Vector3 {
ring.updateWorldMatrix(true, false);
return new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['normal'], 0).transformDirection(ring.matrixWorld);
}
for (const [date, jd, planetodeticDeg] of RING_OPENING) {
it(`opens Saturn's rings to Earth as far as Horizons has them on ${date}`, () => {
renderer.update(jd);
const normal = ringNormal(saturnRingMesh());
const toEarth = worldPosition('earth').sub(worldPosition('saturn')).normalize();
const openingDeg = (Math.asin(normal.dot(toEarth)) * 180) / Math.PI;
const expectedDeg = (Math.atan((1 - SATURN_FLATTENING) ** 2 * Math.tan((planetodeticDeg * Math.PI) / 180)) * 180) / Math.PI;
expect(Math.abs(openingDeg - expectedDeg)).toBeLessThan(0.1);
});
}
it('picks Saturn through its rings', () => {
renderer.update(JUNE_1_2025_NOON_UTC);
const ring = saturnRingMesh();
const normal = ringNormal(ring);
const inRingPlane = new THREE.Vector3().fromBufferAttribute(ring.geometry.attributes['position'], 0).transformDirection(ring.matrixWorld);
// Straight down onto the B ring, 100 000 km out: nowhere near the planet itself.
const onRing = worldPosition('saturn').addScaledVector(inRingPlane, 100000 / 149597870.7);
const [hit] = new THREE.Raycaster(onRing.clone().addScaledVector(normal, 0.01), normal.clone().negate()).intersectObjects(renderer.pickableObjects);
expect(hit.object).toBe(ring);
expect(renderer.memberForObject(hit.object)?.id).toBe('saturn');
});
// Horizons' observer quantities 14 and 15 at 2025-06-01 12:00 UTC, from Earth's centre (from the
// Sun's, for Earth): the sub-observer and sub-solar longitude and latitude, east-positive for
// Earth and the Moon and west-positive for Mars and Jupiter, as each is printed. Horizons gives
@@ -3,7 +3,7 @@ import * as THREE from 'three/webgpu';
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
import { bodyTexturePath, loadCachedTexture, saturnRing } from '../../shared/rendering/texture-catalog';
import { isPropagatableOrbit, keplerRates, meanElementsAt, orbitEllipsePoints, positionAtEpoch, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import { BodyRecord, MeanElementRates, OrbitalElements, RotationalElements } from '../../shared/models/body.model';
@@ -381,6 +381,14 @@ 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, body.rates, body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, elements: body.rotationalElements });
if (body.id === 'saturn') {
// A child of the sphere, so it lies in the equator the IAU pole turns the sphere into and
// is scaled with it where the marker is held to its pixel floor. Jupiter's, Uranus's and
// Neptune's rings are left out: dark, narrow or dusty, they are too faint to see here.
const ring = saturnRing(body.radiusKm, bodyMarkerRadiusAu(body.radiusKm));
tracked.marker.add(ring);
this.trackDisposable(ring.geometry, ring.material as THREE.Material);
}
members.push({ id: body.id, kind, marker: tracked.marker });
}
@@ -503,9 +511,12 @@ export class SystemOrbitsRenderer {
this.tethers?.setTargets(this.tetherPoints);
}
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
/**
* Looks up which system member a marker object belongs to (e.g. from a raycast hit), or a part
* of one: a ray through Saturn's rings picks Saturn.
*/
memberForObject(object: THREE.Object3D): SystemMember | undefined {
return this.members.find((member) => member.marker === object);
return this.members.find((member) => member.marker === object || member.marker === object.parent);
}
/** All marker objects, for raycasting. */
@@ -1,6 +1,7 @@
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { bodyTexturePath } from './texture-catalog';
import { bodyTexturePath, saturnRing } from './texture-catalog';
describe('bodyTexturePath', () => {
it('wraps the moons and dwarf planets that have a mission mosaic in it', () => {
@@ -16,3 +17,42 @@ describe('bodyTexturePath', () => {
}
});
});
describe('saturnRing', () => {
const SATURN_RADIUS_KM = 58232;
/** Each vertex's distance from the centre, in km, beside the texture coordinate it samples. */
function radiiAndU(ring: THREE.Mesh, kmPerUnit: number): Array<{ km: number; u: number; y: number }> {
const position = ring.geometry.attributes['position'];
const uv = ring.geometry.attributes['uv'];
const vertex = new THREE.Vector3();
return Array.from({ length: position.count }, (_, i) => {
vertex.fromBufferAttribute(position, i);
return { km: vertex.length() * kmPerUnit, u: uv.getX(i), y: vertex.y };
});
}
it('reaches from 69 400 to 141 000 km, drawn against the planet at whatever size it is drawn', () => {
for (const drawnRadius of [1, 3.9e-4]) {
const radii = radiiAndU(saturnRing(SATURN_RADIUS_KM, drawnRadius), SATURN_RADIUS_KM / drawnRadius).map(({ km }) => km);
expect(Math.min(...radii)).toBeCloseTo(69400, 0);
expect(Math.max(...radii)).toBeCloseTo(141000, 0);
}
});
it('lies in the equator of a sphere built round +Y', () => {
for (const { y } of radiiAndU(saturnRing(SATURN_RADIUS_KM, 1), SATURN_RADIUS_KM)) {
expect(Math.abs(y)).toBeLessThan(1e-12);
}
});
it('samples the strip outwards, so its B ring starts at 92 000 km and its A ring ends at 136 775', () => {
// Where the strip's alpha jumps: 404.5 and 1 204 of its 1 280 px.
const vertices = radiiAndU(saturnRing(SATURN_RADIUS_KM, 1), SATURN_RADIUS_KM);
const inner = vertices.reduce((a, b) => (b.km < a.km ? b : a));
const outer = vertices.reduce((a, b) => (b.km > a.km ? b : a));
const uAt = (km: number): number => inner.u + ((km - inner.km) / (outer.km - inner.km)) * (outer.u - inner.u);
expect(Math.abs(uAt(92000) * 1280 - 404.5)).toBeLessThan(1.5);
expect(Math.abs(uAt(136775) * 1280 - 1204)).toBeLessThan(1.5);
});
});
@@ -79,6 +79,54 @@ export function atmosphereColorFor(id: string): THREE.ColorRepresentation | unde
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>();
+7
View File
@@ -12,6 +12,13 @@ prime meridian (W) then turns longitude 0 to where it belongs at any date.
and the skybox come from the Solar System Scope texture pack, and `jupiter` from its 8k pack, via
Wikimedia Commons. See each file's Commons page for the original credit line.
`saturn_ring.png` is a 1 280 by 78 px strip. Its x axis runs straight out from Saturn: read off its
alpha, the C ring's inner edge (74 490 km) is at px 91, the B ring's inner and outer edges (92 000
and 117 580 km) at 404.5 and 860, the A ring's outer edge (136 775 km) at 1 204 and the F ring
(140 180 km) at 1 267.5, all within 1.8 px of 55.9 km a pixel. So its left edge stands for
69 400 km and its right edge for 141 000 km (`SATURN_RING_INNER_KM`, `SATURN_RING_OUTER_KM`). The
Cassini Division's outer edge (122 170 km) is drawn 30 px (1 700 km) too far in.
## Mission mosaics (public domain)
Each was downloaded from the URL below and processed the same way (script: