aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/tests/globe.test.mjs
blob: 147a16ac290631cc0c994c1e4360df3fc1e9d1c9 (plain)
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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
import './_planet.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fakeRenderer } from './importmap.mjs';

const { GlobeView, MIN_ALT, MAX_ALT } = await import('../js/globe.js');
const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);

function globe(st = { lat: 40, lon: -10, alt: 0.5 }) {
  const g = new GlobeView(fakeRenderer(), () => {});
  g.resize(800, 400);
  g.setState(st);
  return g;
}

test('state round trip; altitude clamped', () => {
  const s = globe().getState();
  near(s.lat, 40, 1e-6); near(s.lon, -10, 1e-6); near(s.alt, 0.5, 1e-9);
  assert.equal(globe({ lat: 0, lon: 0, alt: 99 }).getState().alt.toFixed(6), MAX_ALT.toFixed(6));
  assert.equal(globe({ lat: 0, lon: 0, alt: 0 }).getState().alt.toFixed(6), MIN_ALT.toFixed(6));
});

test('screen centre picks the sub-camera point; off-globe picks null', () => {
  const p = globe().pick(400, 200);
  near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6);
  assert.equal(globe({ lat: 0, lon: 0, alt: 4 }).pick(2, 2), null);
});

test('toScreen inverts pick; far side hidden', () => {
  const g = globe({ lat: 10, lon: 179, alt: 0.3 });                   // straddles the antimeridian
  for (const [x, y] of [[100, 50], [400, 200], [700, 350], [620, 90]]) {
    const p = g.pick(x, y);
    assert.ok(p && Math.abs(p.lon) <= 180, 'finite pick');
    const q = g.toScreen(p.lat, p.lon);
    near(q.x, x, 1e-6); near(q.y, y, 1e-6);
  }
  assert.equal(g.toScreen(-10, 1), null);
});

test('poles stay finite', () => {
  for (const lat of [90, -90]) {
    const s = globe({ lat, lon: 0, alt: 1 }).getState();
    assert.ok(Number.isFinite(s.lat) && Number.isFinite(s.lon) && Math.abs(Math.abs(s.lat) - 90) < 1e-3, JSON.stringify(s));
  }
});

test('overlay lines are clipped to the visible cap (far-side lines never show through when drawn on top)', async () => {
  const THREE = await import('three');
  const { toVec } = await import('../js/geo.js');
  const g = globe({ lat: 10, lon: 20, alt: 0.01 });
  const plane = g.lines.clip?.[0];
  assert.ok(plane, 'lines carry a clipping plane');
  assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 20))) >= 0);
  assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-10, -160))) < 0);
  assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 60))) < 0, '40° away is beyond the horizon at 64 km up');
  g.setState({ lat: -30, lon: 100, alt: 2 });
  assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-30, 100))) >= 0);
});

const ev = (type, o = {}) => Object.assign(new Event(type, { cancelable: true }),
  { clientX: 0, clientY: 0, button: 0, buttons: 1, shiftKey: false, pointerId: 1, deltaY: 0, deltaMode: 0, ...o });

test('tilted state round trip; the pitch levels out when high', async () => {
  const { R_KM } = await import('../js/geo.js');
  const s = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 }).getState();
  near(s.lat, 40, 1e-9); near(s.lon, -10, 1e-9); near(s.alt, 0.01, 1e-12); near(s.hd, 30, 1e-9); near(s.pt, 60, 1e-9);
  assert.equal(globe({ lat: 0, lon: 0, alt: 0.5, hd: 30, pt: 60 }).getState().pt, 0);
  assert.equal(globe({ lat: 0, lon: 0, alt: 3000 / R_KM, pt: 60 }).getState().pt, 0);
  const h = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 });
  h.setState({ lat: 1, lon: 2, alt: 2 });
  assert.deepEqual([h.getState().hd, h.getState().pt], [0, 0], 'no hd/pt = north up, straight down');
});

test('tilted: the centre still picks the target, the top of the screen is sky', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.002, hd: 0, pt: 80 });
  const p = g.pick(400, 200);
  near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6);
  assert.equal(g.pick(400, 3), null);
  assert.ok(g.pick(400, 390).lat < 40, 'the bottom of the screen is nearer ground, behind the target (south)');
});

test('the eye never goes below 20 m; with 3D terrain, 20 m above the loaded ground × exaggeration', async () => {
  const { eyeAltitude, R_M } = await import('../js/camera.js');
  const g = globe({ lat: 40, lon: -10, alt: 1e-9, pt: 0 });
  near(eyeAltitude(g.pose.range, g.pose.pitch) * R_M, 20, 1e-6);
  g.terrain = 2;
  g.tiles = { heightAt: () => 1000 };
  g.setState({ lat: 40, lon: -10, alt: 1e-9 });
  near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 2020, 1e-6);
  g.tiles = { heightAt: () => 3000 };   // finer ground arrived under the camera
  g.keepAboveGround();
  near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6);
  g.tiles = { heightAt: () => null };
  g.keepAboveGround();
  near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6, 'unknown ground never pulls the camera down');
});

test('a restored view keeps its range from the ground that loads after it (a reload lands where it was)', async () => {
  const { R_M } = await import('../js/camera.js');
  const g = new GlobeView(fakeRenderer(), () => {});
  g.resize(800, 400);
  g.terrain = 1;
  let h = null;
  g.tiles = { heightAt: () => h };
  g.setState({ lat: -53, lon: -44, alt: 120 / R_M, pt: 50 });   // the URL's view, heights not loaded yet
  g.keepAboveGround();
  h = 560;                                                         // coarse heights arrive
  g.keepAboveGround();
  near(g.pose.range * R_M, 120, 1e-6);
  h = null;                                                        // tiles reloading: nothing moves
  g.keepAboveGround();
  near(g.pose.range * R_M, 120, 1e-6);
  h = 575;                                                         // finer heights
  g.keepAboveGround();
  near(g.pose.range * R_M, 120, 1e-6);
  assert.equal(g.getState().alt, 120 / R_M, 'the URL round-trips');
  g.setPose({ ...g.pose, heading: 10 });                           // the user moves: the eye stays put from here on
  h = 700;
  g.keepAboveGround();
  assert.ok(g.pose.range * R_M < 120 - 50, 'finer ground after a gesture keeps the eye, not the range');
});

test('drag pans: the grabbed ground stays under the pointer', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement;
  g.activate();
  const grabbed = g.pick(300, 150);
  c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150 }));
  c.dispatchEvent(ev('pointermove', { clientX: 350, clientY: 230 }));
  c.dispatchEvent(ev('pointermove', { clientX: 420, clientY: 260 }));
  c.dispatchEvent(ev('pointerup', { clientX: 420, clientY: 260 }));
  const now = g.pick(420, 260);
  near(now.lat, grabbed.lat, 1e-6); near(now.lon, grabbed.lon, 1e-6);
});

test('Shift+drag or right-drag: up tilts toward the horizon, sideways turns; the wheel zooms; off when inactive', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement;
  c.dispatchEvent(ev('wheel', { deltaY: 100 }));
  near(g.getState().alt, 0.01, 1e-15, 'inactive: ignored');
  g.activate();
  c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, shiftKey: true }));
  c.dispatchEvent(ev('pointermove', { clientX: 400, clientY: 100, shiftKey: true }));
  c.dispatchEvent(ev('pointerup', { clientX: 400, clientY: 100 }));
  assert.ok(g.getState().pt > 30, `pitch ${g.getState().pt}`);
  c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, button: 2, buttons: 2 }));
  c.dispatchEvent(ev('pointermove', { clientX: 300, clientY: 300, buttons: 2 }));
  c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 300, button: 2 }));
  assert.ok(Math.abs(g.getState().hd - 25) < 1e-9, `heading ${g.getState().hd}`);
  const e = ev('wheel', { deltaY: 100 });
  c.dispatchEvent(e);
  assert.ok(g.getState().alt > 0.0105 && e.defaultPrevented);
  const ctx = ev('contextmenu');
  c.dispatchEvent(ctx);
  assert.ok(ctx.defaultPrevented, 'right button belongs to the map');
});

test('pixel size grows with distance when tilted', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 75 });
  assert.ok(g.kmPerPxAt(g.pick(400, 60)) > 3 * g.kmPerPxAt(g.pick(400, 390)));
});

test('a long diagonal drag keeps north up and the grabbed ground under the pointer', () => {
  const g = globe({ lat: 50, lon: 10, alt: 0.3 }), c = g.renderer.domElement;
  g.activate();
  const grabbed = g.pick(200, 100);
  c.dispatchEvent(ev('pointerdown', { clientX: 200, clientY: 100 }));
  for (let i = 1; i <= 10; i++) c.dispatchEvent(ev('pointermove', { clientX: 200 + 40 * i, clientY: 100 + 25 * i }));
  c.dispatchEvent(ev('pointerup', { clientX: 600, clientY: 350 }));
  assert.equal(g.getState().hd, 0);
  const q = g.toScreen(grabbed.lat, grabbed.lon);
  near(q.x, 600, 0.05); near(q.y, 350, 0.05);   // within a twentieth of a pixel
});

test('tilted over 3D terrain, overlay lines are depth-tested (no rivers floating through mountains)', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.001 });
  assert.equal(g.lines.depthTest, false, 'straight down: lines drawn on top of the tiles');
  g.terrain = 1;
  g.setState({ lat: 40, lon: -10, alt: 0.001, pt: 60 });
  assert.equal(g.lines.depthTest, true);
});

test('sky dome and haze near the ground, gone from orbit', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.0005, pt: 70 });
  assert.equal(g.dome.mesh.visible, true);
  assert.ok(g.shade.hazeAmt.value > 0);
  g.setState({ lat: 40, lon: -10, alt: 2 });
  assert.equal(g.dome.mesh.visible, false);
  assert.equal(g.shade.hazeAmt.value, 0);
});

test('tilted over high 3D ground: tile samples hit the ground, nearer and finer than the sea-level pick', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
  g.terrain = 1;
  g.tiles = { heightAt: () => 3000 };
  g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
  const sea = g.pick(400, 200, false), s = g.groundSample(400, 200);
  assert.ok(s.km < 0.95 * g.kmPerPxAt(sea), `${s.km} vs ${g.kmPerPxAt(sea)}`);
  assert.ok(s.lat < sea.lat, 'the ground hit is nearer the camera (south of the sea-level point)');
  g.terrain = 0;
  const f = g.groundSample(400, 200);
  near(f.lat, sea.lat, 1e-9); near(f.km, g.kmPerPxAt(sea), 1e-9);
  g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 80 });
  assert.equal(g.pick(400, 3), null);
  assert.equal(g.groundSample(400, 3), null, 'sky');
});

test('ground sampling finds a slope rising above the eye (camera below the far ground level)', () => {
  const g = globe({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 });
  g.terrain = 1;
  g.tiles = { heightAt: lat => (lat > 40.02 ? 6000 : 500) };   // a wall of mountains just north of the target
  g.setState({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 });
  const s = g.groundSample(400, 100);   // this ray meets the wall face before the 500 m ground
  assert.ok(s, 'hits the wall');
  assert.ok(s.lat > 40.02 && s.lat < 40.1, `wall face at ${s.lat}`);
});

function hillGlobe(h = 3000) {   // tilted over flat 3D ground at h metres
  const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
  g.terrain = 1;
  g.tiles = { heightAt: () => h, heightGen: 1 };
  g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
  return g;
}

test('with 3D terrain a click picks the ground it sees; markers sit on that ground (toScreen inverts pick)', () => {
  const g = hillGlobe(), sea = g.pick(400, 200, false), p = g.pick(400, 200);
  assert.ok(p.lat < sea.lat - 0.001, 'the ground hit is nearer than the sea-level point behind it');
  for (const [x, y] of [[400, 200], [120, 330], [700, 90]]) {
    const q = g.pick(x, y), s = g.toScreen(q.lat, q.lon);
    near(s.x, x, 0.5); near(s.y, y, 0.5);
  }
});

test('grabbing a hill in 3D keeps that hill under the pointer', () => {
  const g = hillGlobe(), c = g.renderer.domElement;
  g.activate();
  const grabbed = g.pick(400, 250);
  c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 250 }));
  c.dispatchEvent(ev('pointermove', { clientX: 450, clientY: 290 }));
  c.dispatchEvent(ev('pointerup', { clientX: 450, clientY: 290 }));
  const s = g.toScreen(grabbed.lat, grabbed.lon);
  near(s.x, 450, 0.5); near(s.y, 290, 0.5);
});

test('with 3D terrain close up, lines lie on the drawn ground (depth-tested); flat again without it', async () => {
  const { R_M } = await import('../js/camera.js');
  const g = hillGlobe(1000);
  g.setLines('rivers', [[{ lat: 40.01, lon: -10 }, { lat: 40.05, lon: -10.02 }]], 0x3399ff);
  g.updateDrape(0);
  let o = g.lines.objects.get('rivers');
  const a = o.geometry.attributes.position.array, r = Math.hypot(a[0] + o.position.x, a[1] + o.position.y, a[2] + o.position.z);
  assert.ok(r > 1 + 1000 / R_M && r < 1 + 1100 / R_M, `${(r - 1) * R_M} m`);
  assert.equal(o.material.depthTest, true);
  assert.equal(o.material.clippingPlanes?.length ?? 0, 0, 'no horizon clip: depth hides what is behind hills');
  g.tiles.heightGen = 2;
  g.tiles.heightAt = () => 2000;
  g.updateDrape(1000);                                  // heights changed: re-laid
  o = g.lines.objects.get('rivers');
  const b = o.geometry.attributes.position.array;
  assert.ok(Math.hypot(b[0] + o.position.x, b[1] + o.position.y, b[2] + o.position.z) > 1 + 2000 / R_M);
  g.terrain = 0;
  g.updateDrape(2000);
  o = g.lines.objects.get('rivers');
  const c = o.geometry.attributes.position.array;
  assert.ok(Math.hypot(c[0], c[1], c[2]) * o.scale.x < 1 + 600 / R_M, 'back near sea level (lifted alt × 0.02, as before)');
  assert.equal(o.position.length(), 0);
});

test('20 m above high ground: a wheel notch and an arrow key move by the height above the ground, not sea level', async () => {
  const { R_M, eyeAltitude } = await import('../js/camera.js');
  const { panPose } = await import('../js/camera.js');
  const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement;
  g.terrain = 1;
  g.tiles = { heightAt: () => 3000, heightGen: 1 };
  g.setState({ lat: 40, lon: -10, alt: 1e-9 });
  const clear = () => eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M - 3000;
  near(clear(), 20, 1e-6);
  g.activate();
  c.dispatchEvent(ev('wheel', { deltaY: 100 }));
  assert.ok(clear() > 21 && clear() < 25, `one notch: ${clear()} m above the ground`);
  const before = g.pick(400, 200);
  g.setPose(panPose(g.pose, 0.2, 0));
  const after = g.pick(400, 200);
  const movedM = Math.hypot(after.lat - before.lat, (after.lon - before.lon) * Math.cos(40 * Math.PI / 180)) * Math.PI / 180 * R_M;
  assert.ok(movedM < 20, `ArrowRight moved ${movedM} m with a ~40 m wide view`);
});

test('dragging over a pole keeps the grabbed ground under the pointer', () => {
  const g = globe({ lat: 89.8, lon: 30, alt: 0.01 }), c = g.renderer.domElement;
  g.activate();
  for (const x0 of [400, 250]) {
    const grabbed = g.pick(x0, 20);
    c.dispatchEvent(ev('pointerdown', { clientX: x0, clientY: 20 }));
    for (let i = 1; i <= 37; i++) c.dispatchEvent(ev('pointermove', { clientX: x0, clientY: 20 + 10 * i }));
    c.dispatchEvent(ev('pointerup', { clientX: x0, clientY: 390 }));
    const s = g.toScreen(grabbed.lat, grabbed.lon);
    near(s.x, x0, 1); near(s.y, 390, 1);
  }
});

test('looking straight down there is no haze (data colours match their legend); tilting brings it in', () => {
  const g = globe({ lat: 40, lon: -10, alt: 40 / 12742 });
  assert.equal(g.shade.hazeAmt.value, 0);
  g.setState({ lat: 40, lon: -10, alt: 40 / 12742, pt: 60 });
  assert.ok(g.shade.hazeAmt.value > 0);
});

test('controls follow one pointer; the right-click menu is only taken while the globe is active; no touch scrolling', async () => {
  const { readFileSync } = await import('node:fs');
  const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement;
  const menu = ev('contextmenu');
  c.dispatchEvent(menu);
  assert.equal(menu.defaultPrevented, false, 'inactive: the browser keeps its menu');
  g.activate();
  const before = g.getState();
  c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150, pointerId: 1 }));
  c.dispatchEvent(ev('pointermove', { clientX: 500, clientY: 300, pointerId: 2 }));   // a second finger
  near(g.getState().lat, before.lat, 1e-12);
  c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 150, pointerId: 1 }));
  assert.match(readFileSync(new URL('../style.css', import.meta.url), 'utf8'), /#map canvas\s*\{[^}]*touch-action:\s*none/);
});

test('dry land below sea level (water on): the eye comes down to 20 m above it, not above 0 m', async () => {
  const { eyeAltitude, R_M } = await import('../js/camera.js');
  const g = new GlobeView(fakeRenderer(), () => {});
  g.terrain = 1;
  g.tiles = { heightAt: () => -14 };   // a dry hollow: the tiles draw it at −14 m (sea cells they draw at 0 m)
  g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 60 });
  near(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M, -14 + 20, 1e-6);
  near(g.pose.ground, -14, 1e-9);
});

test('the eye clears the drawn ground too, not only finer heights loaded but hidden (a coarser stand-in drawn higher)', async () => {
  const { eyeAltitude, R_M } = await import('../js/camera.js');
  const g = new GlobeView(fakeRenderer(), () => {});
  g.terrain = 1;
  g.tiles = { heightAt: (lat, lon, shown) => (shown ? 30 : 0) };   // drawn: a stand-in 30 m up; loaded finer: 0 m
  g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 0 });
  assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6, 'not inside the drawn ground');
  g.keepAboveGround();
  assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6);
});

test('camera floor follows the water mode', () => {
  const g = new GlobeView(fakeRenderer(), () => {});
  g.terrain = 1;
  g.tiles = { heightAt: () => (g.seaFloor ? -4000 : 0) };   // what the tiles draw: the water surface, or the floor
  g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 });
  const above = g.camera.position.length();
  assert.ok(above > 1, 'water on: above sea level');
  g.seaFloor = true;
  g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 });
  assert.ok(g.camera.position.length() < 1, 'water off: may dive below 0 m');
});

test('water off: tiles reloading (no heights yet) keep the camera where it was, not at sea level', () => {
  const g = new GlobeView(fakeRenderer(), () => {});
  g.terrain = 1;
  g.seaFloor = true;
  let h = -4000;
  g.tiles = { heightAt: () => h };
  g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 });
  const below = g.camera.position.length();
  assert.ok(below < 1);
  h = null;                                                    // a layer switch dropped every tile
  g.keepAboveGround();
  assert.ok(Math.abs(g.camera.position.length() - below) < 1e-9, 'not lifted to sea level');
});

test('water off: the coarse sphere sinks below the deepest trench at any exaggeration', async () => {
  const { R_KM } = await import('../js/geo.js');
  const g = new GlobeView(fakeRenderer(), () => {});
  g.terrain = 4;
  g.seaFloor = true;
  g.tiles = { heightAt: () => -2000 };
  g.setPose({ lat: 0, lon: 0, range: 0.01, heading: 0, pitch: 0 });
  assert.ok(g.base.scale.x < 1 - (11 * 4) / R_KM, `sphere at ${g.base.scale.x}`);
});