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