From d51ea4007bf89232f23f3956d2b1f277b6333670 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 4 Nov 2026 09:24:30 -0800 Subject: [PATCH] Ask for heights through one service per scene About 20 sites called scene.sampleHeight, globe.getHeight, scene.clampToHeight or the MostDetailed variants directly, each with its own idea of a miss (undefined, NaN, a throw). They now ask heightsFor(scene) in src/queries/heights.js: - now(point, { against, exclude }) answers from what is loaded, or null; against is 'surface' (what is drawn) or 'terrain' (loaded terrain only). - clampNow(position) moves a position onto the drawn surface, or null. - batch() and clampBatch() wait for the most detailed tiles: null per missed point, a rejection when the whole query fails. - supported() reports { surface, clamp } without allocating. Cesium still answers now() synchronously, so nothing changes on screen and nothing is slower: in the live app 1,000 calls cost the same through the service as direct (terrain 0.5-0.7 ms, surface 2-6 ms a call, run-to-run noise larger than the difference). Every site already treated a miss as "hold the last value or fall back"; a renderer that can only answer from a cache or asynchronously can now plug in at one place. - A Bhote Koshi evidence point whose terrain sample misses stays at its fallback elevation instead of reading the miss as sea level. - The mesh floor sampler skips a scene without depth-texture support instead of spending its budget on probes that throw. - The world annotation probe's trace reads hit/missed. - A source scan forbids direct height calls outside the service. The service is exported as ./queries/heights and documented in docs/APPLICATION.md. --- docs/APPLICATION.md | 32 +++ package.json | 1 + scripts/format-scope.json | 2 + scripts/package-boundaries.json | 38 ++- .../hybridAnnotationRenderer.test.mjs | 3 +- src/annotations/resolver.js | 29 +-- src/annotations/screenAnnotationRenderer.js | 12 +- src/annotations/worldAnnotationRenderer.js | 11 +- src/cameraGroundGuard.js | 21 +- src/cameraVerbs.js | 26 +-- src/data/bhoteKoshiEvent.js | 35 ++- src/data/bhoteKoshiEvent.test.mjs | 40 ++++ src/data/geoFileImport.js | 6 +- src/data/geoFileImport.test.mjs | 37 +++ src/data/infrastructure.test.mjs | 1 + src/data/localGeojsonCore.js | 27 +-- src/data/meshFloorSampler.test.mjs | 16 ++ src/layers/alpr/overlay.js | 24 +- src/layers/alpr/presentation.js | 31 ++- src/layers/bikeshare/rendering.js | 11 +- src/layers/streetLevel/cameraFraming.js | 4 +- src/layers/streetLevel/index.test.mjs | 12 +- src/layers/streetLevel/view.js | 14 +- src/layers/traffic/ingestion.js | 20 +- src/layers/traffic/surface.js | 9 +- src/layers/transit/height.js | 11 +- src/locations.js | 5 +- src/queries/heights.js | 161 +++++++++++++ src/queries/heights.test.mjs | 218 ++++++++++++++++++ src/services/groundSnap.js | 26 +-- src/services/meshFloorSampler.js | 26 +-- src/ui/flyToDataSource.js | 5 +- 32 files changed, 704 insertions(+), 210 deletions(-) create mode 100644 src/queries/heights.js create mode 100644 src/queries/heights.test.mjs diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 6a7549912..d7bf0d881 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -131,6 +131,38 @@ reads true only while no hold is out. A caller-supplied `createScene` that creat `defer(() => inputFor(viewer).destroy())` right after it; the standalone scene does this and returns the dispatcher as `scene.input`. +## Height queries + +`gods-eye-view/queries/heights` exports `heightsFor(scene)`, the one place +height queries go. Layers, tools and the camera ask it instead of calling +`scene.sampleHeight`, `globe.getHeight`, `scene.clampToHeight` or the +`*MostDetailed` variants themselves, so a renderer that answers heights another +way plugs in without touching them. + +```js +import { heightsFor } from 'gods-eye-view/queries/heights'; + +const heights = heightsFor(viewer.scene); +const ground = heights.now({ lon, lat }, { exclude: [ownMarkers] }); +if (ground !== null) place(ground); +``` + +| Member | What it answers | +| ----------------------------- | ------------------------------------------------------------------------- | +| `now(point, opts)` | Height in metres from what is loaded, or `null` | +| `clampNow(position, opts)` | The `Cartesian3` moved onto the drawn surface, or `null` | +| `batch(points, opts)` | A promise of heights once the most detailed tiles stream; `null` per miss | +| `clampBatch(positions, opts)` | A promise of clamped copies of the positions; `null` per miss | +| `supported()` | `{ surface, clamp }`: whether the drawn surface can be queried at all | + +A point is `{ lon, lat }` in degrees or a `Cesium.Cartographic` in radians. +`against: 'surface'` (the default) reads what is drawn, 3D tiles and globe; +`against: 'terrain'` reads the loaded terrain tiles alone. `exclude` lists the +primitives and entities a query sees through. Every miss is `null`, including +a Cesium throw for missing depth-texture support, and every caller treats it +as one. Cesium answers `now()` synchronously from the scene, so it costs what +the direct call did. + ## Standalone wiring `src/main.js` reads the existing browser configuration and starts diff --git a/package.json b/package.json index cc2eaf44b..9e2e96cfa 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,7 @@ "./application/operations": "./src/app/operations.js", "./data/entity-records": "./src/data/entityRecords.js", "./input": "./src/input/dispatcher.js", + "./queries/heights": "./src/queries/heights.js", "./data/lifecycle": "./src/data/lifecycle.js", "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b9eba226..2bff60a1c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -47,6 +47,8 @@ "src/input/testDoubles.mjs", "src/input/sensorPick.js", "src/input/dispatcher.js", + "src/queries/heights.js", + "src/queries/heights.test.mjs", "docs/CODE-BOUNDARIES.md", "src/main.js", "docs/APPLICATION.md", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5c3e0490f..481cba9c5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -15,7 +15,8 @@ "src/input/dispatcher.js", "src/input/sensorPick.js", "src/data/infrastructureOverlayEntry.js", - "src/sources/infrastructureData.js" + "src/sources/infrastructureData.js", + "src/queries/heights.js" ], "external": ["cesium"] }, @@ -1859,7 +1860,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/queries/heights.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1900,7 +1902,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": ["cesium"] }, @@ -2046,7 +2049,8 @@ "src/data/scenePick.js", "src/input/sensorPick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -2089,7 +2093,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -3327,7 +3332,8 @@ "src/data/local_data/wof_neighborhoods/files.js", "src/data/entityRecords.js", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": [ "hls.js", @@ -3952,7 +3958,8 @@ "src/data/local_data/wof_neighborhoods/files.js", "src/data/entityRecords.js", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": [ "hls.js", @@ -4039,7 +4046,8 @@ "src/data/placeBoundaries.js", "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", - "src/data/local_data/wof_neighborhoods/files.js" + "src/data/local_data/wof_neighborhoods/files.js", + "src/queries/heights.js" ], "external": [ "cesium", @@ -4647,7 +4655,8 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -4703,7 +4712,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": ["cesium", "pbf"] }, @@ -4869,7 +4879,8 @@ "src/data/scenePick.js", "src/input/sensorPick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/queries/heights.js" ], "external": ["cesium"] }, @@ -5101,5 +5112,10 @@ "src/i18n/marker.js" ], "external": ["cesium"] + }, + "heights": { + "exports": ["./queries/heights"], + "modules": ["src/queries/heights.js"], + "external": ["cesium"] } } diff --git a/src/annotations/hybridAnnotationRenderer.test.mjs b/src/annotations/hybridAnnotationRenderer.test.mjs index cf61502c4..01d3ec274 100644 --- a/src/annotations/hybridAnnotationRenderer.test.mjs +++ b/src/annotations/hybridAnnotationRenderer.test.mjs @@ -540,8 +540,9 @@ test('buildings share one probe per frame and preserve probe geometry, filtering const detail = parent.detail; assert.equal(detail.index, index); assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing'); + // The fourth probe finds nothing and the fifth throws: both are misses. assert.equal(detail.clampResult, - trace === firstTrace && index === 4 ? 'threw' : 'returned'); + trace === firstTrace && index !== 3 && index !== 4 ? 'hit' : 'missed'); assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd)); assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end); if (index === 7) { diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index fad27e55d..ac9401720 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -45,6 +45,7 @@ import { import { createOutlineRungs } from './outlineRungs.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; +import { heightsFor } from '../queries/heights.js'; /** Prepare only local dateline outlines for planar admission and centering. */ function localOutlineFrame(fp) { @@ -2351,26 +2352,18 @@ export function createAnnotationResolver({ function sampleGroundHeight(viewer, lon, lat) { const scene = viewer?.scene; if (!scene) return 0; - try { - if ( - scene.clampToHeightSupported && - typeof scene.clampToHeight === 'function' - ) { - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const surface = Cesium.Cartographic.toCartesian(carto); - const clamped = scene.clampToHeight(surface); - if (clamped) { - const h = Cesium.Cartographic.fromCartesian(clamped).height; - if (Number.isFinite(h)) return h; - } + const heights = heightsFor(scene); + // A miss is tiles not ready: fall through to the terrain, then 0. + if (heights.supported().clamp) { + const surface = Cesium.Cartesian3.fromDegrees(lon, lat, 0); + const clamped = heights.clampNow(surface); + if (clamped) { + const h = Cesium.Cartographic.fromCartesian(clamped)?.height; + if (Number.isFinite(h)) return h; } - } catch { - /* tiles not ready — fall through */ } - const globeHeight = scene.globe?.getHeight?.( - Cesium.Cartographic.fromDegrees(lon, lat), - ); - return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0; + const globeHeight = heights.now({ lon, lat }, { against: 'terrain' }); + return globeHeight !== null && globeHeight > 0 ? globeHeight : 0; } function shortLabel(formattedAddress) { diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index 27ae2fcb4..fa43e21b7 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -9,6 +9,7 @@ import { nextTick } from 'vue'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { applicationPinia } from '../ui/vueRuntime.js'; import { readEntityRecord } from '../data/entityRecords.js'; +import { heightsFor } from '../queries/heights.js'; /** A label whose detailed outline could not be resolved. */ function outlineUnavailableLabel(label) { @@ -142,13 +143,10 @@ export function createScreenAnnotationRenderer( return cached.h; } try { - if ( - scene.clampToHeightSupported && - typeof scene.clampToHeight === 'function' - ) { - const c = scene.clampToHeight( - Cesium.Cartesian3.fromDegrees(lon, lat, 0), - ); + const heights = heightsFor(scene); + if (heights.supported().clamp) { + // A miss is tiles not ready. + const c = heights.clampNow(Cesium.Cartesian3.fromDegrees(lon, lat, 0)); if (c) { const h = Cesium.Cartographic.fromCartesian(c).height; if (Number.isFinite(h) && h > -430 && h < 9000) { diff --git a/src/annotations/worldAnnotationRenderer.js b/src/annotations/worldAnnotationRenderer.js index 189d46830..11b96d7ca 100644 --- a/src/annotations/worldAnnotationRenderer.js +++ b/src/annotations/worldAnnotationRenderer.js @@ -2,6 +2,7 @@ import { recordWorkSlice } from '../sources/phaseTiming.js'; import { ringCentroid } from '../sources/featureGeometry.js'; import * as Cesium from 'cesium'; import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; +import { heightsFor } from '../queries/heights.js'; /** * World-space annotation renderer (Direction A). @@ -174,20 +175,16 @@ export function createWorldAnnotationRenderer(viewer) { const sliceStart = performance.now(); let error; try { - if ( - viewer.scene.clampToHeightSupported && - typeof viewer.scene.clampToHeight === 'function' - ) { + if (heightsFor(viewer.scene).supported().clamp) { const [lon, lat] = task.points[task.index++]; timing.probes += 1; try { const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0); let c; note?.('clampStart'); - if (detail) detail.clampResult = 'threw'; try { - c = viewer.scene.clampToHeight(position); - if (detail) detail.clampResult = 'returned'; + c = heightsFor(viewer.scene).clampNow(position); + if (detail) detail.clampResult = c ? 'hit' : 'missed'; } finally { note?.('clampEnd'); } diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 3f9de0cab..65dd607c2 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -16,6 +16,7 @@ */ import * as Cesium from 'cesium'; import { ownCameraArrival } from './data/cameraArrival.js'; +import { heightsFor } from './queries/heights.js'; /** Minimum eye height above the rendered surface for a usable view. */ export const MIN_EYE_CLEARANCE_M = 120; @@ -37,18 +38,16 @@ const probeScratch = new Cesium.Cartographic(); function sampleSurfaceM(scene, cells) { let heightM = Number.NaN; let sampled = 0; - if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled }; + if (!scene) return { heightM, sampled }; + const heights = heightsFor(scene); for (const cell of cells) { - try { - const height = scene.sampleHeight( - Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch), - ); - if (!Number.isFinite(height)) continue; - sampled += 1; - heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height; - } catch { - /* tiles not ready for this cell */ - } + // A miss is a cell whose tiles are not ready. + const height = heights.now( + Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch), + ); + if (height === null) continue; + sampled += 1; + heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height; } return { heightM, sampled }; } diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..c6453c4c7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -29,6 +29,7 @@ import { holdContinuousRender, releaseContinuousRender, } from './renderGovernor.js'; +import { heightsFor } from './queries/heights.js'; let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ @@ -559,25 +560,18 @@ function acquireCorridorFloor(state, takeWhole) { export function probeMeshFloorM(scene, cells) { const requested = Array.isArray(cells) ? cells.length : 0; const empty = { heightM: Number.NaN, sampled: 0, requested }; - if (!scene || typeof scene.sampleHeight !== 'function' || !requested) - return empty; + if (!scene || !requested) return empty; + const heights = heightsFor(scene); let heightM = Number.NaN; let sampled = 0; for (const cell of cells) { - try { - const carto = Cesium.Cartographic.fromDegrees( - cell.lon, - cell.lat, - 0, - _probeCarto, - ); - const height = scene.sampleHeight(carto); - if (!Number.isFinite(height)) continue; - sampled += 1; - heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height; - } catch { - /* tiles not ready for this cell */ - } + // A miss is a cell whose tiles are not ready. + const height = heights.now( + Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _probeCarto), + ); + if (height === null) continue; + sampled += 1; + heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height; } return { heightM, sampled, requested }; } diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 8211cebb5..20315ba8e 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -28,6 +28,7 @@ import { createImagerySplit } from '../ui/imagerySplit.js'; import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js'; import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js'; import { applicationPinia } from '../ui/vueRuntime.js'; +import { heightsFor } from '../queries/heights.js'; export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026'; export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses'; @@ -122,20 +123,11 @@ const DEFAULT_EVENT_LOADER = async () => { const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) => Cesium.SingleTileImageryProvider.fromUrl(url, options); const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => { - if ( - !viewer?.terrainProvider || - !viewer.terrainProvider.availability || - typeof Cesium.sampleTerrainMostDetailed !== 'function' - ) { - return null; - } - const cartographics = observations.map((observation) => - Cesium.Cartographic.fromDegrees(observation.lon, observation.lat), - ); - return Cesium.sampleTerrainMostDetailed( - viewer.terrainProvider, - cartographics, - ); + if (!viewer?.scene || !viewer.terrainProvider?.availability) return null; + const heights = await heightsFor(viewer.scene).batch(observations, { + against: 'terrain', + }); + return heights.map((height) => ({ height })); }; const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => { if (typeof createImageBitmap !== 'function') { @@ -1197,7 +1189,8 @@ export function createBhoteKoshiEventLayer({ } throwIfEnableCancelled(signal, _enabled); return observations.map((observation, index) => { - const sampledHeight = Number(sampled?.[index]?.height); + // A miss is null or undefined, never sea level. + const sampledHeight = Number(sampled?.[index]?.height ?? Number.NaN); const fallbackHeight = Number(observation.fallbackElevationM) || 0; return Cesium.Cartesian3.fromDegrees( observation.lon, @@ -1573,14 +1566,12 @@ export function createBhoteKoshiEventLayer({ // a single high sample can make the front dive backward in screen space. if (_sceneControls.evidencePathElevation !== 'source') { // Sample once while preparing the shot, never from an animation frame. - try { - height = _viewer?.scene?.sampleHeight?.( - position, - [_floodCore, _floodHalo, _surgePoint].filter(Boolean), - ); - } catch {} + const heights = _viewer?.scene ? heightsFor(_viewer.scene) : null; + height = heights?.now(position, { + exclude: [_floodCore, _floodHalo, _surgePoint].filter(Boolean), + }); if (!Number.isFinite(height)) - height = _viewer?.scene?.globe?.getHeight?.(position); + height = heights?.now(position, { against: 'terrain' }); } if (!Number.isFinite(height)) height = point.fallbackElevationM; return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8); diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 51e5f11f5..cf3630ae1 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -2992,3 +2992,43 @@ test('Bhote Koshi corridor remains ordered downstream and inside the imagery rec priorChainage = point.chainageM; } }); + +test('an evidence point whose terrain sample misses sits at its fallback elevation, not sea level', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + installEventDom(); + globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; + const viewer = eventViewer(); + const publications = []; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: null })), + mediaLoader: async () => { + const error = new Error('fixture media intentionally unavailable'); + error.name = 'AbortError'; + throw error; + }, + overlayHost: { setEntries(_source, entries) { publications.push(entries); }, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + try { + layer.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + beatId: 'gyirong-border-gate', + }, { origin: 'scene' }); + await layer.enable(viewer, { origin: 'scene' }); + const card = publications.at(-1).find((entry) => entry.id === 'evidence-card-gyirong-border-gate'); + const fallback = event.evidenceSpine.find(({ id }) => id === 'gyirong-border-gate').fallbackElevationM; + assert.ok(fallback > 100, 'the fixture has a real fallback elevation'); + const height = Cesium.Cartographic.fromCartesian(card.position()).height; + assert.ok(Math.abs(height - (fallback + 18)) < 1, `card at ${height} m`); + await layer.disable(); + } finally { + await layer.destroy(); + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index 2d444092e..a80909b91 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -3,6 +3,7 @@ import * as Cesium from 'cesium'; import { createImportImageBudget } from './geoImportImages.js'; import { createImportedPins } from './geoImportPins.js'; import { msg } from '../i18n/marker.js'; +import { heightsFor } from '../queries/heights.js'; /** Largest file accepted, in bytes. */ const MAX_IMPORT_BYTES = 50 * 1024 * 1024; @@ -1272,10 +1273,11 @@ async function settleGroundPins(dataSource, viewer, signal) { // a buried starting point can make the downward mesh ray miss the surface. const ellipsoid = viewer.terrainProvider instanceof Cesium.EllipsoidTerrainProvider; + const heights = viewer.scene ? heightsFor(viewer.scene) : null; for (let i = 0; i < positions.length; i++) { const sampled = ellipsoid ? 0 - : viewer.scene?.globe?.getHeight?.(positions[i]); + : heights?.now(positions[i], { against: 'terrain' }); positions[i].height = mesh ? (batch[i].groundHeight ?? Math.max(0, Number.isFinite(sampled) ? sampled : 0)) @@ -1295,7 +1297,7 @@ async function settleGroundPins(dataSource, viewer, signal) { indices.push(i); if (indices.length) { const resolved = await boundedImport( - viewer.scene.clampToHeightMostDetailed( + heights.clampBatch( indices.map((i) => { const c = positions[i]; return Cesium.Cartesian3.fromRadians( diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index 9b147e1d2..aac478a07 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1262,3 +1262,40 @@ test('KML namespace repair ignores declaration text inside quoted attribute valu /note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/, ); }); + +test('a failed mesh clamp fails the import instead of leaving pins at sea level', async () => { + const previous = Cesium.PinBuilder.prototype.fromColor; + Cesium.PinBuilder.prototype.fromColor = () => ({ + tagName: 'CANVAS', + toDataURL: () => 'data:image/png;base64,local', + }); + try { + const viewer = { + terrainProvider: new Cesium.EllipsoidTerrainProvider(), + camera: { + positionCartographic: { height: 100000 }, + computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE, + }, + scene: { + frameState: { mode: Cesium.SceneMode.SCENE3D }, + globe: { show: false, getHeight: () => 0 }, + clampToHeightSupported: true, + clampToHeightMostDetailed: async () => { + throw new Error('tile request failed'); + }, + }, + }; + await assert.rejects( + loadGeoFile( + new File( + [JSON.stringify({ type: 'Point', coordinates: [1, 2] })], + 'surface.geojson', + ), + { viewer }, + ), + /Ground heights could not be read/, + ); + } finally { + Cesium.PinBuilder.prototype.fromColor = previous; + } +}); diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 16aef75d1..fa5e9c283 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -187,6 +187,7 @@ test('consumer build includes only infrastructure code and resolves assets under const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ 'dispatcher.js', + 'heights.js', 'infrastructure.js', 'infrastructureData.js', 'infrastructureOverlayEntry.js', diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 6448911d0..683f7e026 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -11,6 +11,7 @@ import { applyInfraEvictionGrace, shouldRecomputeInfraLod, } from './localGeojsonLod.js'; +import { heightsFor } from '../queries/heights.js'; const DEFAULT_LABEL_MAX = 900; const DEFAULT_LABEL_GRID_PX = 132; @@ -1114,9 +1115,11 @@ function refreshLocalTerrainFloor(viewer, record) { // Camera motion can refine terrain after the first successful depth sample. // Reuse the loaded mesh on existing bounded walks; never arm a timer or do // another GPU readback just to maintain this floor. Keep roof elevations. - const height = globe.getHeight?.(record.carto); + const height = heightsFor(viewer.scene).now(record.carto, { + against: 'terrain', + }); if ( - !Number.isFinite(height) || + height === null || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || height <= record.groundHeight ) @@ -1131,26 +1134,20 @@ function sampleLocalGroundHeight(viewer, record, now) { record.lastGroundSampleMs = now; const globe = viewer.scene.globe; if (globe?.show && globe.tilesLoaded === false) return false; - let sampled; - try { - sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); - } catch { - return false; // tiles not ready; retry on a later bounded update - } - if ( - !Number.isFinite(sampled) || - Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M - ) + const heights = heightsFor(viewer.scene); + // A miss is tiles not ready: retry on a later bounded update. + let sampled = heights.now(record.carto, { exclude: [record.entity] }); + if (sampled === null || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) return false; // A coarse scene-depth sample can lie below the terrain already loaded by // the visible globe. Keep that terrain as a floor while allowing roofs and // valid below-sea-level elevations. Photoreal scenes hide the globe, so its // inactive terrain must not constrain their geometry. const terrainHeight = globe?.show - ? globe.getHeight?.(record.carto) - : undefined; + ? heights.now(record.carto, { against: 'terrain' }) + : null; if ( - Number.isFinite(terrainHeight) && + terrainHeight !== null && Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M ) { sampled = Math.max(sampled, terrainHeight); diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs index f651d72f8..019000314 100644 --- a/src/data/meshFloorSampler.test.mjs +++ b/src/data/meshFloorSampler.test.mjs @@ -55,6 +55,7 @@ function fakeScene(sampleHeight, { cameraHeightM = 900, tilesLoaded = true, show Object.defineProperty(tileset, 'tilesLoaded', { value: tilesLoaded, configurable: true }); Object.defineProperty(tileset, 'show', { value: show, configurable: true }); return { + sampleHeightSupported: true, sampleHeight, camera: { positionCartographic: { height: cameraHeightM } }, primitives: { length: 1, get: () => tileset }, @@ -137,3 +138,18 @@ test('sampler: a cell just across the antimeridian is still near the viewer', () sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 }); assert.equal(calls, 2, 'about 200 m apart, so both cells are probed'); }); + +test('sampler: a scene without depth-texture support is never probed', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + let calls = 0; + const scene = fakeScene(() => { + calls++; + throw new Error('depth textures unsupported'); + }); + scene.sampleHeightSupported = false; + sampleMeshFloorCells(scene, [cell], { viewerLat: cell.lat, viewerLon: cell.lon }); + assert.equal(calls, 0); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), null); +}); diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js index ea62468c9..470a542c5 100644 --- a/src/layers/alpr/overlay.js +++ b/src/layers/alpr/overlay.js @@ -19,6 +19,7 @@ import { validAlprGroundHeight, } from './visuals.js'; import { alprMarkers } from './markerState.js'; +import { heightsFor } from '../../queries/heights.js'; /** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */ export function createAlprOverlay({ @@ -121,14 +122,11 @@ export function createAlprOverlay({ else if (scene.sampleHeightSupported) { if (budget.samples <= 0) return null; budget.samples -= 1; - try { - height = scene.sampleHeight(location, [entity]); - } catch { - /* streaming tiles */ - } + // A miss is tiles still streaming. + height = heightsFor(scene).now(location, { exclude: [entity] }); } if (!validAlprGroundHeight(height) && scene.globe.show) - height = scene.globe.getHeight?.(location); + height = heightsFor(scene).now(location, { against: 'terrain' }); if (!validAlprGroundHeight(height)) return null; setAnchor(record, entity, height); return alprMarkers.read(entity)?.canvasPosition; @@ -202,15 +200,11 @@ export function createAlprOverlay({ continue; } budget.samples -= 1; - let sample; - try { - sample = scene.sampleHeight( - Cesium.Cartographic.fromDegrees(record.longitude, record.latitude), - [entity], - ); - } catch { - /* streaming tiles */ - } + // A miss is tiles still streaming. + const sample = heightsFor(scene).now( + { lon: record.longitude, lat: record.latitude }, + { exclude: [entity] }, + ); const current = Cesium.Cartographic.fromCartesian(anchor).height; const floor = services.groundFloor.cachedGroundFloor( record.latitude, diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index c1ea8a4eb..2f4f7ea51 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -25,6 +25,7 @@ import { } from './visuals.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; import { alprMarkers } from './markerState.js'; +import { heightsFor } from '../../queries/heights.js'; export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; @@ -518,16 +519,13 @@ export function createAlprPresentation({ state, services, source }) { record.longitude, record.latitude, ); - let height; - if (state.viewer.scene.sampleHeightSupported) { - try { - height = state.viewer.scene.sampleHeight(location, [nearest]); - } catch { - /* tiles may still be streaming */ - } - } + const heights = heightsFor(state.viewer.scene); + // A miss is tiles still streaming. + let height = state.viewer.scene.sampleHeightSupported + ? heights.now(location, { exclude: [nearest] }) + : null; if (!validAlprGroundHeight(height) && state.viewer.scene.globe.show) - height = state.viewer.scene.globe.getHeight?.(location); + height = heights.now(location, { against: 'terrain' }); if (!validAlprGroundHeight(height)) height = cachedGroundFloor(record.latitude, record.longitude); const center = Cesium.Cartesian3.fromDegrees( @@ -598,16 +596,13 @@ export function createAlprPresentation({ state, services, source }) { record.longitude, record.latitude, ); - let height; - if (scene.sampleHeightSupported) { - try { - height = scene.sampleHeight(location, [entity]); - } catch { - /* tiles may still be streaming */ - } - } + const heights = heightsFor(scene); + // A miss is tiles still streaming. + let height = scene.sampleHeightSupported + ? heights.now(location, { exclude: [entity] }) + : null; if (!validAlprGroundHeight(height) && scene.globe.show) - height = scene.globe.getHeight?.(location); + height = heights.now(location, { against: 'terrain' }); if (!validAlprGroundHeight(height)) height = cachedGroundFloor(record.latitude, record.longitude); if (!validAlprGroundHeight(height)) return; diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js index c75189d4a..70f319967 100644 --- a/src/layers/bikeshare/rendering.js +++ b/src/layers/bikeshare/rendering.js @@ -12,6 +12,7 @@ import { MAX_TOTAL_POINTS, COLOR_OUTLINE, } from './policy.js'; +import { heightsFor } from '../../queries/heights.js'; export function createRendering({ state: layerState, @@ -100,12 +101,10 @@ export function createRendering({ let height = POINT_HEIGHT_OFFSET_M; // Sample terrain height so points sit on ground rather than at ellipsoid level - if (layerState._viewer?.scene?.sampleHeightSupported) { - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const sampled = layerState._viewer.scene.sampleHeight(carto); - if (Number.isFinite(sampled)) { - height = sampled + POINT_HEIGHT_OFFSET_M; - } + const scene = layerState._viewer?.scene; + if (scene?.sampleHeightSupported) { + const sampled = heightsFor(scene).now({ lon, lat }); + if (sampled !== null) height = sampled + POINT_HEIGHT_OFFSET_M; } return Cesium.Cartesian3.fromDegrees(lon, lat, height); diff --git a/src/layers/streetLevel/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js index c6bc9327a..251bf909d 100644 --- a/src/layers/streetLevel/cameraFraming.js +++ b/src/layers/streetLevel/cameraFraming.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; +import { heightsFor } from '../../queries/heights.js'; /** Fly the globe camera once to frame a photo after it opens. */ export function createCameraFraming({ state }) { @@ -30,7 +31,8 @@ export function createCameraFraming({ state }) { const scene = state.viewer?.scene; const carto = Cesium.Cartographic.fromDegrees(lon, lat); let height = sampledSurfaceHeight(scene, carto); - if (height === null) height = scene?.globe?.getHeight?.(carto); + if (height === null && scene) + height = heightsFor(scene).now(carto, { against: 'terrain' }); if (!plausibleSurfaceHeight(height)) height = fallback; return plausibleSurfaceHeight(height) ? height : 0; } diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 6c79a52ca..d563769ba 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -799,7 +799,11 @@ test('under Google 3D the ground is measured again once its tiles load, without const viewer = fakeViewer(); // 4 km up the ellipsoid, 2 km over a city whose tiles are still loading. let ground; - viewer.camera.positionCartographic = { height: 4000 }; + viewer.camera.positionCartographic = { + longitude: 0, + latitude: 0, + height: 4000, + }; viewer.scene.globe = { show: false }; viewer.scene.sampleHeightSupported = true; viewer.scene.sampleHeight = () => ground; @@ -823,7 +827,11 @@ test('an unknown ground is measured only a few times, and not after the layer is t.mock.timers.enable({ apis: ['setTimeout'] }); const viewer = fakeViewer(); let samples = 0; - viewer.camera.positionCartographic = { height: 4000 }; + viewer.camera.positionCartographic = { + longitude: 0, + latitude: 0, + height: 4000, + }; viewer.scene.globe = { show: false }; viewer.scene.sampleHeightSupported = true; viewer.scene.sampleHeight = () => { diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index e14cb6493..6c687393a 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { wrapLon } from './tileMath.js'; +import { heightsFor } from '../../queries/heights.js'; /** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */ export function metresBetween(a, b) { @@ -21,12 +22,9 @@ export function plausibleSurfaceHeight(height) { * underground before tiles load). */ export function sampledSurfaceHeight(scene, carto) { - let height = null; - try { - if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); - } catch { - /* not sampleable yet */ - } + const height = scene?.sampleHeightSupported + ? heightsFor(scene).now(carto) + : null; return plausibleSurfaceHeight(height) ? height : null; } @@ -41,8 +39,8 @@ export function groundUnderCamera(viewer) { 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 ground = heightsFor(scene).now(carto, { against: 'terrain' }); + if (ground !== null) return ground; } const sampled = sampledSurfaceHeight(scene, carto); return sampled !== null && sampled < carto.height ? sampled : null; diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index 8bb1da5f4..afb198a27 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -12,6 +12,7 @@ import { TILE_CACHE_MAX_ENTRIES, FAST_FETCH_ALTITUDE, } from './policy.js'; +import { heightsFor } from '../../queries/heights.js'; /** Retain complete parsed snapshots under both an LRU entry cap and a byte budget. */ export function cacheRoadSnapshot( @@ -273,16 +274,15 @@ export function createIngestion({ const scene = layerState._viewer?.scene; const location = Cesium.Cartographic.fromDegrees(centerLon, centerLat); let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon); - if (!Number.isFinite(estimated)) { - try { - estimated = scene?.globe?.show - ? scene.globe.getHeight?.(location) - : scene?.sampleHeightSupported - ? scene.sampleHeight(location, [layerState._pointCollection]) - : undefined; - } catch { - /* the local mesh can still be loading */ - } + // A miss is the local mesh still loading. + if (!Number.isFinite(estimated) && scene) { + estimated = scene.globe?.show + ? heightsFor(scene).now(location, { against: 'terrain' }) + : scene.sampleHeightSupported + ? heightsFor(scene).now(location, { + exclude: [layerState._pointCollection], + }) + : undefined; } if (Number.isFinite(estimated) && Math.abs(estimated) <= 9000) layerState._viewHeightEstimate = estimated; diff --git a/src/layers/traffic/surface.js b/src/layers/traffic/surface.js index 3344faa64..b5170a310 100644 --- a/src/layers/traffic/surface.js +++ b/src/layers/traffic/surface.js @@ -1,6 +1,7 @@ import { phaseTiming } from '../../sources/phaseTiming.js'; import * as Cesium from 'cesium'; import { DOT_HEIGHT_OFFSET, MAX_WAYPOINTS_PER_ROAD } from './policy.js'; +import { heightsFor } from '../../queries/heights.js'; function surfaceTilesReady(scene) { if (scene.globe?.show) return scene.globe.tilesLoaded !== false; @@ -333,7 +334,8 @@ export async function prepareRoadSurfaces( let sampled; // sampleHeight reads the locally rendered mesh, not unresolved/offscreen // tiles. Global tilesLoaded can stay false while this street is usable. - if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto); + if (scene.globe?.show) + sampled = heightsFor(scene).now(carto, { against: 'terrain' }); else { const estimate = Cesium.Cartographic.fromCartesian( road.waypoints[i], @@ -351,9 +353,8 @@ export async function prepareRoadSurfaces( const shown = (excluded || []).filter((p) => p?.show); for (const primitive of shown) primitive.show = false; try { - sampled = scene.sampleHeight(carto, excluded); - } catch { - /* local mesh missing */ + // A miss is the local mesh still missing. + sampled = heightsFor(scene).now(carto, { exclude: excluded }); } finally { for (const primitive of shown) primitive.show = true; } diff --git a/src/layers/transit/height.js b/src/layers/transit/height.js index 2e2945a88..a91bb1842 100644 --- a/src/layers/transit/height.js +++ b/src/layers/transit/height.js @@ -7,6 +7,7 @@ import { HEIGHT_SAMPLE_MAX_ALTITUDE_M, withinViewBounds, } from './policy.js'; +import { heightsFor } from '../../queries/heights.js'; /** Surface preparation off the render path, shared by markers and history. */ export function createHeight({ state, services, parts }) { @@ -63,14 +64,16 @@ export function createHeight({ state, services, parts }) { return { cell, height: fixtureFloor, prior: null }; // On a terrain globe, use the loaded surface under the marker. This is // synchronous cache work and does not launch a terrain request. - const globe = state._viewer?.scene?.globe; - if (globe?.show === true) { + const scene = state._viewer?.scene; + if (scene?.globe?.show === true) { Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); - const rendered = globe.getHeight?.(terrainPoint); + const rendered = heightsFor(scene).now(terrainPoint, { + against: 'terrain', + }); const flat = state._viewer?.terrainProvider instanceof Cesium.EllipsoidTerrainProvider; - if (Number.isFinite(rendered) || flat) + if (rendered !== null || flat) return { cell, height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M, diff --git a/src/locations.js b/src/locations.js index d17186905..a3cddf17d 100644 --- a/src/locations.js +++ b/src/locations.js @@ -12,6 +12,7 @@ import { unavailablePlaceSearch } from './search/placeSearch.js'; import { cancelCameraArrival } from './data/cameraArrival.js'; import { guardCameraAboveGround } from './cameraGroundGuard.js'; import { CITY_POIS } from './cityPois.js'; +import { heightsFor } from './queries/heights.js'; /** Newest landmark arrival; an older post-arrival ground guard yields to it. */ let arrivalGeneration = 0; @@ -109,7 +110,9 @@ export function flyToLandmark(viewer, lat, lon, 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); + const sampledHeight = heightsFor(viewer.scene).now(targetCartographic, { + against: 'terrain', + }); // Use sampled height if available, otherwise the cached ground-floor // elevation (same ellipsoidal datum), then the pre-baked city elevation. diff --git a/src/queries/heights.js b/src/queries/heights.js new file mode 100644 index 000000000..b0dd6fcdd --- /dev/null +++ b/src/queries/heights.js @@ -0,0 +1,161 @@ +/** + * @module queries/heights + * @description The one place height queries go: how high the rendered + * surface or the terrain is under a point, now or after streaming. + * + * `now()` answers from what is already loaded and may return `null`: tiles + * not streamed yet, no depth support, a point off screen. Every caller treats + * that as a miss, holding its last value or placing first and patching when + * `batch()` resolves. Cesium answers `now()` synchronously from the scene, + * so nothing changes on screen; a renderer that can only answer from a cache + * or asynchronously plugs in here without touching the callers. + * + * `against` picks the surface: `'surface'` is what is drawn (3D tiles and the + * globe), `'terrain'` the loaded terrain tiles alone. `exclude` lists what the + * query must see through, typically the caller's own markers. + */ +import * as Cesium from 'cesium'; + +/** @typedef {'surface'|'terrain'} HeightSurface */ +/** + * A point in degrees, or a `Cesium.Cartographic` in radians. + * @typedef {{lon: number, lat: number}|Cesium.Cartographic} HeightPoint + */ +/** + * @typedef {object} HeightOptions + * @property {HeightSurface} [against] Default `'surface'`. + * @property {object[]} [exclude] Primitives and entities to see through. + */ + +/** @type {WeakMap} scene -> service */ +const services = new WeakMap(); + +const scratch = new Cesium.Cartographic(); + +/** `supported()` answers, shared so a per-frame check allocates nothing. */ +const SUPPORT = [ + [ + Object.freeze({ surface: false, clamp: false }), + Object.freeze({ surface: false, clamp: true }), + ], + [ + Object.freeze({ surface: true, clamp: false }), + Object.freeze({ surface: true, clamp: true }), + ], +]; + +const NO_OPTIONS = Object.freeze({}); + +/** Plain finite metres, or null. */ +const metres = (height) => (Number.isFinite(height) ? height : null); + +/** + * The point as a Cartographic in radians at height 0. Degrees go through a + * shared scratch unless `result` is given; a Cartographic is used as it is. + * @param {HeightPoint} point + * @param {Cesium.Cartographic} [result] + * @returns {Cesium.Cartographic|null} + */ +function cartographicOf(point, result = scratch) { + if (Number.isFinite(point?.longitude) && Number.isFinite(point?.latitude)) + return point; + const lon = Number(point?.lon); + const lat = Number(point?.lat); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + return Cesium.Cartographic.fromDegrees(lon, lat, 0, result); +} + +/** + * @typedef {object} HeightService + * @property {(point: HeightPoint, options?: HeightOptions) => number|null} now + * Height in metres above the ellipsoid from what is loaded, or null. + * @property {(position: Cesium.Cartesian3, options?: {exclude?: object[]}) => Cesium.Cartesian3|null} clampNow + * `position` moved onto the drawn surface along the ellipsoid normal, or null. + * @property {(points: HeightPoint[], options?: HeightOptions) => Promise>} batch + * Heights once the most detailed tiles have streamed; null per miss. A + * failed query (a tile request error, a destroyed scene) rejects, so the + * caller can tell it from points that are merely uncovered. + * @property {(positions: Cesium.Cartesian3[], options?: {exclude?: object[]}) => Promise>} clampBatch + * Clamped copies of `positions`; null per miss, rejecting as `batch` does. + * @property {() => {surface: boolean, clamp: boolean}} supported + * Whether the drawn surface can be queried at all (Cesium needs depth + * textures); terrain queries are always attempted. + */ + +/** + * The scene's height service, made on first use. + * @param {object} scene A Cesium scene (or a test double). + * @returns {HeightService} + */ +export function heightsFor(scene) { + let service = services.get(scene); + if (!service) { + service = createHeights(scene); + services.set(scene, service); + } + return service; +} + +function createHeights(scene) { + const terrainProvider = () => + scene.terrainProvider ?? scene.globe?.terrainProvider; + + return { + now(point, { against = 'surface', exclude } = NO_OPTIONS) { + const carto = cartographicOf(point); + if (!carto) return null; + try { + if (against === 'terrain') + return metres(scene.globe?.getHeight?.(carto)); + // Cesium throws without depth-texture support: that is a miss. + return metres(scene.sampleHeight?.(carto, exclude)); + } catch { + return null; + } + }, + + clampNow(position, { exclude } = NO_OPTIONS) { + if (!position) return null; + try { + return scene.clampToHeight?.(position, exclude) ?? null; + } catch { + return null; + } + }, + + async batch(points, { against = 'surface', exclude } = NO_OPTIONS) { + const cartos = points.map((point) => { + const carto = cartographicOf(point, new Cesium.Cartographic()); + return ( + carto && new Cesium.Cartographic(carto.longitude, carto.latitude) + ); + }); + const valid = cartos.filter(Boolean); + if (!valid.length) return cartos.map(() => null); + if (against === 'terrain') { + const provider = terrainProvider(); + if (!provider) return cartos.map(() => null); + await Cesium.sampleTerrainMostDetailed(provider, valid); + } else { + if (typeof scene.sampleHeightMostDetailed !== 'function') + return cartos.map(() => null); + await scene.sampleHeightMostDetailed(valid, exclude); + } + return cartos.map((carto) => (carto ? metres(carto.height) : null)); + }, + + async clampBatch(positions, { exclude } = NO_OPTIONS) { + if (typeof scene.clampToHeightMostDetailed !== 'function') + return positions.map(() => null); + const clones = positions.map((p) => Cesium.Cartesian3.clone(p)); + const clamped = await scene.clampToHeightMostDetailed(clones, exclude); + return clamped.map((p) => p ?? null); + }, + + supported() { + return SUPPORT[+(scene.sampleHeightSupported === true)][ + +(scene.clampToHeightSupported === true) + ]; + }, + }; +} diff --git a/src/queries/heights.test.mjs b/src/queries/heights.test.mjs new file mode 100644 index 000000000..7acf03047 --- /dev/null +++ b/src/queries/heights.test.mjs @@ -0,0 +1,218 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as Cesium from 'cesium'; +import { heightsFor } from './heights.js'; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); + +const AUSTIN = { lon: -97.74, lat: 30.27 }; + +function scene(overrides = {}) { + const calls = []; + return { + calls, + sampleHeightSupported: true, + clampToHeightSupported: true, + sampleHeight(carto, exclude) { + calls.push(['sampleHeight', carto, exclude]); + return 150; + }, + clampToHeight(position, exclude) { + calls.push(['clampToHeight', position, exclude]); + return Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 150); + }, + globe: { getHeight: () => 140 }, + ...overrides, + }; +} + +test('now reads the drawn surface, seeing through what the caller excludes', () => { + const s = scene(); + const own = [{ name: 'own marker' }]; + assert.equal(heightsFor(s).now(AUSTIN, { exclude: own }), 150); + const [[, carto, exclude]] = s.calls; + assert.ok( + Math.abs(Cesium.Math.toDegrees(carto.longitude) - AUSTIN.lon) < 1e-9, + ); + assert.equal(exclude, own); +}); + +test('now takes a Cartographic as it is', () => { + const s = scene(); + const carto = Cesium.Cartographic.fromDegrees(AUSTIN.lon, AUSTIN.lat); + heightsFor(s).now(carto); + assert.equal(s.calls[0][1], carto); +}); + +test('a miss is null, whatever form it takes', () => { + for (const sampleHeight of [ + () => undefined, + () => Number.NaN, + () => { + throw new Error('depth textures unsupported'); + }, + ]) { + assert.equal(heightsFor(scene({ sampleHeight })).now(AUSTIN), null); + } + assert.equal( + heightsFor(scene({ sampleHeight: undefined })).now(AUSTIN), + null, + ); + assert.equal(heightsFor(scene()).now({ lon: 'x', lat: 1 }), null); +}); + +test('terrain reads the loaded terrain tiles only', () => { + const s = scene(); + assert.equal(heightsFor(s).now(AUSTIN, { against: 'terrain' }), 140); + assert.equal(s.calls.length, 0, 'the drawn surface is not sampled'); + assert.equal( + heightsFor(scene({ globe: undefined })).now(AUSTIN, { against: 'terrain' }), + null, + ); +}); + +test('clampNow moves a position onto the surface, or misses with null', () => { + const s = scene(); + const own = [{}]; + const position = Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0); + const clamped = heightsFor(s).clampNow(position, { exclude: own }); + assert.ok( + Math.abs(Cesium.Cartographic.fromCartesian(clamped).height - 150) < 1e-6, + ); + assert.equal(s.calls[0][2], own); + assert.equal( + heightsFor(scene({ clampToHeight: () => undefined })).clampNow(position), + null, + ); +}); + +test('batch answers each point once the tiles stream, null per miss', async () => { + let seen; + const s = scene({ + async sampleHeightMostDetailed(cartos, exclude) { + seen = exclude; + cartos.forEach((c, i) => (c.height = i === 1 ? undefined : 100 + i)); + return cartos; + }, + }); + const own = [{}]; + const heights = await heightsFor(s).batch( + [ + AUSTIN, + { lon: 0, lat: 0 }, + Cesium.Cartographic.fromDegrees(1, 1, 999), + { lon: 'bad' }, + ], + { exclude: own }, + ); + assert.deepEqual(heights, [100, null, 102, null]); + assert.equal(seen, own); +}); + +test('batch leaves the caller’s Cartographics alone', async () => { + const s = scene({ + async sampleHeightMostDetailed(cartos) { + cartos.forEach((c) => (c.height = 5)); + return cartos; + }, + }); + const carto = Cesium.Cartographic.fromDegrees(1, 1, 999); + await heightsFor(s).batch([carto]); + assert.equal(carto.height, 999); +}); + +test('batch misses everything when the scene cannot stream, and rejects when it fails', async () => { + assert.deepEqual(await heightsFor(scene()).batch([AUSTIN]), [null]); + const failing = scene({ + sampleHeightMostDetailed: async () => { + throw new Error('scene destroyed'); + }, + clampToHeightMostDetailed: async () => { + throw new Error('scene destroyed'); + }, + }); + await assert.rejects(heightsFor(failing).batch([AUSTIN]), /destroyed/); + await assert.rejects( + heightsFor(failing).clampBatch([Cesium.Cartesian3.UNIT_X]), + /destroyed/, + ); + assert.deepEqual( + await heightsFor(scene()).batch([AUSTIN], { against: 'terrain' }), + [null], + 'no terrain provider, no terrain heights', + ); +}); + +test('clampBatch clamps copies and misses per position', async () => { + const s = scene({ + async clampToHeightMostDetailed(positions) { + return positions.map((p, i) => (i === 0 ? p : undefined)); + }, + }); + const position = Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0); + const [first, second] = await heightsFor(s).clampBatch([position, position]); + assert.notEqual(first, position, 'the caller’s position is not mutated'); + assert.equal(second, null); +}); + +test('supported reports whether the drawn surface can be queried', () => { + assert.deepEqual(heightsFor(scene()).supported(), { + surface: true, + clamp: true, + }); + assert.deepEqual( + heightsFor( + scene({ + sampleHeightSupported: false, + clampToHeightSupported: undefined, + }), + ).supported(), + { surface: false, clamp: false }, + ); +}); + +test('supported answers without allocating', () => { + const s = scene(); + assert.equal(heightsFor(s).supported(), heightsFor(s).supported()); +}); + +test('one service per scene', () => { + const s = scene(); + assert.equal(heightsFor(s), heightsFor(s)); + assert.notEqual(heightsFor(s), heightsFor(scene())); +}); + +test('height queries go through the service', () => { + // A renderer that answers heights from a cache or asynchronously plugs in + // at src/queries/heights.js; a site that asks Cesium directly bypasses it. + const SERVICE = 'src/queries/heights.js'; + const NAMES = + 'sampleHeight|clampToHeight|sampleHeightMostDetailed|clampToHeightMostDetailed|getHeight'; + const direct = new RegExp( + `\\.(${NAMES})\\b|\\[\\s*['"\`](${NAMES})['"\`]\\s*\\]|\\bsampleTerrain(MostDetailed)?\\b`, + ); + const walk = (dir) => + fs + .readdirSync(path.join(ROOT, dir), { withFileTypes: true }) + .flatMap((entry) => { + const rel = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(rel); + return /\.(js|vue)$/.test(entry.name) ? [rel] : []; + }); + const offenders = walk('src').filter((file) => { + if (file === SERVICE) return false; + const code = fs + .readFileSync(path.join(ROOT, file), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + return direct.test(code); + }); + assert.deepEqual(offenders, []); +}); diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index eecb1fa34..ba63322fb 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -47,6 +47,7 @@ */ import * as Cesium from 'cesium'; +import { heightsFor } from '../queries/heights.js'; /** Taxi threshold: a cached snap answers directly for moves up to this far from * the sampled spot (m). Past it the value is demoted to a held last-known and a @@ -347,21 +348,16 @@ export function createGroundSnap({ groundFloor }) { }; if (!_tilesReady(viewer)) return miss(); windowCount += 1; - let sampled; - try { - const carto = Cesium.Cartographic.fromCartesian( - pos, - Cesium.Ellipsoid.WGS84, - scratchCarto, - ); - // sampleHeight throws when unsupported (no depth textures) — that's a miss. - sampled = viewer.scene.sampleHeight( - carto, - getExclusions ? getExclusions() : undefined, - ); - } catch { - sampled = undefined; - } + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + scratchCarto, + ); + const sampled = carto + ? heightsFor(viewer.scene).now(carto, { + exclude: getExclusions ? getExclusions() : undefined, + }) + : null; // Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the // ellipsoid is pick garbage, not ground. if (!Number.isFinite(sampled) || sampled < -150) return miss(); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index c967f80c8..69e5db4f8 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { approxDistanceKm } from '../data/approxDistance.js'; +import { heightsFor } from '../queries/heights.js'; /** Construct an instance-owned meshFloorSampler service with explicit dependencies. */ export function createMeshFloorSampler({ @@ -101,7 +102,8 @@ export function createMeshFloorSampler({ { excludeObjects = [], viewerLat, viewerLon } = {}, ) { if (!meshFloorPreferred()) return; - if (!scene || typeof scene.sampleHeight !== 'function') return; + // Without depth-texture support every probe throws: nothing to latch. + if (!scene || !heightsFor(scene).supported().surface) return; if (!Array.isArray(points) || !points.length) return; // Round-5 hardening (the CCTV projectionTilesReady lesson, ported late): // while tiles are MID-STREAM a probe returns coarse-LOD heights — real @@ -129,20 +131,14 @@ export function createMeshFloorSampler({ ) { continue; // too far: tiles not streamed there, probe would be a guaranteed miss } - let height; - try { - const carto = Cesium.Cartographic.fromDegrees( - cell.lon, - cell.lat, - 0, - _scratchProbe, - ); - height = scene.sampleHeight(carto, excludeObjects); - sampled += 1; - } catch { - continue; // scene mid-teardown — try again next poll - } - if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch + const height = heightsFor(scene).now( + Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _scratchProbe), + { exclude: excludeObjects }, + ); + // A miss counts against the budget whatever its cause: past the support + // gate a probe only throws mid-teardown, when every later cell would too. + sampled += 1; + if (height === null) continue; // tiles not loaded yet — no negative latch // Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches. // - A geoid-fallback prior (cached while the proxy was failing) is a // sea-level poison that rejected legitimate samples at elevated diff --git a/src/ui/flyToDataSource.js b/src/ui/flyToDataSource.js index bf4b9336a..30cd98227 100644 --- a/src/ui/flyToDataSource.js +++ b/src/ui/flyToDataSource.js @@ -3,6 +3,7 @@ import * as Cesium from 'cesium'; import { guardCameraAboveGround } from '../cameraGroundGuard.js'; import { rangeForBoundingSphere } from '../data/cameraFraming.js'; import { cancelCameraArrival } from '../data/cameraArrival.js'; +import { heightsFor } from '../queries/heights.js'; /** All visible positions, path vertices, polygon rings, walls and overlay bounds. */ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) { @@ -87,7 +88,9 @@ export function flyToDataSource( Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]); const lat = Cesium.Math.toDegrees(center.latitude), lon = Cesium.Math.toDegrees(center.longitude); - const terrain = viewer.scene?.globe?.getHeight?.(center); + const terrain = viewer.scene + ? heightsFor(viewer.scene).now(center, { against: 'terrain' }) + : null; const cached = ground?.cachedGroundFloor?.(lat, lon); const height = Number.isFinite(terrain) && terrain > 0