GODSIGH/js/solarsystem.js
type-two e9b687d983 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>
2026-07-21 10:19:21 +10:00

427 lines
22 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);
}
// 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 = `<div class="ss-title">TRAVEL TO</div><div class="ss-dests"></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');
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 };
}