Merge branch 'main' into codex/shared-geodesy

This commit is contained in:
Sameh Khamis committed 2026-10-17 11:42:58 -07:00
commit 64d7ed3c61
1373 files changed
+245910 -10664

No files matched your search

+73 -540
View File
@@ -1,3 +1,5 @@
import { rangeForBoundingSphere } from './data/cameraFraming.js';
import { isUnavailableCapability } from './sources/capability.js';
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
@@ -7,515 +9,12 @@ import {
placesNearViewRecovery,
} from './annotations/annotationResolver.js';
import { unavailablePlaceSearch } from './search/placeSearch.js';
import { cancelCameraArrival } from './data/cameraArrival.js';
import { guardCameraAboveGround } from './cameraGroundGuard.js';
import { CITY_POIS } from './cityPois.js';
/**
* Points of Interest per city.
* Each city has 5 POIs; the first is the default fly-to landmark.
*
* Field reference:
* alt — RANGE (distance from target in meters), NOT absolute altitude
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
* pitch — camera tilt in degrees (negative = looking down)
* buildingHeight — estimated height of landmark center above ground (meters)
*/
export const CITY_POIS = {
austin: {
name: 'Austin',
groundElevation: 150, // meters above WGS84 ellipsoid
viewBounds: {
southwest: { lat: 30.1, lng: -97.95 },
northeast: { lat: 30.52, lng: -97.55 },
},
pois: [
{
name: 'Texas State Capitol',
lat: 30.2747,
lon: -97.7403,
alt: 550,
pitch: -28,
heading: 180,
buildingHeight: 35,
},
{
name: 'Frost Bank Tower',
lat: 30.2674,
lon: -97.7434,
alt: 550,
pitch: -22,
heading: 30,
buildingHeight: 80,
},
{
name: 'Pennybacker Bridge',
lat: 30.3451,
lon: -97.7951,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 40,
},
{
name: 'The Jenga Tower',
lat: 30.2642,
lon: -97.75,
alt: 500,
pitch: -18,
heading: 45,
buildingHeight: 60,
},
{
name: 'UT Tower',
lat: 30.2862,
lon: -97.7394,
alt: 500,
pitch: -22,
heading: 180,
buildingHeight: 50,
},
],
},
sf: {
name: 'San Francisco',
groundElevation: 15,
viewBounds: {
southwest: { lat: 37.7, lng: -122.53 },
northeast: { lat: 37.84, lng: -122.35 },
},
pois: [
{
name: 'Golden Gate Bridge',
lat: 37.8199,
lon: -122.4783,
alt: 1400,
pitch: -20,
heading: 45,
buildingHeight: 100,
},
{
name: 'Transamerica Pyramid',
lat: 37.7952,
lon: -122.4028,
alt: 500,
pitch: -25,
heading: 30,
buildingHeight: 85,
},
{
name: 'Salesforce Tower',
lat: 37.7897,
lon: -122.3972,
alt: 680,
pitch: -25,
heading: 330,
buildingHeight: 100,
},
{
name: 'Alcatraz Island',
lat: 37.8267,
lon: -122.423,
alt: 800,
pitch: -30,
heading: 0,
buildingHeight: 20,
},
{
name: 'Coit Tower',
lat: 37.8024,
lon: -122.4058,
alt: 420,
pitch: -30,
heading: 45,
buildingHeight: 30,
},
],
},
nyc: {
name: 'New York',
groundElevation: 10,
viewBounds: {
southwest: { lat: 40.477, lng: -74.259 },
northeast: { lat: 40.918, lng: -73.7 },
},
pois: [
{
name: 'Statue of Liberty',
lat: 40.6892,
lon: -74.0445,
alt: 450,
pitch: -25,
heading: 315,
buildingHeight: 45,
},
{
name: 'Empire State Building',
lat: 40.7484,
lon: -73.9857,
alt: 850,
pitch: -12,
heading: 30,
buildingHeight: 130,
buildingBounds: { height: 443, width: 130, depth: 75 },
},
{
name: 'One World Trade Center',
lat: 40.7127,
lon: -74.0134,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 170,
},
{
name: 'Brooklyn Bridge',
lat: 40.7061,
lon: -73.9969,
alt: 850,
pitch: -25,
heading: 45,
buildingHeight: 40,
},
{
name: 'Chrysler Building',
lat: 40.7516,
lon: -73.9755,
alt: 700,
pitch: -20,
heading: 225,
buildingHeight: 100,
},
],
},
tokyo: {
name: 'Tokyo',
groundElevation: 40,
viewBounds: {
southwest: { lat: 35.52, lng: 139.55 },
northeast: { lat: 35.9, lng: 139.92 },
},
pois: [
{
name: 'Tokyo Tower',
lat: 35.6586,
lon: 139.7454,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 110,
},
{
name: 'Tokyo Skytree',
lat: 35.7101,
lon: 139.8107,
alt: 900,
pitch: -25,
heading: 30,
buildingHeight: 200,
},
{
name: 'Imperial Palace',
lat: 35.6852,
lon: 139.7528,
alt: 900,
pitch: -35,
heading: 0,
buildingHeight: 20,
},
{
name: 'Senso-ji Temple',
lat: 35.7148,
lon: 139.7967,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 25,
},
{
name: 'Mode Gakuen Cocoon Tower',
lat: 35.6929,
lon: 139.6925,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 70,
},
],
},
london: {
name: 'London',
groundElevation: 15,
viewBounds: {
southwest: { lat: 51.28, lng: -0.51 },
northeast: { lat: 51.7, lng: 0.33 },
},
pois: [
{
name: 'Tower Bridge',
lat: 51.5055,
lon: -0.0754,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 65,
},
{
name: 'The Shard',
lat: 51.5045,
lon: -0.0865,
alt: 850,
pitch: -20,
heading: 0,
buildingHeight: 100,
},
{
name: 'Big Ben / Parliament',
lat: 51.5007,
lon: -0.1246,
alt: 600,
pitch: -25,
heading: 180,
buildingHeight: 50,
},
{
name: "St. Paul's Cathedral",
lat: 51.5138,
lon: -0.0984,
alt: 400,
pitch: -30,
heading: 270,
buildingHeight: 55,
},
{
name: 'The Gherkin',
lat: 51.5145,
lon: -0.0803,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 60,
},
],
},
paris: {
name: 'Paris',
groundElevation: 35,
viewBounds: {
southwest: { lat: 48.815, lng: 2.224 },
northeast: { lat: 48.902, lng: 2.47 },
},
pois: [
{
name: 'Eiffel Tower',
lat: 48.8584,
lon: 2.2945,
alt: 750,
pitch: -25,
heading: 315,
buildingHeight: 150,
},
{
name: 'Arc de Triomphe',
lat: 48.8738,
lon: 2.295,
alt: 400,
pitch: -28,
heading: 45,
buildingHeight: 25,
},
{
name: 'Notre-Dame',
lat: 48.853,
lon: 2.3499,
alt: 400,
pitch: -25,
heading: 225,
buildingHeight: 35,
},
{
name: 'Sacré-Cœur',
lat: 48.8867,
lon: 2.3431,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 40,
},
{
name: 'Louvre Pyramid',
lat: 48.8606,
lon: 2.3376,
alt: 500,
pitch: -35,
heading: 0,
buildingHeight: 10,
},
],
},
dubai: {
name: 'Dubai',
groundElevation: 5,
viewBounds: {
southwest: { lat: 24.95, lng: 54.9 },
northeast: { lat: 25.35, lng: 55.55 },
},
pois: [
{
name: 'Burj Khalifa',
lat: 25.1972,
lon: 55.2744,
alt: 600,
pitch: -20,
heading: 200,
buildingHeight: 270,
},
{
name: 'Burj Al Arab',
lat: 25.1412,
lon: 55.1853,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 100,
},
{
name: 'Palm Jumeirah',
lat: 25.1124,
lon: 55.139,
alt: 1200,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Dubai Frame',
lat: 25.235,
lon: 55.3003,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 75,
},
{
name: 'Museum of the Future',
lat: 25.2197,
lon: 55.2806,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 35,
},
],
},
dc: {
name: 'Washington DC',
groundElevation: 10,
viewBounds: {
southwest: { lat: 38.79, lng: -77.12 },
northeast: { lat: 38.995, lng: -76.91 },
},
pois: [
{
name: 'US Capitol',
lat: 38.8897,
lon: -77.0091,
alt: 550,
pitch: -25,
heading: 270,
buildingHeight: 45,
},
{
name: 'Washington Monument',
lat: 38.8895,
lon: -77.0353,
alt: 500,
pitch: -30,
heading: 0,
buildingHeight: 85,
},
{
name: 'Lincoln Memorial',
lat: 38.8893,
lon: -77.0502,
alt: 400,
pitch: -25,
heading: 90,
buildingHeight: 20,
},
{
name: 'Pentagon',
lat: 38.8711,
lon: -77.0559,
alt: 800,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Jefferson Memorial',
lat: 38.8814,
lon: -77.0365,
alt: 400,
pitch: -30,
heading: 0,
buildingHeight: 25,
},
],
},
tallinn: {
name: 'Tallinn',
groundElevation: 15,
viewBounds: {
southwest: { lat: 59.36, lng: 24.6 },
northeast: { lat: 59.52, lng: 24.92 },
},
pois: [
{
name: 'Viru Square',
lat: 59.4366,
lon: 24.7527,
alt: 450,
pitch: -28,
heading: 60,
buildingHeight: 25,
},
{
name: 'Old Town / Raekoja plats',
lat: 59.4372,
lon: 24.7452,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 20,
},
{
name: 'Teatri väljak',
lat: 59.4344,
lon: 24.7514,
alt: 400,
pitch: -25,
heading: 220,
buildingHeight: 25,
},
{
name: 'Port of Tallinn',
lat: 59.4445,
lon: 24.7675,
alt: 700,
pitch: -30,
heading: 90,
buildingHeight: 20,
},
{
name: 'Ülemiste',
lat: 59.421,
lon: 24.792,
alt: 600,
pitch: -28,
heading: 45,
buildingHeight: 30,
},
],
},
};
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
let arrivalGeneration = 0;
/**
* Absolute full-earth camera preset for the zoom_to_globe voice tool. The height
@@ -588,9 +87,12 @@ export const LOCATIONS = Object.entries(CITY_POIS).map(([id, city]) => ({
* @param {number} options.buildingHeight - Estimated landmark center height above ground (default 30)
* @param {number} options.groundElevation - Fallback ground elevation when terrain isn't loaded (default 0)
* @param {number} options.duration - Flight duration in seconds (default 3.0)
* @param {{cachedGroundFloor?: Function, warmGroundFloor?: Function}} [options.ground] -
* Optional ground-floor service; its cached elevation outranks `groundElevation`
* @returns {{ targetPosition: Cesium.Cartesian3 }} The computed target for orbit use
*/
export function flyToLandmark(viewer, lat, lon, options = {}) {
cancelCameraArrival(viewer);
const {
range = 500,
pitch = -30,
@@ -602,18 +104,32 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
onComplete = null,
onCancel = null,
buildingBounds = null,
ground = null,
} = options;
// Sample terrain height (sync — uses loaded tiles; 0 if globe/terrain not ready)
const targetCartographic = Cesium.Cartographic.fromDegrees(lon, lat);
const sampledHeight = viewer.scene.globe?.getHeight(targetCartographic);
// Use sampled height if available, otherwise fall back to pre-baked city ground elevation.
// Google 3D Tiles don't populate globe terrain, so first fly-to always gets the fallback.
// Use sampled height if available, otherwise the cached ground-floor
// elevation (same ellipsoidal datum), then the pre-baked city elevation.
// Google 3D Tiles don't populate globe terrain, and the preset defaults to sea
// level, so without the ground-floor read an uncurated destination was framed
// as if it stood on the ocean and the eye landed underground.
const realGround = ground?.cachedGroundFloor?.(lat, lon);
const terrainHeight =
sampledHeight != null && sampledHeight > 0
? sampledHeight
: groundElevation;
: Number.isFinite(realGround)
? realGround
: groundElevation;
// Warm the cell so the next nearby arrival is already correct.
try {
ground?.warmGroundFloor?.([{ lat, lon }]);
} catch {
/* best-effort prefetch */
}
const generation = ++arrivalGeneration;
const bounds = normalizeBuildingBounds(buildingBounds);
const targetHeight = bounds
@@ -651,6 +167,18 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
complete: () => {
viewer.camera.lookAt(targetPosition, hpr);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Framing used a predicted ground height. Once tiles stream in, measure
// the rendered surface and lift the eye if it is buried or pressed
// against it; a newer arrival or a manual gesture ends the check.
try {
guardCameraAboveGround(
viewer,
{ lat, lon },
{ isStale: () => generation !== arrivalGeneration },
);
} catch {
/* best-effort */
}
if (typeof onComplete === 'function') {
try {
onComplete();
@@ -807,6 +335,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
query,
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
signal,
placeSearch,
);
signal?.throwIfAborted();
if (recovered) {
@@ -843,6 +372,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
pitch: swath.pitchDeg,
heading: swath.headingDeg,
buildingHeight: 0,
ground: options.ground || null,
duration,
onStart: options.onStart,
onComplete: options.onComplete,
@@ -907,23 +437,35 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
)
: null;
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
// Resolve the real ground height before framing, for the same reason as the
// coordinate branch: with no detailed outline, a searched place on elevated
// ground would otherwise be framed against sea level. Resolved before the
// mayFly() gate so a cancellation still wins the race.
const flyLat = buildingBounds?.lat ?? lat;
const flyLon = buildingBounds?.lon ?? lng;
const ground = options.ground || null;
await ground?.resolveGroundFloorCellsBounded?.([
{ lat: flyLat, lon: flyLon },
]);
const searchGroundElevation = ground?.cachedGroundFloor?.(flyLat, flyLon);
if (!mayFly()) return CANCELLED_SEARCH;
const flight = flyToLandmark(
viewer,
buildingBounds?.lat ?? lat,
buildingBounds?.lon ?? lng,
{
range,
pitch: buildingPitch(buildingBounds),
heading: 30,
buildingHeight: 30,
buildingBounds,
duration,
onStart: options.onStart,
onComplete: options.onComplete,
onCancel: options.onCancel,
},
);
const flight = flyToLandmark(viewer, flyLat, flyLon, {
range,
pitch: buildingPitch(buildingBounds),
heading: 30,
buildingHeight: 30,
buildingBounds: isUnavailableCapability(buildingBounds)
? null
: buildingBounds,
...(Number.isFinite(searchGroundElevation)
? { groundElevation: searchGroundElevation }
: {}),
ground,
duration,
onStart: options.onStart,
onComplete: options.onComplete,
onCancel: options.onCancel,
});
return {
label,
navigationMode: requestedRange
@@ -932,6 +474,9 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
? navigationMode.replace('-overview', '-close')
: navigationMode,
rangeM: Math.round(flight.range),
...(isUnavailableCapability(buildingBounds)
? { outlineUnavailable: true, message: 'Detailed outline unavailable' }
: {}),
};
}
@@ -1312,19 +857,6 @@ function buildingBoundingRadius(bounds) {
return Math.hypot(halfHeight, halfWidth, halfDepth) * 1.18;
}
function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40%
// measured roof-to-base breathing room in ordinary oblique building views.
const occupiedViewportFraction = 0.57;
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
}
function buildingPitch(bounds) {
if (!bounds) return -25;
const footprint = Math.max(bounds.width, bounds.depth);
@@ -1347,6 +879,7 @@ async function resolveBuildingBounds(
{ lat, lon },
{ signal },
);
if (isUnavailableCapability(candidates)) return candidates;
return selectBuildingBounds(
Array.isArray(candidates) ? candidates : [],
lat,