aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/camera.js
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/js/camera.js')
-rw-r--r--viewer/js/camera.js91
1 files changed, 91 insertions, 0 deletions
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;
+}