diff options
Diffstat (limited to 'viewer/tests/globe.test.mjs')
| -rw-r--r-- | viewer/tests/globe.test.mjs | 377 |
1 files changed, 377 insertions, 0 deletions
diff --git a/viewer/tests/globe.test.mjs b/viewer/tests/globe.test.mjs new file mode 100644 index 0000000..147a16a --- /dev/null +++ b/viewer/tests/globe.test.mjs @@ -0,0 +1,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}`); +}); |
