From 3443c1c65e9f1753e1e656b35d08416c1fa298f2 Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 00:14:38 +0200 Subject: worldmap-viewer: initial public history --- viewer/js/camera.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 viewer/js/camera.js (limited to 'viewer/js/camera.js') diff --git a/viewer/js/camera.js b/viewer/js/camera.js new file mode 100644 index 0000000..35f6ccd --- /dev/null +++ b/viewer/js/camera.js @@ -0,0 +1,91 @@ +// 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; +} -- cgit