raw · 7417 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | 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'); }); |