// camera.js — globe camera pose (pure): a target on the ground (lat, lon and the ground's height there, m), a range // (planet radii), a heading (0 = north up) and a pitch (0 = straight down). Scene units: planet radius 1. import { R_KM, toVec, toLatLon } from './geo.js'; import { MIN_ALT, MAX_ALT, MAX_PITCH, CLEARANCE_M } from './limits.js'; const D = Math.PI / 180; export const R_M = R_KM * 1000; export const TILT_FULL = 1000 / R_KM; // full tilt below ≈ 1,000 km … export const TILT_NONE = 2000 / R_KM; // … none above 2,000 km: zooming out levels the view smoothly const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; const mul = (a, k) => [a[0] * k, a[1] * k, a[2] * k]; const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; const norm = a => mul(a, 1 / (Math.hypot(a[0], a[1], a[2]) || 1)); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); export function frame(lat, lon) { // unit east, north and up at a point (defined at the poles too, by lon) const la = lat * D, lo = lon * D; return { east: [-Math.sin(lo), 0, -Math.cos(lo)], north: [-Math.sin(la) * Math.cos(lo), Math.cos(la), Math.sin(la) * Math.sin(lo)], up: toVec(lat, lon) }; } export const maxPitch = range => MAX_PITCH * clamp((TILT_NONE - range) / (TILT_NONE - TILT_FULL), 0, 1); export function clampPose(p) { const range = clamp(num(p.range, 2), MIN_ALT, MAX_ALT); return { lat: clamp(num(p.lat, 0), -90, 90), lon: ((((num(p.lon, 0) + 180) % 360) + 360) % 360) - 180, range, heading: ((num(p.heading, 0) % 360) + 360) % 360, pitch: clamp(num(p.pitch, 0), 0, maxPitch(range)), ground: Math.max(0, num(p.ground, 0)) }; } const forward = (f, heading) => add(mul(f.north, Math.cos(heading * D)), mul(f.east, Math.sin(heading * D))); export function eyeOf(p) { // camera position, screen-up vector and look-at target const f = frame(p.lat, p.lon), fw = forward(f, p.heading), t = p.pitch * D; const back = add(mul(f.up, Math.cos(t)), mul(fw, -Math.sin(t))), target = mul(f.up, 1 + (p.ground ?? 0) / R_M); return { eye: add(target, mul(back, p.range)), up: add(mul(fw, Math.cos(t)), mul(f.up, Math.sin(t))), target }; } export function eyeAltitude(range, pitch, ground = 0) { // above sea level, for a target on ground (m) const b = 1 + ground / R_M; return Math.sqrt(b * b + 2 * b * range * Math.cos(pitch * D) + range * range) - 1; } export function rangeForAltitude(alt, pitch, ground = 0) { // the range that puts the eye at this altitude const b = 1 + ground / R_M, c = Math.cos(pitch * D); return -b * c + Math.sqrt(b * b * c * c - b * b + (1 + alt) ** 2); } export function headingOf(lat, lon, dir) { // compass bearing (deg) of a direction at a point const f = frame(lat, lon); return ((Math.atan2(dot(dir, f.east), dot(dir, f.north)) / D) % 360 + 360) % 360; } export function rotatePose(p, from, to) { // turn the whole camera about the planet centre, taking `from` to `to` const a = norm(from), b = norm(to), axis = cross(a, b), s = Math.hypot(axis[0], axis[1], axis[2]), c = dot(a, b); if (s < 1e-15) return { ...p }; const k = mul(axis, 1 / s); const rot = v => add(add(mul(v, c), mul(cross(k, v), s)), mul(k, dot(k, v) * (1 - c))); // Rodrigues const f = frame(p.lat, p.lon), target = rot(f.up), fw = rot(forward(f, p.heading)); const { lat, lon } = toLatLon(target); return { ...p, lat, lon, heading: headingOf(lat, lon, fw) }; } export const nearPole = lat => Math.abs(lat) > 89; // north is undefined here: pans turn rigidly export function keepHeading(from, to) { // a pan result that keeps the compass heading, except around a pole return nearPole(from.lat) || nearPole(to.lat) ? to : { ...to, heading: from.heading }; } export function panPose(p, fx, fy) { // keys: move the target by fractions of the range (right = fx, screen-up = −fy); // the compass heading stays put, like a map (except around a pole) const f = frame(p.lat, p.lon), fw = forward(f, p.heading), right = cross(fw, f.up); const ang = Math.min(0.5, Math.hypot(fx, fy) * p.range); if (!(ang > 0)) return { ...p }; const dir = norm(add(mul(right, fx), mul(fw, -fy))); return keepHeading(p, rotatePose(p, f.up, add(mul(f.up, Math.cos(ang)), mul(dir, Math.sin(ang))))); } export function lift(p, groundM, below = false) { // raise the range until the eye clears the ground (or sea level; const g = num(groundM, 0), need = ((below ? g : Math.max(0, g)) + CLEARANCE_M) / R_M; // below: the floor under it) if (eyeAltitude(p.range, p.pitch, p.ground) >= need - 1e-15) return p; return { ...p, range: Math.min(MAX_ALT, rangeForAltitude(need, p.pitch, p.ground)) }; } export function kmPerPxAt(eye, p, fovDeg, hPx) { // ground size of one pixel at a point, by its distance only const v = toVec(p.lat, p.lon); return (Math.hypot(eye[0] - v[0], eye[1] - v[1], eye[2] - v[2]) * R_KM * 2 * Math.tan((fovDeg * D) / 2)) / hPx; }