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'); });