mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(street-level): ground under the camera on Google 3D; tilted views load what they look at
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 <[email protected]>
This commit is contained in:
1 parent
346245e8e7
commit
8706cafdd1
5 files changed
+148
-29
No files matched your search
@@ -16,6 +16,8 @@ output/
|
|||||||
.gev-cache/
|
.gev-cache/
|
||||||
# Browser QA gate evidence (screenshots, console and network dumps).
|
# Browser QA gate evidence (screenshots, console and network dumps).
|
||||||
qa-artifacts/
|
qa-artifacts/
|
||||||
|
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
|
||||||
|
.playwright-mcp/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.gstack/
|
.gstack/
|
||||||
3d-models/
|
3d-models/
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
|
||||||
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
|
|
||||||
const SURFACE_MIN_M = -500;
|
|
||||||
const SURFACE_MAX_M = 9000;
|
|
||||||
|
|
||||||
/** Fly the globe camera once to frame a photo after it opens. */
|
/** Fly the globe camera once to frame a photo after it opens. */
|
||||||
export function createCameraFraming({ state }) {
|
export function createCameraFraming({ state }) {
|
||||||
@@ -34,19 +31,10 @@ export function createCameraFraming({ state }) {
|
|||||||
function groundHeightAt(lon, lat, fallback) {
|
function groundHeightAt(lon, lat, fallback) {
|
||||||
const scene = state.viewer?.scene;
|
const scene = state.viewer?.scene;
|
||||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||||
const plausible = (height) =>
|
let height = sampledSurfaceHeight(scene, carto);
|
||||||
Number.isFinite(height) &&
|
if (height === null) height = scene?.globe?.getHeight?.(carto);
|
||||||
height >= SURFACE_MIN_M &&
|
if (!plausibleSurfaceHeight(height)) height = fallback;
|
||||||
height <= SURFACE_MAX_M;
|
return plausibleSurfaceHeight(height) ? height : 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as Cesium from 'cesium';
|
|||||||
import { decodeCoverageTile } from './decode.js';
|
import { decodeCoverageTile } from './decode.js';
|
||||||
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
||||||
import { isActive } from '../../state.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 { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
@@ -318,19 +318,20 @@ export function createCoverage({ state, source }) {
|
|||||||
if (zoom == null) return { hint: ZOOM_IN_HINT };
|
if (zoom == null) return { hint: ZOOM_IN_HINT };
|
||||||
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
|
// 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.
|
// short of the horizon, which would stretch the box to the world.
|
||||||
const bbox = visibleBbox(viewer, {
|
const ranged = {
|
||||||
groundHeight: ground,
|
groundHeight: ground,
|
||||||
maxRange: Math.max(
|
maxRange: Math.max(
|
||||||
SEQUENCE_VIEW_RANGE_MIN_M,
|
SEQUENCE_VIEW_RANGE_MIN_M,
|
||||||
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||||
),
|
),
|
||||||
});
|
};
|
||||||
|
const bbox = visibleBbox(viewer, ranged);
|
||||||
if (!bbox) return { hint: NO_GROUND_HINT };
|
if (!bbox) return { hint: NO_GROUND_HINT };
|
||||||
// Ranked from the ground under the camera: a tilted view's box centre
|
// Ranked from between the camera's ground point and the screen centre's:
|
||||||
// can sit kilometres ahead of anything near.
|
// a tilted view's box centre can sit kilometres past both.
|
||||||
const { tiles } = tilesForBbox(bbox, zoom, {
|
const { tiles } = tilesForBbox(bbox, zoom, {
|
||||||
limit: COVERAGE_MAX_TILES,
|
limit: COVERAGE_MAX_TILES,
|
||||||
from: cameraNadir(viewer),
|
from: viewFocus(viewer, ranged),
|
||||||
});
|
});
|
||||||
return { zoom, tiles };
|
return { zoom, tiles };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* Height (m) of the rendered surface, 3D tiles included, at `carto`; null
|
||||||
* the globe is hidden (Google 3D) or not yet loaded.
|
* 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) {
|
export function groundUnderCamera(viewer) {
|
||||||
const carto = viewer?.camera?.positionCartographic;
|
const carto = viewer?.camera?.positionCartographic;
|
||||||
const globe = viewer?.scene?.globe;
|
const scene = viewer?.scene;
|
||||||
if (!carto || !globe || globe.show === false) return null;
|
if (!carto || !scene) return null;
|
||||||
const ground = globe.getHeight?.(carto);
|
const globe = scene.globe;
|
||||||
return Number.isFinite(ground) ? ground : null;
|
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). */
|
/** 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
|
* 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.
|
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
groundUnderCamera,
|
groundUnderCamera,
|
||||||
metresBetween,
|
metresBetween,
|
||||||
viewCentre,
|
viewCentre,
|
||||||
|
viewFocus,
|
||||||
visibleBbox,
|
visibleBbox,
|
||||||
} from './view.js';
|
} from './view.js';
|
||||||
|
import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js';
|
||||||
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
const RAD = Math.PI / 180;
|
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);
|
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. */
|
/** A camera whose screen rays land on a grid of lon/lat points. */
|
||||||
function gridViewer(lons, lats) {
|
function gridViewer(lons, lats) {
|
||||||
const points = [];
|
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', () => {
|
test('looking at the horizon from eye height boxes the near ground, not the horizon', () => {
|
||||||
const view = pinhole({
|
const view = pinhole({
|
||||||
lon: -121.4944,
|
lon: -121.4944,
|
||||||
|
|||||||
Reference in new issue
Block a user