worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 7417 bytes

import './_planet.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { toVec, R_KM } from '../js/geo.js';
import { MIN_ALT, MAX_ALT, CLEARANCE_M } from '../js/limits.js';
import { R_M, TILT_FULL, TILT_NONE, frame, maxPitch, clampPose, eyeOf, eyeAltitude, rangeForAltitude, headingOf,
  rotatePose, panPose, lift, kmPerPxAt } from '../js/camera.js';

const near = (a, b, eps, m = '') => assert.ok(Math.abs(a - b) <= eps, `${m} ${a} vs ${b}`);
const nearV = (a, b, eps) => a.forEach((v, i) => near(v, b[i], eps, `[${i}]`));
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const sub = (a, b) => a.map((v, i) => v - b[i]);
const len = a => Math.hypot(...a);
const pose = o => clampPose({ lat: 40, lon: -10, range: 0.01, heading: 0, pitch: 0, ...o });

test('frame: east, north, up are orthonormal and right-handed (east × north = up)', () => {
  for (const [la, lo] of [[0, 0], [40, -10], [-65, 170], [90, 30], [-90, -120]]) {
    const { east: e, north: n, up: u } = frame(la, lo);
    for (const v of [e, n, u]) near(len(v), 1, 1e-12);
    near(dot(e, n), 0, 1e-12); near(dot(e, u), 0, 1e-12); near(dot(n, u), 0, 1e-12);
    nearV([e[1] * n[2] - e[2] * n[1], e[2] * n[0] - e[0] * n[2], e[0] * n[1] - e[1] * n[0]], u, 1e-12);
  }
  nearV(frame(0, 0).north, [0, 1, 0], 1e-12);
});

test('straight down: the eye is above the target, screen-up is the heading', () => {
  const p = pose({ heading: 0 }), { eye, up, target } = eyeOf(p);
  nearV(target, toVec(40, -10), 1e-12);
  nearV(eye, toVec(40, -10).map(v => v * 1.01), 1e-12);
  nearV(up, frame(40, -10).north, 1e-12);
  nearV(eyeOf(pose({ heading: 90 })).up, frame(40, -10).east, 1e-12);
});

test('tilted: range is the eye–target distance, pitch the angle from vertical, eye behind the heading', () => {
  const p = pose({ heading: 30, pitch: 60 }), { eye, up, target } = eyeOf(p), back = sub(eye, target);
  near(len(back), 0.01, 1e-12);
  near(Math.acos(dot(back, target) / len(back)) * 180 / Math.PI, 60, 1e-9);
  near(headingOf(40, -10, sub(target, eye)), 30, 1e-9);
  near(dot(up, sub(target, eye)), 0, 1e-12);
  near(len(up), 1, 1e-12);
});

test('clampPose: range limits, tilt only when low (ramp 1,000 → 2,000 km), heading wraps, junk → defaults', () => {
  assert.equal(clampPose({ range: 99 }).range, MAX_ALT);
  assert.equal(clampPose({ range: 0 }).range, MIN_ALT);
  near(TILT_FULL * R_KM, 1000, 1e-9); near(TILT_NONE * R_KM, 2000, 1e-9);
  assert.equal(pose({ range: 500 / R_KM, pitch: 70 }).pitch, 70);
  assert.equal(pose({ range: 3000 / R_KM, pitch: 70 }).pitch, 0);
  near(maxPitch(1500 / R_KM), 40, 1e-9);
  assert.equal(pose({ range: 500 / R_KM, pitch: 95 }).pitch, 80);
  assert.equal(pose({ heading: -30 }).heading, 330);
  assert.equal(pose({ heading: 720 }).heading, 0);
  const j = clampPose({ lat: NaN, lon: 'x', range: undefined, heading: null, pitch: Infinity });
  assert.deepEqual([j.lat, j.lon, j.range, j.heading], [0, 0, 2, 0]);
  assert.equal(pose({ lon: 190 }).lon, -170);
});

test('eye altitude ↔ range round trip', () => {
  for (const pt of [0, 30, 80]) for (const a of [1e-6, 3e-4, 0.05]) near(eyeAltitude(rangeForAltitude(a, pt), pt), a, 1e-15);
  near(eyeAltitude(0.01, 0), 0.01, 1e-15);
});

test('rotatePose moves the target exactly; a meridian move keeps the heading; poles and the antimeridian stay finite', () => {
  const p = pose({ heading: 45, pitch: 30 }), q = rotatePose(p, toVec(40, -10), toVec(41, -10));
  near(q.lat, 41, 1e-9); near(q.lon, -10, 1e-9); near(q.heading, 45, 1e-9);
  assert.equal(q.range, p.range); assert.equal(q.pitch, p.pitch);
  const r = rotatePose(pose({ lon: 179.5 }), toVec(40, 179.5), toVec(40, -179.5));
  near(r.lon, -179.5, 1e-9);
  const s = rotatePose(pose({ lat: 89.9, heading: 10 }), toVec(89.9, -10), toVec(90, 0));
  assert.ok([s.lat, s.lon, s.heading].every(Number.isFinite));
  assert.deepEqual(rotatePose(p, toVec(1, 2), toVec(1, 2)), p);
});

test('rotatePose is a rigid turn: the eye keeps its distance to the moved point (the grab invariant)', () => {
  const p = pose({ heading: 200, pitch: 50 }), a = toVec(40.2, -9.9), b = toVec(40.05, -10.3);
  const e1 = eyeOf(p).eye, e2 = eyeOf(rotatePose(p, a, b)).eye;
  near(len(sub(e2, b)), len(sub(e1, a)), 1e-12);
});

test('panPose: screen-up moves forward along the heading, right moves right, by a fraction of the range', () => {
  const up = panPose(pose({ heading: 0 }), 0, -0.2);
  near(up.lat - 40, (0.2 * 0.01 * 180) / Math.PI, 1e-9); near(up.lon, -10, 1e-9);
  const right = panPose(pose({ lat: 0, lon: 0, heading: 0 }), 0.2, 0);
  near(right.lat, 0, 1e-9); near(right.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9);
  const east = panPose(pose({ lat: 0, lon: 0, heading: 90 }), 0, -0.2);
  near(east.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9);
  assert.ok(panPose(pose({ range: 5 }), 0, -1).lat - 40 <= 0.5 * 180 / Math.PI + 1e-9, 'big steps are capped');
});

test('lift: never below the ground + 20 m (sea level when the ground is lower or unknown)', () => {
  const low = pose({ range: 1e-9, pitch: 0 }), g = 1500;
  near(eyeAltitude(lift(low, g).range, 0) * R_M, g + CLEARANCE_M, 1e-6);
  near(eyeAltitude(lift(low, -300).range, 0) * R_M, CLEARANCE_M, 1e-6);
  const tilted = pose({ range: 2e-6, pitch: 80 });
  near(eyeAltitude(lift(tilted, 0).range, 80) * R_M, CLEARANCE_M, 1e-6);
  const high = pose({ range: 0.01 });
  assert.equal(lift(high, 1500), high);
});

test('kmPerPxAt: distance × the pixel angle', () => {
  const { eye } = eyeOf(pose()), fov = 35, h = 800;
  near(kmPerPxAt(eye, { lat: 40, lon: -10 }, fov, h), (0.01 * R_KM * 2 * Math.tan((fov * Math.PI) / 360)) / h, 1e-9);
});

test('panning keeps the compass heading (north stays where it was)', () => {
  const p = pose({ lat: 20, lon: 0, range: 2, heading: 0 });
  assert.equal(panPose(p, 0.2, 0).heading, 0);
  assert.equal(panPose(pose({ heading: 30, pitch: 40 }), 0, -0.2).heading, 30);
});

test('the target sits on the ground: range, altitude and the floor measure from it', () => {
  const g = 3000, p = pose({ range: 20 / R_M, ground: g });
  const { eye, target } = eyeOf(p);
  near(len(target), 1 + g / R_M, 1e-15);
  near(len(sub(eye, target)), 20 / R_M, 1e-15);
  near(eyeAltitude(p.range, 0, g) * R_M, g + 20, 1e-6);
  for (const pt of [0, 45, 80]) near(eyeAltitude(rangeForAltitude(0.001, pt, g), pt, g), 0.001, 1e-15);
  near(eyeAltitude(lift(pose({ range: 1e-9, ground: g }), g).range, 0, g) * R_M, g + CLEARANCE_M, 1e-6);
  assert.equal(clampPose({ ground: -5 }).ground, 0);
  assert.equal(clampPose({}).ground, 0);
});

test('holding the forward key across a pole keeps going (no bouncing back and forth)', async () => {
  const { angleRad } = await import('../js/geo.js');
  let p = pose({ lat: 89.5, lon: 0, range: 0.01, heading: 0 }), last = 0;
  const start = { lat: 89.5, lon: 0 };
  for (let i = 0; i < 12; i++) {
    p = panPose(p, 0, -0.2);
    const d = angleRad(start, p);
    assert.ok(d > last, `press ${i}: ${d} after ${last} (lat ${p.lat}, lon ${p.lon})`);
    last = d;
  }
});

test('lift: a floor below sea level only when asked', () => {
  const p = { lat: 0, lon: 0, range: MIN_ALT, heading: 0, pitch: 0, ground: -3000 };
  near(eyeAltitude(lift(p, -3000).range, 0, -3000) * R_M, CLEARANCE_M, 1, 'default: 20 m above sea level');
  near(eyeAltitude(lift(p, -3000, true).range, 0, -3000) * R_M, -3000 + CLEARANCE_M, 1, 'below: 20 m above the floor');
});