feat: Solar System mode — real 3D lit planets + Saturn rings

Replace the flat billboard-disc planets with real 3D EllipsoidGraphics
spheres textured with self-hosted high-res NASA/JPL + Solar System Scope
maps (hd_*.jpg, 2048x1024), plus a PlaneGraphics Saturn ring (top-down
annulus, transparent centre, depth-tested occlusion). Constant axial tilts
(Venus upside-down, Uranus on its side).

Cesium gotchas handled: constant orientation (time-varying orientation
drops image materials -> blank white); DirectionalLight intensity 0.42
(no HDR -> higher clamps bright maps to white); camera.flyTo is dead in
this mode so enter/travel run a manual eased setView tween (flyCamera);
ellipsoid+ring gated by distanceDisplayCondition(r*30) so the orrery
overview stays clean colored dots and the textured sphere takes over
up close.

Drop 9 now-unused 2k_ maps; textures/ is 3.3MB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 10:18:15 +10:00
parent 0a6bbfaa23
commit e9b687d983
19 changed files with 196 additions and 53 deletions

View File

@ -55,16 +55,19 @@ export default function initSolarSystem(ctx) {
return new Cesium.Cartesian3(vec.x * s, vec.y * s, vec.z * s);
}
// Real 3D spheres textured with self-hosted CC BY 4.0 / NASA imagery. `obliq` =
// axial tilt in degrees (Venus ~177° reads upside-down; Uranus ~98° lies on its
// side) — applied as a constant orientation so each planet leans correctly.
const BODIES = [
{ key: 'sun', name: '☉ Sun', tex: 'textures/2k_sun.jpg', km: 696000, sun: true },
{ key: 'mercury', name: '☿ Mercury', tex: 'textures/2k_mercury.jpg', km: 2439.7 },
{ key: 'venus', name: '♀ Venus', tex: 'textures/2k_venus_atmosphere.jpg', km: 6051.8 },
{ key: 'earth', name: '🜨 Earth', tex: 'textures/2k_earth_daymap.jpg', km: 6371 },
{ key: 'mars', name: '♂ Mars', tex: 'textures/2k_mars.jpg', km: 3389.5 },
{ key: 'jupiter', name: '♃ Jupiter', tex: 'textures/2k_jupiter.jpg', km: 69911 },
{ key: 'saturn', name: '♄ Saturn', tex: 'textures/2k_saturn.jpg', km: 58232, ring: [1.3, 2.3] },
{ key: 'uranus', name: '⛢ Uranus', tex: 'textures/2k_uranus.jpg', km: 25362 },
{ key: 'neptune', name: '♆ Neptune', tex: 'textures/2k_neptune.jpg', km: 24622 },
{ key: 'sun', name: '☉ Sun', tex: 'textures/2k_sun.jpg', km: 696000, sun: true, color: '#ffcf6b' },
{ key: 'mercury', name: '☿ Mercury', tex: 'textures/hd_mercury.jpg', km: 2439.7, color: '#9a8d80', obliq: 0.03 },
{ key: 'venus', name: '♀ Venus', tex: 'textures/hd_venus.jpg', km: 6051.8, color: '#d8b56a', obliq: 177.4 },
{ key: 'earth', name: '🜨 Earth', tex: 'textures/hd_earth.jpg', km: 6371, color: '#5b8fd0', obliq: 23.44 },
{ key: 'mars', name: '♂ Mars', tex: 'textures/hd_mars.jpg', km: 3389.5, color: '#c1502a', obliq: 25.19 },
{ key: 'jupiter', name: '♃ Jupiter', tex: 'textures/hd_jupiter.jpg', km: 69911, color: '#d9c9a3', obliq: 3.13 },
{ key: 'saturn', name: '♄ Saturn', tex: 'textures/hd_saturn.jpg', km: 58232, color: '#e6dbb0', obliq: 26.73, ring: 'textures/hd_saturn_ring.png' },
{ key: 'uranus', name: '⛢ Uranus', tex: 'textures/hd_uranus.jpg', km: 25362, color: '#9fdce4', obliq: 97.77 },
{ key: 'neptune', name: '♆ Neptune', tex: 'textures/2k_neptune.jpg', km: 24622, color: '#456fe0', obliq: 28.32 },
];
const ds = new Cesium.CustomDataSource('solarsystem');
@ -115,14 +118,30 @@ export default function initSolarSystem(ctx) {
return cv;
}
// Constant axial tilt about +X (Saturn's matches its ring plane; Uranus rolls on
// its side at 98°). NOTE: the orientation MUST be constant — a time-varying
// orientation pushes the ellipsoid onto Cesium's dynamic-geometry path, which
// only supports solid-colour materials and renders the planet blank white. A
// static orientation keeps the image material; you still orbit planets by hand.
const X_AXIS = new Cesium.Cartesian3(1, 0, 0);
function tiltQuat(b) {
return Cesium.Quaternion.fromAxisAngle(X_AXIS, (b.obliq || 0) * D2R);
}
// Saturn's ring-plane normal = its tilted pole (so ring ⟂ pole, aligned to sphere).
function ringNormal(obliqDeg) {
const t = obliqDeg * D2R;
return new Cesium.Cartesian3(0, -Math.sin(t), Math.cos(t));
}
function build(d) {
ds.entities.removeAll();
for (const b of BODIES) {
const pos = posOf(b.key, d);
const r = bodyRadius(b.km, b.sun);
// The disc canvas is 256px; `scale` is a MULTIPLIER on that. Small values:
// ~2.4 when you're right at the planet (fills the view), ~0.12 far away (a
// dot). Per-body near distance = just outside its own radius.
// The Sun is a star: keep it a bright, always-lit billboard disc (a lit
// ellipsoid would show a dark hemisphere). Planets are real 3D spheres.
if (b.sun) {
const ent = ds.entities.add({
name: b.name,
position: pos,
@ -131,19 +150,59 @@ export default function initSolarSystem(ctx) {
scaleByDistance: new Cesium.NearFarScalar(r * 2.5, 2.4, 9e8, 0.12),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
text: b.name, font: '13px "Segoe UI", system-ui, sans-serif',
fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.fromCssColorString('#05080b'),
outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.TOP, pixelOffset: new Cesium.Cartesian2(0, 12),
label: labelGraphics(b),
});
bodyEntities[b.key] = ent;
continue;
}
const ent = ds.entities.add({
name: b.name,
position: pos,
orientation: tiltQuat(b),
ellipsoid: {
radii: new Cesium.Cartesian3(r, r, r),
material: new Cesium.ImageMaterialProperty({ image: b.tex, color: Cesium.Color.WHITE }),
slicePartitions: 64, stackPartitions: 64,
// Only render the real sphere once you're close enough for it to read as a
// textured planet. Far away it minifies to a grey ball — hide it and show
// the colored dot instead. Clean handoff at r*30.
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 30),
},
// Colored dot + label are the orrery-overview representation; they vanish as
// you approach (r*30) and the textured 3D planet takes over.
point: {
pixelSize: 7,
color: Cesium.Color.fromCssColorString(b.color),
outlineColor: Cesium.Color.fromCssColorString('#05080b'), outlineWidth: 1,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(r * 30, 9e9),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: labelGraphics(b),
});
bodyEntities[b.key] = ent;
// Orbit (skip the Sun): trace one full revolution by sweeping the anomaly.
// Saturn's rings: a flat quad in the equatorial plane, textured with a
// top-down ring annulus (transparent centre + corners). Depth-tested so the
// planet occludes the far half of the ring — the real Saturn silhouette.
if (b.ring) {
const side = r * 6.6; // hole ≈ planet radius, outer edge ≈ 3.1 radii
ds.entities.add({
position: pos,
plane: {
plane: new Cesium.Plane(ringNormal(b.obliq), 0.0),
dimensions: new Cesium.Cartesian2(side, side),
material: new Cesium.ImageMaterialProperty({ image: b.ring, transparent: true }),
// Match the sphere's visibility window (r*30). Beyond it the ring both
// minifies to an ugly opaque grey card AND would float without a planet,
// so hide it — at that range Saturn is just its labelled dot.
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 30),
},
});
}
// Orbit: trace one full revolution by sweeping the anomaly.
// arcType NONE = straight segments in space (not draped on an ellipsoid).
if (b.key !== 'sun') {
const pts = [];
for (let a = 0; a <= 360; a += 3) {
const v = b.key === 'earth' ? earthHelioAt(d, a) : helioAt(b.key, d, a);
@ -154,6 +213,14 @@ export default function initSolarSystem(ctx) {
});
}
}
function labelGraphics(b) {
return {
text: b.name, font: '13px "Segoe UI", system-ui, sans-serif',
fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.fromCssColorString('#05080b'),
outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.TOP, pixelOffset: new Cesium.Cartesian2(0, 12),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
};
}
// Orbit sampling: recompute a body's heliocentric position at mean-anomaly `deg`.
function helioAt(key, d, deg) {
@ -178,6 +245,33 @@ export default function initSolarSystem(ctx) {
let active = false;
const saved = { dataSources: [], primitives: [], globe: true, atmo: true, sky: true, animate: true, light: null, far: 5e8, near: 0.1, collide: true };
// Cesium's animated camera.flyTo does NOT progress in this mode (its flight tween
// sits idle out here 1e8 m off Earth with the globe hidden) — but setView works
// perfectly. So drive our own eased per-frame tween: lerp position + orientation
// and setView each frame. Guaranteed smooth because setView is reliable.
let flightTick = null;
const V3 = Cesium.Cartesian3;
function flyCamera(destPos, destDir, destUp, duration) {
if (flightTick) { flightTick(); flightTick = null; }
const cam = scene.camera;
const startPos = cam.positionWC.clone();
const startDir = cam.directionWC.clone();
const startUp = cam.upWC.clone();
const t0 = performance.now();
flightTick = scene.preRender.addEventListener(() => {
let u = (performance.now() - t0) / 1000 / duration;
if (u > 1) u = 1;
const e = u * u * (3 - 2 * u); // smoothstep ease in/out
const pos = V3.lerp(startPos, destPos, e, new V3());
let dir = V3.normalize(V3.lerp(startDir, destDir, e, new V3()), new V3());
let up = V3.normalize(V3.lerp(startUp, destUp, e, new V3()), new V3());
const right = V3.normalize(V3.cross(dir, up, new V3()), new V3());
up = V3.normalize(V3.cross(right, dir, new V3()), new V3()); // re-orthonormalize
scene.camera.setView({ destination: pos, orientation: { direction: dir, up } });
if (u >= 1 && flightTick) { flightTick(); flightTick = null; }
});
}
function enter() {
if (active) return;
active = true;
@ -212,11 +306,22 @@ export default function initSolarSystem(ctx) {
scene.screenSpaceCameraController.enableCollisionDetection = false;
scene.screenSpaceCameraController.maximumZoomDistance = 6e9;
// Even "planetarium" lighting: a headlight from the camera so no half-dark planets.
// Portrait key-light: source sits upper-left-front of the camera, so the lit
// hemisphere of whatever you orbit stays upper-left — every 3D sphere reads
// with a graceful terminator from any angle, and (since it tracks the camera)
// there's no "sun is over there but lit from here" contradiction.
saved.light = scene.light;
scene.light = new Cesium.DirectionalLight({ direction: scene.camera.directionWC.clone(), intensity: 2.2 });
// Intensity is deliberately low: the equirect maps are near-albedo-1 in
// places, so anything above ~0.5 clamps the lit hemisphere to pure white
// (no HDR). 0.42 keeps the texture + Saturn's rings rich and readable.
scene.light = new Cesium.DirectionalLight({ direction: scene.camera.directionWC.clone(), intensity: 0.42 });
const _ld = new Cesium.Cartesian3();
headlight = scene.preRender.addEventListener((s) => {
scene.light.direction = Cesium.Cartesian3.clone(s.camera.directionWC, scene.light.direction);
const c = s.camera;
Cesium.Cartesian3.multiplyByScalar(c.rightWC, 0.5, _ld);
Cesium.Cartesian3.add(_ld, Cesium.Cartesian3.multiplyByScalar(c.upWC, -0.55, new Cesium.Cartesian3()), _ld);
Cesium.Cartesian3.add(_ld, Cesium.Cartesian3.multiplyByScalar(c.directionWC, 0.67, new Cesium.Cartesian3()), _ld);
Cesium.Cartesian3.normalize(_ld, scene.light.direction);
});
ds.show = true;
@ -224,19 +329,22 @@ export default function initSolarSystem(ctx) {
panel.style.display = 'block';
document.getElementById('ss-btn').classList.add('active');
// Fly out to a clean top-down-ish view of the system (orthonormal dir/up —
// Cesium needs those, and lookAt at the geocentre is degenerate).
viewer.camera.flyTo({
destination: new Cesium.Cartesian3(0.7e8, 0.3e8, 7.5e8),
orientation: { direction: new Cesium.Cartesian3(0, 0, -1), up: new Cesium.Cartesian3(0, 1, 0) },
duration: 2.5,
});
// Swoop out from "Sun in your face" (camera starts at the old Earth-surface
// spot, and the Sun now sits where Earth's centre was) to a clean oblique view
// of the whole system.
flyCamera(
new Cesium.Cartesian3(1.6e8, -3.4e8, 2.6e8),
Cesium.Cartesian3.normalize(new Cesium.Cartesian3(-1.6e8, 3.4e8, -2.6e8), new Cesium.Cartesian3()),
new Cesium.Cartesian3(0, 0, 1),
2.6,
);
}
let headlight = null;
function exit() {
if (!active) return;
active = false;
if (flightTick) { flightTick(); flightTick = null; } // stop any in-progress swoop
ds.show = false;
for (const [s, show] of saved.dataSources) s.show = show;
for (const [p, show] of saved.primitives) p.show = show;
@ -253,20 +361,36 @@ export default function initSolarSystem(ctx) {
document.getElementById('hud').style.display = '';
panel.style.display = 'none';
document.getElementById('ss-btn').classList.remove('active');
viewer.camera.flyTo({
// Instant, reliable return to the OSINT globe (animated flyTo is dead in this
// mode; setView always works). Nadir view over the configured home spot.
scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(ctx.CONFIG.camera.lon, ctx.CONFIG.camera.lat, ctx.CONFIG.camera.height),
duration: 2.0,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}
function travelTo(key) {
const ent = bodyEntities[key];
if (!ent) return;
if (key === 'earth') { // Earth = home; offer to drop back to the OSINT globe
const b = BODIES.find((x) => x.key === key);
if (!b) return;
if (key === 'earth') { // Earth = home; drop back to the OSINT globe
exit();
return;
}
viewer.flyTo(ent, { duration: 2.5, offset: new Cesium.HeadingPitchRange(0, -0.3, bodyRadius(BODIES.find((b) => b.key === key).km) * 6) });
// Deterministic framing in WORLD space. flyTo(entity)/flyToBoundingSphere both
// misbehave out here (the ENU frame they offset within is degenerate 2e8 m off
// Earth), so compute the destination + orientation directly. Camera sits up and
// to the side of the ecliptic → a 3/4 view that shows Saturn's ring tilt.
const ent = bodyEntities[key];
const pos = ent.position.getValue(viewer.clock.currentTime);
const r = bodyRadius(b.km, b.sun);
const range = (b.sun ? 3.0 : b.ring ? 6.0 : 5.0) * r;
const V = Cesium.Cartesian3;
const offDir = V.normalize(new V(0.55, -0.25, 0.45), new V());
const dest = V.add(pos, V.multiplyByScalar(offDir, range, new V()), new V());
const dir = V.negate(offDir, new V());
const right = V.normalize(V.cross(dir, new V(0, 0, 1), new V()), new V());
const up = V.normalize(V.cross(right, dir, new V()), new V());
flyCamera(dest, dir, up, 2.2);
}
// ---- UI: enter button + destination panel ----
@ -282,7 +406,7 @@ export default function initSolarSystem(ctx) {
panel.id = 'ss-panel';
panel.style.display = 'none';
panel.innerHTML = `<div class="ss-title">TRAVEL TO</div><div class="ss-dests"></div>
<div class="ss-foot">Positions computed live · textures © <a href="https://www.solarsystemscope.com/" target="_blank" rel="noopener">Solar System Scope</a> CC BY 4.0</div>`;
<div class="ss-foot">Positions computed live · planet maps: NASA/JPL &amp; <a href="https://www.solarsystemscope.com/" target="_blank" rel="noopener">Solar System Scope</a> (CC BY 4.0)</div>`;
const dests = panel.querySelector('.ss-dests');
for (const b of BODIES) {
const d = document.createElement('button');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

View File

@ -1,10 +1,29 @@
# Planet & body textures
Equirectangular surface/cloud maps for the Solar System mode.
Equirectangular surface maps + Saturn ring for the Solar System mode. All
self-hosted, downscaled to web sizes (2048×1024 maps; 1024² ring).
**Source:** [Solar System Scope](https://www.solarsystemscope.com/textures/) — © INOVE / Solar System Scope.
**License:** Creative Commons Attribution 4.0 International (CC BY 4.0).
## Files in use
| File | Body | Origin |
|---|---|---|
| `2k_sun.jpg` | Sun | Solar System Scope |
| `2k_neptune.jpg` | Neptune | Solar System Scope |
| `hd_mercury.jpg` | Mercury | NASA/JPLderived 8K map |
| `hd_venus.jpg` | Venus (Magellan radar surface) | NASA/JPLderived 8K map |
| `hd_earth.jpg` | Earth | NASA Blue/Visible Earthderived |
| `hd_mars.jpg` | Mars | NASA/JPL MGS/Vikingderived |
| `hd_jupiter.jpg` | Jupiter | NASA/Cassiniderived |
| `hd_saturn.jpg` | Saturn | NASA/Cassiniderived |
| `hd_saturn_ring.png` | Saturn ring (top-down annulus, RGBA) | NASA/Cassiniderived |
| `hd_uranus.jpg` | Uranus | Solar System Scope |
Files: `2k_{sun,mercury,venus_atmosphere,earth_daymap,moon,mars,jupiter,saturn,uranus,neptune}.jpg`, `2k_saturn_ring_alpha.png`.
## Licensing
The maps are derived from public-domain **NASA/JPL** imagery and **Solar System
Scope** planetary maps, the latter under **Creative Commons Attribution 4.0
International (CC BY 4.0)** — © INOVE / Solar System Scope,
<https://www.solarsystemscope.com/textures/>.
Attribution is also shown in-app (Solar System mode footer) and in the project README.
The `hd_*` set was produced from freely-distributed 3D-asset texture packs whose
underlying imagery is NASA/JPL (public domain) or Solar System Scope (CC BY 4.0).
Attribution is shown in-app (Solar System mode footer): *"planet maps: NASA/JPL &
Solar System Scope (CC BY 4.0)"*.

BIN
textures/hd_earth.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
textures/hd_jupiter.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

BIN
textures/hd_mars.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

BIN
textures/hd_mercury.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

BIN
textures/hd_saturn.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
textures/hd_saturn_ring.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

BIN
textures/hd_uranus.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
textures/hd_venus.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB