mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
- visibleBbox: no view-rectangle fallback or grid option; its one caller always ranges the rays - tilesForBbox returns the tiles; normalizeBbox takes arrays only - viewerHost: no try/catch around adapter calls that catch for themselves - coverage: counts come from the drawn sequences; no sequenceCount export - one keyStatusLabel for the pill and the layer list - no constructor source check, double-init guard or selectSequence / clearSequence on the layer Co-Authored-By: Claude Opus 5.5 <[email protected]>
388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import * as Cesium from 'cesium';
|
||
import {
|
||
cameraNadir,
|
||
createHorizonCull,
|
||
groundUnderCamera,
|
||
metresBetween,
|
||
viewFocus,
|
||
visibleBbox,
|
||
} from './view.js';
|
||
import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js';
|
||
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
||
|
||
const RAD = Math.PI / 180;
|
||
/** A viewer 300 m above a street 1,600 m up (Denver-like). */
|
||
function viewer({ globeShown, globeHeight }) {
|
||
return {
|
||
camera: {
|
||
positionCartographic: {
|
||
longitude: -104.99 * RAD,
|
||
latitude: 39.74 * RAD,
|
||
height: 1900,
|
||
},
|
||
},
|
||
scene: { globe: { show: globeShown, getHeight: () => globeHeight } },
|
||
};
|
||
}
|
||
|
||
test('a shown globe answers the ground under the camera', () => {
|
||
assert.equal(
|
||
groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })),
|
||
1600,
|
||
);
|
||
});
|
||
|
||
test('a hidden globe (Google 3D) or a missing sample gives no ground', () => {
|
||
// A hidden globe's getHeight is ignored even when it returns a number.
|
||
assert.equal(
|
||
groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })),
|
||
null,
|
||
);
|
||
assert.equal(groundUnderCamera({}), null);
|
||
});
|
||
|
||
/** The Denver viewer with Google 3D: no globe, a sampleable rendered surface. */
|
||
function google3d(sample) {
|
||
const view = viewer({ globeShown: false, globeHeight: 0 });
|
||
view.scene.sampleHeightSupported = true;
|
||
view.scene.sampleHeight = () => sample;
|
||
return view;
|
||
}
|
||
|
||
test('on Google 3D the rendered surface answers the ground under the camera', () => {
|
||
assert.equal(groundUnderCamera(google3d(1612)), 1612);
|
||
});
|
||
|
||
test('an implausible or above-camera surface sample gives no ground', () => {
|
||
// Before tiles stream in, sampleHeight can read kilometres underground.
|
||
assert.equal(groundUnderCamera(google3d(-14_886)), null);
|
||
// A roof above the camera (1,900 m) would put the camera underground.
|
||
assert.equal(groundUnderCamera(google3d(1950)), null);
|
||
assert.equal(groundUnderCamera(google3d(undefined)), null);
|
||
});
|
||
|
||
/** A camera whose screen rays land on a grid of lon/lat points. */
|
||
function gridViewer(lons, lats) {
|
||
const points = [];
|
||
for (const lat of lats)
|
||
for (const lon of lons)
|
||
points.push(Cesium.Cartesian3.fromDegrees(lon, lat));
|
||
let next = 0;
|
||
return {
|
||
scene: { canvas: { clientWidth: 100, clientHeight: 100 }, globe: {} },
|
||
camera: {
|
||
pickEllipsoid: () => points[next++ % points.length],
|
||
positionCartographic: { longitude: 0, latitude: 0, height: 1000 },
|
||
},
|
||
};
|
||
}
|
||
|
||
test('a view across the date line is a narrow box with west > east', () => {
|
||
const viewer = gridViewer([178, 179, -179, -178], [-1, 0, 1]);
|
||
const bbox = visibleBbox(viewer);
|
||
assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`);
|
||
assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`);
|
||
});
|
||
|
||
test('an ordinary view keeps west < east', () => {
|
||
const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51]));
|
||
assert.ok(bbox[0] < bbox[2]);
|
||
});
|
||
|
||
/** A pinhole viewer `agl` m above ground `ground` m up, looking north. */
|
||
function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) {
|
||
return {
|
||
scene: {
|
||
canvas: { clientWidth: w, clientHeight: h },
|
||
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||
},
|
||
camera: rayCamera({
|
||
lon,
|
||
lat,
|
||
altitude: ground + agl,
|
||
pitch,
|
||
width: w,
|
||
height: h,
|
||
}),
|
||
};
|
||
}
|
||
|
||
test('a tilted street view over high ground boxes the streets it looks at, not the horizon', () => {
|
||
// Denver, 600 m above a street 1,610 m up, looking 20° down: the centre of
|
||
// the screen meets the street about 1.65 km north of the camera.
|
||
const view = pinhole({
|
||
lon: -104.99,
|
||
lat: 39.74,
|
||
ground: 1610,
|
||
agl: 600,
|
||
pitch: -20,
|
||
});
|
||
const ahead = 39.74 + 1648 / 111_000;
|
||
const unranged = visibleBbox(view);
|
||
// Without options, rays to the bare ellipsoid travel 1.6 km further down:
|
||
// the box is 20 km wide and starts past the street at the screen centre.
|
||
assert.ok(unranged[2] - unranged[0] > 0.2);
|
||
assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
|
||
const options = { groundHeight: 1610, maxRange: 6000 };
|
||
const [west, south, east, north] = visibleBbox(view, options);
|
||
assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box');
|
||
assert.ok(south < ahead && north > ahead, 'the screen centre is inside');
|
||
assert.ok(
|
||
south > 39.74,
|
||
'it starts ahead of the camera, where the view does',
|
||
);
|
||
assert.ok(west < -104.99 && east > -104.99);
|
||
const nadir = cameraNadir(view);
|
||
assert.ok(
|
||
Math.abs(nadir.lat - 39.74) < 1e-9 && Math.abs(nadir.lon + 104.99) < 1e-9,
|
||
);
|
||
});
|
||
|
||
test('a tilted view from 3 km loads the tile at the centre of the screen', () => {
|
||
// 3 km up, 70° from straight down: the screen centre meets the ground
|
||
// 8.2 km ahead, past the 3 × 3 z13 tiles around the camera's ground point.
|
||
const lon = -121.49;
|
||
const lat = 38.58;
|
||
const view = pinhole({ lon, lat, ground: 10, agl: 3000, pitch: -20 });
|
||
const ranged = { groundHeight: 10, maxRange: 30_000 };
|
||
const bbox = visibleBbox(view, ranged);
|
||
const ahead = lat + 3000 / Math.tan((20 * Math.PI) / 180) / 110_540;
|
||
const centreTile = `${lonToTileX(lon, 13)}/${latToTileY(ahead, 13)}`;
|
||
const chosen = (from) =>
|
||
tilesForBbox(bbox, 13, { limit: 9, from }).map(
|
||
(tile) => `${tile.x}/${tile.y}`,
|
||
);
|
||
assert.ok(
|
||
!chosen(cameraNadir(view)).includes(centreTile),
|
||
'ranked from the nadir alone, the screen centre is left out',
|
||
);
|
||
const focus = viewFocus(view, ranged);
|
||
assert.ok(focus.lat > lat && focus.lat < ahead, 'between camera and centre');
|
||
assert.ok(chosen(focus).includes(centreTile), 'the screen centre is loaded');
|
||
// The bottom edge of the screen (50° down) meets the ground 2.5 km ahead.
|
||
const nearest = lat + 3000 / Math.tan((50 * Math.PI) / 180) / 110_540;
|
||
assert.ok(
|
||
chosen(focus).includes(`${lonToTileX(lon, 13)}/${latToTileY(nearest, 13)}`),
|
||
'and so is the nearest ground in view',
|
||
);
|
||
});
|
||
|
||
test('looking straight down or at the sky, the focus is the ground under the camera', () => {
|
||
const lon = -121.49;
|
||
const lat = 38.58;
|
||
const down = viewFocus(
|
||
pinhole({ lon, lat, ground: 10, agl: 500, pitch: -90 }),
|
||
{ groundHeight: 10, maxRange: 5000 },
|
||
);
|
||
assert.ok(Math.abs(down.lat - lat) < 1e-4 && Math.abs(down.lon - lon) < 1e-4);
|
||
const sky = viewFocus(pinhole({ lon, lat, ground: 10, agl: 2, pitch: 60 }), {
|
||
groundHeight: 10,
|
||
maxRange: 2500,
|
||
});
|
||
assert.ok(Math.abs(sky.lat - lat) < 1e-9 && Math.abs(sky.lon - lon) < 1e-9);
|
||
});
|
||
|
||
test('looking at the horizon from eye height boxes the near ground, not the horizon', () => {
|
||
const view = pinhole({
|
||
lon: -121.4944,
|
||
lat: 38.5816,
|
||
ground: 10,
|
||
agl: 2,
|
||
pitch: -5,
|
||
});
|
||
const [west, south, east, north] = visibleBbox(view, {
|
||
groundHeight: 10,
|
||
maxRange: 2500,
|
||
});
|
||
assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead');
|
||
assert.ok(north - 38.5816 < 2500 / 111_000, 'no further than the range');
|
||
assert.ok(west < -121.4944 && east > -121.4944);
|
||
// Looking at the sky, nothing is in range: no box, never the horizon's.
|
||
const sky = pinhole({
|
||
lon: -121.4944,
|
||
lat: 38.5816,
|
||
ground: 10,
|
||
agl: 2,
|
||
pitch: 60,
|
||
});
|
||
assert.equal(visibleBbox(sky, { groundHeight: 10, maxRange: 2500 }), null);
|
||
});
|
||
|
||
test('metresBetween measures the short way round the date line', () => {
|
||
const east = { lon: 179.9999, lat: 0 };
|
||
const west = { lon: -179.9999, lat: 0 };
|
||
// 0.0002° of longitude at the equator, not 40,000 km round the other way.
|
||
assert.ok(Math.abs(metresBetween(east, west) - 22.264) < 1e-6);
|
||
assert.ok(Math.abs(metresBetween(west, east) - 22.264) < 1e-6);
|
||
assert.ok(
|
||
Math.abs(
|
||
metresBetween({ lon: 180, lat: 0 }, { lon: -180, lat: 0.0001 }) - 11.054,
|
||
) < 1e-6,
|
||
'the two names of the same meridian',
|
||
);
|
||
// Away from the date line nothing changes.
|
||
assert.ok(
|
||
Math.abs(
|
||
metresBetween({ lon: 10, lat: 0 }, { lon: 10.001, lat: 0 }) - 111.32,
|
||
) < 1e-9,
|
||
);
|
||
});
|
||
|
||
/**
|
||
* A horizon cull over `items` for a camera the test moves, with the frame's
|
||
* pre-render listeners and a count of the points tested against the horizon.
|
||
*/
|
||
function cullHarness(t, items) {
|
||
const visible = t.mock.method(
|
||
Cesium.EllipsoidalOccluder.prototype,
|
||
'isPointVisible',
|
||
);
|
||
const preRender = new Set();
|
||
const camera = { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) };
|
||
const viewer = {
|
||
camera,
|
||
scene: {
|
||
preRender: {
|
||
addEventListener(listener) {
|
||
preRender.add(listener);
|
||
return () => preRender.delete(listener);
|
||
},
|
||
},
|
||
},
|
||
};
|
||
let changes = 0;
|
||
const cull = createHorizonCull({
|
||
getViewer: () => viewer,
|
||
items: () => items,
|
||
onChange: () => changes++,
|
||
});
|
||
return {
|
||
cull,
|
||
preRender,
|
||
tested: () => visible.mock.callCount(),
|
||
changes: () => changes,
|
||
frame: () => {
|
||
for (const listener of [...preRender]) listener();
|
||
},
|
||
// Moved in place, as Cesium updates positionWC.
|
||
moveTo: (lon, lat) =>
|
||
Cesium.Cartesian3.fromDegrees(
|
||
lon,
|
||
lat,
|
||
1e7,
|
||
undefined,
|
||
camera.positionWC,
|
||
),
|
||
};
|
||
}
|
||
|
||
const point = (lon, lat) => ({
|
||
position: Cesium.Cartesian3.fromDegrees(lon, lat),
|
||
show: true,
|
||
});
|
||
|
||
test('the horizon cull does no work while the camera stands still', (t) => {
|
||
const near = point(0, 0);
|
||
const far = point(180, 0);
|
||
const h = cullHarness(t, [near, far]);
|
||
h.cull.update();
|
||
assert.equal(h.tested(), 2);
|
||
assert.deepEqual([near.show, far.show], [true, false]);
|
||
assert.equal(h.preRender.size, 1, 'listening for camera moves');
|
||
for (let i = 0; i < 5; i++) h.frame();
|
||
assert.equal(h.tested(), 2, 'a still camera re-culls nothing');
|
||
h.moveTo(180, 0);
|
||
h.frame();
|
||
assert.equal(h.tested(), 4, 'a moved camera re-culls everything once');
|
||
assert.deepEqual([near.show, far.show], [false, true]);
|
||
h.frame();
|
||
h.frame();
|
||
assert.equal(h.tested(), 4, 'and then rests again');
|
||
h.cull.stop();
|
||
assert.equal(h.preRender.size, 0);
|
||
});
|
||
|
||
test('the horizon cull stops listening once nothing is left, and starts again for new items', (t) => {
|
||
const items = [point(0, 0)];
|
||
const h = cullHarness(t, items);
|
||
h.cull.update();
|
||
assert.equal(h.preRender.size, 1);
|
||
// Every item goes (the tiles are dropped); the next camera move finds none.
|
||
items.length = 0;
|
||
h.moveTo(10, 0);
|
||
h.frame();
|
||
assert.equal(h.preRender.size, 0, 'the listener is removed');
|
||
// New items arrive: they are culled now, and camera moves are watched again.
|
||
const far = point(-170, 0);
|
||
items.push(far);
|
||
h.cull.update([far]);
|
||
assert.equal(far.show, false, 'culled on arrival');
|
||
assert.equal(h.preRender.size, 1, 'the listener is back');
|
||
h.moveTo(-170, 0);
|
||
h.frame();
|
||
assert.equal(far.show, true, 're-culled when the camera moves');
|
||
assert.equal(h.changes(), 2, 'a redraw for each change');
|
||
h.cull.stop();
|
||
});
|
||
|
||
/** A cull over `points` ([lon, lat, height]) seen from a camera at `camera`. */
|
||
function cullFrom(camera, points) {
|
||
const items = points.map(([lon, lat, height]) => ({
|
||
position: Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
||
show: true,
|
||
}));
|
||
const horizon = createHorizonCull({
|
||
getViewer: () => ({
|
||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(...camera) },
|
||
scene: { preRender: { addEventListener: () => () => {} } },
|
||
}),
|
||
items: () => items,
|
||
});
|
||
horizon.update();
|
||
horizon.stop();
|
||
return items.map((item) => item.show);
|
||
}
|
||
|
||
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
|
||
// NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away.
|
||
assert.deepEqual(
|
||
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
|
||
[true],
|
||
);
|
||
// Colombo framing view: camera at -21 m, cones at -95 m about 80 m away.
|
||
assert.deepEqual(
|
||
cullFrom(
|
||
[79.8612, 6.9271, -21],
|
||
[
|
||
[79.8612, 6.92782, -95],
|
||
[79.86192, 6.9271, -95],
|
||
],
|
||
),
|
||
[true, true],
|
||
);
|
||
// Austin, ground well above the ellipsoid.
|
||
assert.deepEqual(
|
||
cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]),
|
||
[true],
|
||
);
|
||
});
|
||
|
||
test('the horizon cull still hides the far side of the Earth from orbit', () => {
|
||
assert.deepEqual(
|
||
cullFrom(
|
||
[0, 0, 20_000_000],
|
||
[
|
||
[10, 10, 0],
|
||
[180, 0, 0],
|
||
],
|
||
),
|
||
[true, false],
|
||
);
|
||
});
|