diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 6e61774..24df270 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -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; diff --git a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts index 07537fe..051bd5b 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -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 diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 2c55b24..eee8041 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -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. */ diff --git a/src/app/shared/rendering/texture-catalog.spec.ts b/src/app/shared/rendering/texture-catalog.spec.ts index dfdf6f7..7af9926 100644 --- a/src/app/shared/rendering/texture-catalog.spec.ts +++ b/src/app/shared/rendering/texture-catalog.spec.ts @@ -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); + }); +}); diff --git a/src/app/shared/rendering/texture-catalog.ts b/src/app/shared/rendering/texture-catalog.ts index 95bb198..31455c8 100644 --- a/src/app/shared/rendering/texture-catalog.ts +++ b/src/app/shared/rendering/texture-catalog.ts @@ -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(); diff --git a/src/assets/textures/README.md b/src/assets/textures/README.md index 38b03a5..d0dc441 100644 --- a/src/assets/textures/README.md +++ b/src/assets/textures/README.md @@ -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: