From 8706cafdd1959ca74013b865d6a039459cf604b3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 7 Oct 2026 23:50:44 +0400 Subject: [PATCH] fix(street-level): ground under the camera on Google 3D; tilted views load what they look at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the slimming, found in review: - Google 3D hides the globe, so groundUnderCamera returned null once the ground caster was gone: in high cities (Denver ~1,600 m) the camera read kilometres too high, the coverage zoom came out coarse and the visible box slid kilometres ahead. With the globe hidden (or not yet loaded) the ground now comes from the rendered surface (scene.sampleHeight), checked for plausibility and ignored when above the camera. The plausibility check is shared with photo framing (plausibleSurfaceHeight, sampledSurfaceHeight). - Tiles were ranked from the camera's ground point alone, so a tilted view from a few kilometres up (z13) could miss the tile at the centre of the screen. They are ranked from viewFocus: halfway between that point and the ground at the screen centre (or the nadir when the centre ray misses). A test pins the 3 km / 70° case: the nadir ranking leaves the centre tile out, the focus ranking loads it. Also ignore .playwright-mcp/: its browser console logs can carry tokens. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + src/layers/streetLevel/cameraFraming.js | 22 ++---- .../providers/mapillary/coverage.js | 13 ++-- src/layers/streetLevel/view.js | 74 +++++++++++++++++-- src/layers/streetLevel/view.test.mjs | 66 +++++++++++++++++ 5 files changed, 148 insertions(+), 29 deletions(-) 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,