diff --git a/.gitignore b/.gitignore index 14e8d320b..0180accfc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ output/ .gev-cache/ # Browser QA gate evidence (screenshots, console and network dumps). qa-artifacts/ +# Playwright MCP session logs: browser consoles can carry tokens in URLs. +.playwright-mcp/ .DS_Store .gstack/ 3d-models/ diff --git a/src/layers/streetLevel/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js index fa0545719..13246570e 100644 --- a/src/layers/streetLevel/cameraFraming.js +++ b/src/layers/streetLevel/cameraFraming.js @@ -1,8 +1,5 @@ import * as Cesium from 'cesium'; - -/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */ -const SURFACE_MIN_M = -500; -const SURFACE_MAX_M = 9000; +import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; /** Fly the globe camera once to frame a photo after it opens. */ export function createCameraFraming({ state }) { @@ -34,19 +31,10 @@ export function createCameraFraming({ state }) { function groundHeightAt(lon, lat, fallback) { const scene = state.viewer?.scene; const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const plausible = (height) => - Number.isFinite(height) && - height >= SURFACE_MIN_M && - height <= SURFACE_MAX_M; - let height = null; - try { - if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); - } catch { - /* not sampleable yet */ - } - if (!plausible(height)) height = scene?.globe?.getHeight?.(carto); - if (!plausible(height)) height = fallback; - return plausible(height) ? height : 0; + let height = sampledSurfaceHeight(scene, carto); + if (height === null) height = scene?.globe?.getHeight?.(carto); + if (!plausibleSurfaceHeight(height)) height = fallback; + return plausibleSurfaceHeight(height) ? height : 0; } /** diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 879e628d8..e1fea56df 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -2,7 +2,7 @@ import * as Cesium from 'cesium'; import { decodeCoverageTile } from './decode.js'; import { passesImageryFilter, resolveFilter } from '../../filter.js'; import { isActive } from '../../state.js'; -import { cameraNadir, groundUnderCamera, visibleBbox } from '../../view.js'; +import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js'; import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js'; import { COLORS, @@ -318,19 +318,20 @@ export function createCoverage({ state, source }) { if (zoom == null) return { hint: ZOOM_IN_HINT }; // Rays meet the ground where it really is (1,600 m up in Denver) and stop // short of the horizon, which would stretch the box to the world. - const bbox = visibleBbox(viewer, { + const ranged = { groundHeight: ground, maxRange: Math.max( SEQUENCE_VIEW_RANGE_MIN_M, height * SEQUENCE_VIEW_RANGE_PER_HEIGHT, ), - }); + }; + const bbox = visibleBbox(viewer, ranged); if (!bbox) return { hint: NO_GROUND_HINT }; - // Ranked from the ground under the camera: a tilted view's box centre - // can sit kilometres ahead of anything near. + // Ranked from between the camera's ground point and the screen centre's: + // a tilted view's box centre can sit kilometres past both. const { tiles } = tilesForBbox(bbox, zoom, { limit: COVERAGE_MAX_TILES, - from: cameraNadir(viewer), + from: viewFocus(viewer, ranged), }); return { zoom, tiles }; } diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index c0aa6a6d4..4cfd98b2c 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -10,16 +10,43 @@ export function metresBetween(a, b) { ); } +/** Whether a height (m) is plausible ellipsoidal land: Dead Sea to Everest, with geoid slack. */ +export function plausibleSurfaceHeight(height) { + return Number.isFinite(height) && height >= -500 && height <= 9000; +} + /** - * Ellipsoidal height (m) of the globe terrain under the camera; null where - * the globe is hidden (Google 3D) or not yet loaded. + * Height (m) of the rendered surface, 3D tiles included, at `carto`; null + * when unsampleable or implausible (`sampleHeight` reads kilometres + * underground before tiles load). + */ +export function sampledSurfaceHeight(scene, carto) { + let height = null; + try { + if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); + } catch { + /* not sampleable yet */ + } + return plausibleSurfaceHeight(height) ? height : null; +} + +/** + * Ellipsoidal height (m) of the ground under the camera, or null. The globe + * answers where shown; Google 3D hides it, so the rendered surface does + * (roofs count: a building's height at most). A sample above the camera is + * ignored: a raised ground would put the camera underground. */ export function groundUnderCamera(viewer) { const carto = viewer?.camera?.positionCartographic; - const globe = viewer?.scene?.globe; - if (!carto || !globe || globe.show === false) return null; - const ground = globe.getHeight?.(carto); - return Number.isFinite(ground) ? ground : null; + const scene = viewer?.scene; + if (!carto || !scene) return null; + const globe = scene.globe; + if (globe && globe.show !== false) { + const ground = globe.getHeight?.(carto); + if (Number.isFinite(ground)) return ground; + } + const sampled = sampledSurfaceHeight(scene, carto); + return sampled !== null && sampled < carto.height ? sampled : null; } /** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */ @@ -150,6 +177,41 @@ export function cameraNadir(viewer) { }; } +/** + * Where coverage tiles are ranked from: halfway between the ground under the + * camera and the ground at the centre of the screen, so a tilted view loads + * what it looks at as well as what is near. The nadir when the centre ray + * misses the ground within `maxRange`. Options as `visibleBbox`. + */ +export function viewFocus( + viewer, + { groundHeight = null, maxRange = null } = {}, +) { + const nadir = cameraNadir(viewer); + const scene = viewer?.scene; + const width = scene?.canvas?.clientWidth || scene?.canvas?.width || 0; + const height = scene?.canvas?.clientHeight || scene?.canvas?.height || 0; + if (!nadir || !width || !height) return nadir; + const hit = groundHit( + viewer.camera, + new Cesium.Cartesian2(width / 2, height / 2), + raisedEllipsoid( + scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, + groundHeight, + ), + maxRange, + ); + if (!hit) return nadir; + const ahead = { + lon: Cesium.Math.toDegrees(hit.longitude), + lat: Cesium.Math.toDegrees(hit.latitude), + }; + return { + lon: wrapLon(nadir.lon + wrapLon(ahead.lon - nadir.lon) / 2), + lat: (nadir.lat + ahead.lat) / 2, + }; +} + /** * Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid * (NYC −22 m), and an occluder at 0 m would hide the cones around it. diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index 5d1e8c530..3c8370f3e 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -7,8 +7,10 @@ import { groundUnderCamera, metresBetween, viewCentre, + viewFocus, visibleBbox, } from './view.js'; +import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js'; import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; const RAD = Math.PI / 180; @@ -46,6 +48,26 @@ test('a hidden globe (Google 3D) or a missing sample gives no ground', () => { 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 = []; @@ -128,6 +150,50 @@ test('a tilted street view over high ground boxes the streets it looks at, not t ); }); +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 }).tiles.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,