GODSIGH/js/solarsystem.js
type-two 596cc4db19 wip: Solar System mode — billboard planets + depth/near-plane fixes
Switched planets from async ellipsoid geometry to orthographic-disc BILLBOARDS
(reliable, unlit, no worker geometry), with billboard-image refresh once each
disc canvas is drawn. Fixed the near-plane depth collapse (near 0.1 + far 6e9
killed the starfield). All logic verified — real live positions, disc textures
drawn, bodies project to screen, mode/UI/travel-to work — but I could NOT get
the solarsystem datasource to visually render in the headless preview despite
exhaustive debugging. NOT deployed; needs a real-browser render check / a fresh
debugging pass. Live site is unaffected (never deployed this).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:19:21 +10:00

302 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Solar System mode (Wave 4) — "Travel to…". Leaves the Earth OSINT globe and
// renders a heliocentric orrery: the Sun + 8 planets as spheres textured with
// real (CC BY 4.0, self-hosted) imagery, at their TRUE current positions
// (Schlyter series), with orbits. Click a destination to fly there.
//
// Cesium's globe is Earth-at-origin, so the mode HIDES the globe + all Earth
// layers and places the Sun at the scene origin; exiting restores everything.
export default function initSolarSystem(ctx) {
const { viewer, Cesium, lib } = ctx;
const scene = viewer.scene;
// ---- astronomy (Schlyter low-precision, heliocentric ecliptic AU) ----
const D2R = Math.PI / 180;
const rev = (x) => x - Math.floor(x / 360) * 360;
const ELEM = {
Mercury: (d) => ({ N: 48.3313 + 3.24587e-5 * d, i: 7.0047 + 5.00e-8 * d, w: 29.1241 + 1.01444e-5 * d, a: 0.387098, e: 0.205635 + 5.59e-10 * d, M: 168.6562 + 4.0923344368 * d }),
Venus: (d) => ({ N: 76.6799 + 2.46590e-5 * d, i: 3.3946 + 2.75e-8 * d, w: 54.8910 + 1.38374e-5 * d, a: 0.723330, e: 0.006773 - 1.302e-9 * d, M: 48.0052 + 1.6021302244 * d }),
Mars: (d) => ({ N: 49.5574 + 2.11081e-5 * d, i: 1.8497 - 1.78e-8 * d, w: 286.5016 + 2.92961e-5 * d, a: 1.523688, e: 0.093405 + 2.516e-9 * d, M: 18.6021 + 0.5240207766 * d }),
Jupiter: (d) => ({ N: 100.4542 + 2.76854e-5 * d, i: 1.3030 - 1.557e-7 * d, w: 273.8777 + 1.64505e-5 * d, a: 5.20256, e: 0.048498 + 4.469e-9 * d, M: 19.8950 + 0.0830853001 * d }),
Saturn: (d) => ({ N: 113.6634 + 2.38980e-5 * d, i: 2.4886 - 1.081e-7 * d, w: 339.3939 + 2.97661e-5 * d, a: 9.55475, e: 0.055546 - 9.499e-9 * d, M: 316.9670 + 0.0334442282 * d }),
Uranus: (d) => ({ N: 74.0005 + 1.3978e-5 * d, i: 0.7733 + 1.9e-8 * d, w: 96.6612 + 3.0565e-5 * d, a: 19.18171 - 1.55e-8 * d, e: 0.047318 + 7.45e-9 * d, M: 142.5905 + 0.011725806 * d }),
Neptune: (d) => ({ N: 131.7806 + 3.0173e-5 * d, i: 1.7700 - 2.55e-7 * d, w: 272.8461 - 6.027e-6 * d, a: 30.05826 + 3.313e-8 * d, e: 0.008606 + 2.15e-9 * d, M: 260.2471 + 0.005995147 * d }),
};
const dayNumber = (t) => Cesium.JulianDate.toDate(t).getTime() / 86400000 - 10956.0;
function helio(name, d) {
const el = ELEM[name](d);
const N = el.N * D2R, i = el.i * D2R, w = el.w * D2R, e = el.e, M = rev(el.M) * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
for (let k = 0; k < 6; k++) E = E - (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
const xv = el.a * (Math.cos(E) - e), yv = el.a * Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), u = v + w;
return {
x: r * (Math.cos(N) * Math.cos(u) - Math.sin(N) * Math.sin(u) * Math.cos(i)),
y: r * (Math.sin(N) * Math.cos(u) + Math.cos(N) * Math.sin(u) * Math.cos(i)),
z: r * Math.sin(u) * Math.sin(i),
};
}
function earthHelio(d) {
const w = 282.9404 + 4.70935e-5 * d, e = 0.016709 - 1.151e-9 * d, M = rev(356.0470 + 0.9856002585 * d) * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
const xv = Math.cos(E) - e, yv = Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), lon = v + w * D2R;
return { x: -r * Math.cos(lon), y: -r * Math.sin(lon), z: 0 }; // Earth = (Sun geocentric)
}
// ---- scaling (tuned for a readable orrery, not true scale) ----
const AU_UNIT = 4.0e7; // ~Earth orbit display radius
const distScale = (au) => AU_UNIT * Math.pow(au, 0.72); // gentle outer compression
const BODY_UNIT = 3.4e6; // exaggerated so bodies read at orrery scale
const bodyRadius = (km, isSun) => isSun ? 1.6e7 : BODY_UNIT * Math.pow(km / 6371, 0.4);
function scaledPos(vec) { // ecliptic AU {x,y,z} → scene Cartesian3
const au = Math.hypot(vec.x, vec.y, vec.z) || 1e-6;
const s = distScale(au) / au;
return new Cesium.Cartesian3(vec.x * s, vec.y * s, vec.z * s);
}
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 },
];
const ds = new Cesium.CustomDataSource('solarsystem');
ds.show = false;
viewer.dataSources.add(ds);
const bodyEntities = {}; // key → entity (for travel-to framing)
function posOf(key, d) {
if (key === 'sun') return new Cesium.Cartesian3(0, 0, 0);
if (key === 'earth') return scaledPos(earthHelio(d));
return scaledPos(helio(key.charAt(0).toUpperCase() + key.slice(1), d));
}
// Orthographic planet disc from an equirectangular texture, drawn onto a canvas
// used as a BILLBOARD image. Billboards render instantly (no async worker
// geometry), always face the camera, and are unlit — reliable where a 3D
// ellipsoid + hidden globe is not. Still reads as a real 3D planet.
const discCache = {};
function makeDisc(b) {
if (discCache[b.key]) return discCache[b.key];
const S = 256, R = S / 2, cv = document.createElement('canvas'); cv.width = cv.height = S;
const g = cv.getContext('2d');
discCache[b.key] = cv; // return now; fill in once the texture loads
const img = new Image();
img.onload = () => {
const iw = img.width, ih = img.height, src = document.createElement('canvas');
src.width = iw; src.height = ih; const sg = src.getContext('2d'); sg.drawImage(img, 0, 0);
const sd = sg.getImageData(0, 0, iw, ih).data;
const out = g.createImageData(S, S), od = out.data;
const lx = -0.5, ly = 0.55, lz = 0.67; // light from upper-left-front
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const nx = (x - R) / R, ny = (R - y) / R, r2 = nx * nx + ny * ny, oi = (y * S + x) * 4;
if (r2 > 1) { od[oi + 3] = 0; continue; }
const nz = Math.sqrt(1 - r2);
const lat = Math.asin(ny), lon = Math.atan2(nx, nz);
let u = lon / (2 * Math.PI) + 0.5; u -= Math.floor(u);
const vv = 0.5 - lat / Math.PI;
const si = (Math.min(ih - 1, (vv * ih) | 0) * iw + Math.min(iw - 1, (u * iw) | 0)) * 4;
const shade = b.sun ? 1 : Math.max(0.14, nx * lx + ny * ly + nz * lz) * 0.85 + 0.15;
od[oi] = sd[si] * shade; od[oi + 1] = sd[si + 1] * shade; od[oi + 2] = sd[si + 2] * shade; od[oi + 3] = 255;
}
g.putImageData(out, 0, 0);
// The billboard uploaded a blank canvas at creation time; re-assign the now
// -drawn canvas so Cesium re-uploads the real planet image to the atlas.
if (bodyEntities[b.key]) bodyEntities[b.key].billboard.image = cv;
};
img.src = b.tex;
return cv;
}
function build(d) {
ds.entities.removeAll();
for (const b of BODIES) {
const pos = posOf(b.key, d);
const r = bodyRadius(b.km, b.sun);
// Billboard pixel size = the body's angular size at a reference distance,
// grown as you approach so a planet fills the view up close.
const px = Math.max(10, (r * 2) / 90000); // ~screen px at ~ the fly-to range
const ent = ds.entities.add({
name: b.name,
position: pos,
billboard: {
image: makeDisc(b),
scaleByDistance: new Cesium.NearFarScalar(r * 3, px * 8, 2.5e9, Math.max(3, px * 0.05)),
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),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
bodyEntities[b.key] = ent;
// Orbit (skip the Sun): 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);
pts.push(scaledPos(v));
}
ds.entities.add({
polyline: { positions: pts, width: 1.5, arcType: Cesium.ArcType.NONE, material: lib.cz('#6a86c0', 0.6) },
});
}
}
}
// Orbit sampling: recompute a body's heliocentric position at mean-anomaly `deg`.
function helioAt(key, d, deg) {
const name = key.charAt(0).toUpperCase() + key.slice(1);
const el = ELEM[name](d);
const N = el.N * D2R, i = el.i * D2R, w = el.w * D2R, e = el.e, M = deg * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
for (let k = 0; k < 6; k++) E = E - (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
const xv = el.a * (Math.cos(E) - e), yv = el.a * Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), u = v + w;
return { x: r * (Math.cos(N) * Math.cos(u) - Math.sin(N) * Math.sin(u) * Math.cos(i)), y: r * (Math.sin(N) * Math.cos(u) + Math.cos(N) * Math.sin(u) * Math.cos(i)), z: r * Math.sin(u) * Math.sin(i) };
}
function earthHelioAt(d, deg) {
const w = 282.9404 + 4.70935e-5 * d, e = 0.016709 - 1.151e-9 * d, M = deg * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
const xv = Math.cos(E) - e, yv = Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), lon = v + w * D2R;
return { x: -r * Math.cos(lon), y: -r * Math.sin(lon), z: 0 };
}
// ---- mode enter / exit ----
let active = false;
const saved = { dataSources: [], primitives: [], globe: true, atmo: true, sky: true, animate: true, light: null, far: 5e8, near: 0.1, collide: true };
function enter() {
if (active) return;
active = true;
const d = dayNumber(viewer.clock.currentTime);
build(d);
// Hide every Earth layer: entity data sources + billboard primitives.
saved.dataSources = [];
for (let i = 0; i < viewer.dataSources.length; i++) {
const s = viewer.dataSources.get(i);
if (s === ds) continue;
saved.dataSources.push([s, s.show]); s.show = false;
}
saved.primitives = [];
for (let i = 0; i < scene.primitives.length; i++) {
const p = scene.primitives.get(i);
if (p && typeof p.length === 'number' && 'show' in p) { saved.primitives.push([p, p.show]); p.show = false; }
}
saved.globe = scene.globe.show; scene.globe.show = false;
saved.atmo = scene.globe.showGroundAtmosphere; scene.globe.showGroundAtmosphere = false;
saved.sky = scene.skyAtmosphere.show; scene.skyAtmosphere.show = false;
saved.animate = viewer.clock.shouldAnimate; viewer.clock.shouldAnimate = false; // freeze the snapshot
// The scene spans ~1e9 m — well past Cesium's default 5e8 far plane, which
// would clip every body. Widen it (and drop the surface-collision limit so
// you can fly far out); restored on exit.
saved.far = scene.camera.frustum.far; scene.camera.frustum.far = 6e9;
// Push the near plane out too: with a 6e9 far plane, a 0.1 near destroys depth
// precision and the starfield/scene stop drawing. 1e5 is fine at orrery scale.
saved.near = scene.camera.frustum.near; scene.camera.frustum.near = 1e5;
saved.collide = scene.screenSpaceCameraController.enableCollisionDetection;
scene.screenSpaceCameraController.enableCollisionDetection = false;
scene.screenSpaceCameraController.maximumZoomDistance = 6e9;
// Even "planetarium" lighting: a headlight from the camera so no half-dark planets.
saved.light = scene.light;
scene.light = new Cesium.DirectionalLight({ direction: scene.camera.directionWC.clone(), intensity: 2.2 });
headlight = scene.preRender.addEventListener((s) => {
scene.light.direction = Cesium.Cartesian3.clone(s.camera.directionWC, scene.light.direction);
});
ds.show = true;
document.getElementById('hud').style.display = 'none';
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,
});
}
let headlight = null;
function exit() {
if (!active) return;
active = false;
ds.show = false;
for (const [s, show] of saved.dataSources) s.show = show;
for (const [p, show] of saved.primitives) p.show = show;
scene.globe.show = saved.globe;
scene.globe.showGroundAtmosphere = saved.atmo;
scene.skyAtmosphere.show = saved.sky;
viewer.clock.shouldAnimate = saved.animate;
if (headlight) { headlight(); headlight = null; }
if (saved.light) scene.light = saved.light;
scene.camera.frustum.far = saved.far;
if (saved.near != null) scene.camera.frustum.near = saved.near;
scene.screenSpaceCameraController.enableCollisionDetection = saved.collide;
scene.screenSpaceCameraController.maximumZoomDistance = Infinity;
document.getElementById('hud').style.display = '';
panel.style.display = 'none';
document.getElementById('ss-btn').classList.remove('active');
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(ctx.CONFIG.camera.lon, ctx.CONFIG.camera.lat, ctx.CONFIG.camera.height),
duration: 2.0,
});
}
function travelTo(key) {
const ent = bodyEntities[key];
if (!ent) return;
if (key === 'earth') { // Earth = home; offer to 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) });
}
// ---- UI: enter button + destination panel ----
const btn = document.createElement('button');
btn.id = 'ss-btn';
btn.type = 'button';
btn.textContent = '◉ Solar System';
btn.title = 'Leave Earth and travel the solar system';
btn.addEventListener('click', () => (active ? exit() : enter()));
document.body.appendChild(btn);
const panel = document.createElement('aside');
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>`;
const dests = panel.querySelector('.ss-dests');
for (const b of BODIES) {
const d = document.createElement('button');
d.className = 'ss-dest';
d.textContent = b.name;
d.addEventListener('click', () => travelTo(b.key));
dests.appendChild(d);
}
const back = document.createElement('button');
back.className = 'ss-dest ss-back';
back.textContent = '↩ Return to Earth (OSINT)';
back.addEventListener('click', exit);
panel.appendChild(back);
document.body.appendChild(panel);
return { enter, exit, isActive: () => active };
}