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>
@ -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 & <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');
|
||||
|
||||
|
Before Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 733 KiB |
|
Before Width: | Height: | Size: 852 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 224 KiB |
@ -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/JPL–derived 8K map |
|
||||
| `hd_venus.jpg` | Venus (Magellan radar surface) | NASA/JPL–derived 8K map |
|
||||
| `hd_earth.jpg` | Earth | NASA Blue/Visible Earth–derived |
|
||||
| `hd_mars.jpg` | Mars | NASA/JPL MGS/Viking–derived |
|
||||
| `hd_jupiter.jpg` | Jupiter | NASA/Cassini–derived |
|
||||
| `hd_saturn.jpg` | Saturn | NASA/Cassini–derived |
|
||||
| `hd_saturn_ring.png` | Saturn ring (top-down annulus, RGBA) | NASA/Cassini–derived |
|
||||
| `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
|
After Width: | Height: | Size: 239 KiB |
BIN
textures/hd_jupiter.jpg
Normal file
|
After Width: | Height: | Size: 365 KiB |
BIN
textures/hd_mars.jpg
Normal file
|
After Width: | Height: | Size: 349 KiB |
BIN
textures/hd_mercury.jpg
Normal file
|
After Width: | Height: | Size: 477 KiB |
BIN
textures/hd_saturn.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
textures/hd_saturn_ring.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
textures/hd_uranus.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
textures/hd_venus.jpg
Normal file
|
After Width: | Height: | Size: 418 KiB |