diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 762dc3644..0a8ef4fe8 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -3,6 +3,7 @@ import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; +import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createApplicationFlights } from './layers/flights.js'; @@ -56,7 +57,7 @@ const SOURCE_METHODS = Object.freeze({ earthquakes: ['getSnapshot'], 'fire-perimeters': ['getSnapshot'], cables: ['fetch'], - mapillary: ['getStatus', 'getTile', 'getSequenceImages', 'nearestImages'], + mapillary: MAPILLARY_SOURCE_METHODS, }); /** diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 1228c5ab6..0576bc0d1 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -95,6 +95,24 @@ export function sequenceIdFromPick(pickId) { * and tree tops. A tile is drawn draped first and swapped for its cast lines * once the terrain heights are in, so coverage never waits on the terrain. */ +/** + * Whether any point of a line's bounding `box` is within the mesh sampler's + * range of the camera ground point `centre`, measured the short way round + * the date line. + */ +export function meshBoxInRange(box, centre) { + // Take the camera's longitude to the box's side of the date line first: + // clamping 179.999 into a box at -179.99 would pick its far edge. + const lon = + centre.lon + + 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); + const nearest = { + lon: Math.min(box.east, Math.max(box.west, lon)), + lat: Math.min(box.north, Math.max(box.south, centre.lat)), + }; + return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; +} + export function createCoverage({ state, source }) { const { render } = state.services; @@ -268,13 +286,6 @@ export function createCoverage({ state, source }) { } /** Whether any of a {west, south, east, north} box is within the sampler's range. */ - function inMeshRange(box, centre) { - const nearest = { - lon: Math.min(box.east, Math.max(box.west, centre.lon)), - lat: Math.min(box.north, Math.max(box.south, centre.lat)), - }; - return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; - } /** * A tile's drawn lines with their bounding boxes, built once per filter; @@ -310,10 +321,11 @@ export function createCoverage({ state, source }) { const sampler = state.context.meshSampler; if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; const centre = meshCentre(); - if (!centre || !entry.bounds || !inMeshRange(entry.bounds, centre)) return; + if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) + return; const points = []; for (const line of meshParts(entry)) { - if (!inMeshRange(line.box, centre)) continue; + if (!meshBoxInRange(line.box, centre)) continue; line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); for (const point of line.points) points.push(point); } @@ -668,6 +680,7 @@ export function createCoverage({ state, source }) { function refresh() { const viewer = state.viewer; if (!viewer || !state.context.isActive() || state.keyRequired) return; + if (state.statusKnown === false) return; if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; const ground = groundUnderCamera(viewer, { groundAt: state.context.groundCaster?.groundAt, diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 8353791e3..ffc381ef5 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; -import { SWAP_MAX_WAIT_MS, createCoverage } from './coverage.js'; +import { + SWAP_MAX_WAIT_MS, + createCoverage, + meshBoxInRange, +} from './coverage.js'; import { encodeCoverageTile } from './coverageFixture.mjs'; import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; import { @@ -893,3 +897,16 @@ test('a rate limit holds every refresh inside its wait, not only the first (revi coverage.clear(); coverage.resetErrors(); }); + +test('a line across the date line from the camera is in mesh range (gekh P3)', () => { + // A z14 tile's width of lines just past -180, the camera 111 m short of it. + const camera = { lon: 179.999, lat: 0 }; + const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 }; + assert.equal(meshBoxInRange(east, camera), true); + // The mirror case, from the other side. + const west = { west: 179.978, east: 180, south: -0.001, north: 0.001 }; + assert.equal(meshBoxInRange(west, { lon: -179.999, lat: 0 }), true); + // ~2.2 km away across the line: out of range either way. + const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 }; + assert.equal(meshBoxInRange(far, camera), false); +}); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 3a5b5aca6..b71db46ad 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -16,8 +16,6 @@ import { PICK_PREFIX, } from './policy.js'; -export { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js'; - /** Per-provider mutable state, created once per `create()`. */ function createProviderState(context) { return { @@ -25,6 +23,11 @@ function createProviderState(context) { services: context.services, viewer: null, keyRequired: false, + /** + * False until the first status answer: coverage asks for nothing before + * it, so a key-less install never sends tile requests that get 503. + */ + statusKnown: false, /** Mapillary refused the configured token: nothing is requested until re-enabled. */ keyRejected: false, status: null, @@ -101,6 +104,10 @@ export function createMapillaryProvider({ source }) { state.status = null; state.keyRequired = !source.hasToken(); } + const first = !state.statusKnown; + state.statusKnown = true; + // Coverage held back until now: draw it if the layer is on. + if (first) parts.coverage.refresh(); context.notify(); return { configured: !state.keyRequired }; }, diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index 0d2bcba1f..eec27f559 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -1,11 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createMapillaryProvider, mapillaryImageUrl } from './index.js'; +import { createMapillaryProvider } from './index.js'; +import { mapillaryImageUrl } from './policy.js'; import { sequenceIdFromPick } from './coverage.js'; import { thinImages } from './sequences.js'; import { validateProviders } from '../../registry.js'; import { PROVIDER_COLORS } from '../../policy.js'; import { resolveFilter } from '../../filter.js'; +import * as Cesium from 'cesium'; +import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; function fakeSource({ nearest = [] } = {}) { return { @@ -180,3 +183,70 @@ test('cone thinning across the date line drops images metres apart (review IC8 P ['a', 'c'], ); }); + +/** A viewer looking straight down on downtown Sacramento from 900 m. */ +function streetViewer() { + const collection = () => ({ + add: (item) => item, + remove: () => true, + contains: () => false, + get length() { + return 0; + }, + }); + const event = () => ({ addEventListener: () => () => {} }); + const camera = Object.assign( + rayCamera({ + lon: -121.4944, + lat: 38.5816, + altitude: 900, + pitch: -90, + width: 100, + height: 100, + }), + { changed: event(), moveEnd: event() }, + ); + return { + camera, + scene: { + canvas: { clientWidth: 100, clientHeight: 100 }, + globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, + primitives: collection(), + groundPrimitives: collection(), + preRender: event(), + postRender: event(), + requestRender() {}, + }, + }; +} + +test('coverage asks for no tiles before the key status is known (gekh P3)', async () => { + for (const configured of [false, true]) { + let answer; + const tiles = []; + const source = { + ...fakeSource(), + hasToken: () => configured, + getStatus: () => new Promise((resolve) => (answer = resolve)), + getTile: (...args) => { + tiles.push(args); + return new Promise(() => {}); + }, + }; + const instance = createMapillaryProvider({ source }).create(fakeContext()); + const viewer = streetViewer(); + instance.init(viewer); + const status = instance.status(); + instance.activate(viewer); + assert.equal(tiles.length, 0, 'nothing before the status answers'); + answer({ configured }); + await status; + if (configured) + assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known'); + else { + assert.equal(tiles.length, 0, 'a key-less install never asks'); + assert.equal(instance.coverageStats().keyRequired, true); + } + instance.destroy(viewer); + } +}); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index e02462f19..80e793524 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -2,9 +2,8 @@ import * as Cesium from 'cesium'; import { imageConeGlyph } from '../../glyphs.js'; import { passesImageryFilter } from '../../filter.js'; import { refineHeights } from '../../groundCast.js'; -import { createHorizonCull } from '../../view.js'; +import { createHorizonCull, metresBetween } from '../../view.js'; import { meshCellKey } from '../../meshSampler.js'; -import { metresBetween } from '../../view.js'; import { COLORS, IMAGE_CONE_MIN_SPACING_M, diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index 543ee2da0..1f66aabf8 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -124,7 +124,10 @@ export function createMapillaryViewer({ source, render } = {}) { zoom: true, attribution: true, }, - trackResize: true, + // The panel's ResizeObserver resizes the viewer, and skips it while it + // is hidden (0x0); MapillaryJS's own window tracking would resize a + // hidden viewer and ask for a tile at z=NaN. + trackResize: false, renderMode: libraryRenderMode(renderMode), }); container = host; diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index a90185f77..0ecf69d42 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -251,6 +251,14 @@ export function viewFocus( return { nadir, ahead }; } +/** How far below WGS84 the horizon cull's occluder sits, in metres. */ +export const HORIZON_CULL_DEPTH_M = 1000; +const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid( + Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, + Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, + Cesium.Ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M, +); + /** * Hide billboards and points behind the horizon, as the cyclones layer does. * Street Level's skip the depth test, so Google 3D and clouds never hide the @@ -258,10 +266,16 @@ export function viewFocus( * the globe, and a finite skip distance cannot, as the horizon's distance * moves with the camera. Re-culls on pre-render only once the camera moved, * and stops listening when there is nothing left to cull. + * + * The occluder is the WGS84 ellipsoid shrunk by HORIZON_CULL_DEPTH_M, below + * any real surface: on the ellipsoid itself a camera standing on ground that + * lies below it (NYC −22 m, Colombo −95 m) would see every lower point as + * behind the horizon and hide the cones around it. A kilometre changes + * nothing for the far side of the Earth seen from orbit. * @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options */ export function createHorizonCull({ getViewer, items, onChange }) { - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + const occluder = new Cesium.EllipsoidalOccluder(HORIZON_CULL_ELLIPSOID); let from = null; let stopListening = null; diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index ecd3d16a4..a92b1206d 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -265,3 +265,58 @@ test('the horizon cull stops listening once nothing is left, and starts again fo 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 (gekh P2)', () => { + // NYC in FOLLOW: 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: as before. + 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 (gekh P2)', () => { + assert.deepEqual( + cullFrom( + [0, 0, 20_000_000], + [ + [10, 10, 0], + [180, 0, 0], + ], + ), + [true, false], + ); +}); diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 8ff71200d..b92bad313 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -365,7 +365,8 @@ function fakeLibrary() { positionGate: null, }; class Viewer { - constructor() { + constructor(options) { + this.options = options; viewers.created++; viewers.live++; viewers.instances.push(this); @@ -457,6 +458,15 @@ test('a prewarm that gave up when the layer went off and on mid-download is foll adapter.unmount(); }); +test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN (gekh P3)', async () => { + const { gate, viewers } = fakeLibrary(); + gate.resolve(); + const adapter = createMapillaryViewer({ source: { token: 't' } }); + await adapter.mount({}); + assert.equal(viewers.instances[0].options.trackResize, false); + adapter.unmount(); +}); + test('closing the photo stops sequence playback in the hidden viewer (review P2-6)', async () => { const { gate, viewers } = fakeLibrary(); gate.resolve();