diff --git a/js/solarsystem.js b/js/solarsystem.js index 3de22ec..9519649 100644 --- a/js/solarsystem.js +++ b/js/solarsystem.js @@ -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,46 +118,110 @@ 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, + 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, - billboard: { - image: makeDisc(b), - 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), + 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. - // 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)); - } + // 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({ - polyline: { positions: pts, width: 1.5, arcType: Cesium.ArcType.NONE, material: lib.cz('#6a86c0', 0.6) }, + 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); @@ -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 = `