// 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); } // 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, 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'); 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; } // 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 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, billboard: { image: makeDisc(b), scaleByDistance: new Cesium.NearFarScalar(r * 2.5, 2.4, 9e8, 0.12), disableDepthTestDistance: Number.POSITIVE_INFINITY, }, 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; // 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). 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) }, }); } } 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) { 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 }; // 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; 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; } // NOTE: do NOT blanket-hide scene.primitives by duck-typing — that also hides // Cesium's shared DataSourceDisplay collections (the very ones our planets // render into). The bespoke billboard layers (aircraft/military/adsb/radius) // sit at Earth-surface coords → a tiny speck near the Sun in the orrery, so // leaving them visible is harmless. saved.primitives = []; 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; // 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; // 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) => { 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; document.getElementById('hud').style.display = 'none'; panel.style.display = 'block'; document.getElementById('ss-btn').classList.add('active'); // 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; 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'); // 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), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); } function travelTo(key) { const b = BODIES.find((x) => x.key === key); if (!b) return; if (key === 'earth') { // Earth = home; drop back to the OSINT globe exit(); return; } // 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 ---- 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 = `