mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
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.
This commit is contained in:
1 parent
b51f1bf7c5
commit
d51ea4007b
32 files changed
+704
-210
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+11
-18
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+10
-11
@@ -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 };
|
||||
}
|
||||
|
||||
+10
-16
@@ -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 };
|
||||
}
|
||||
|
||||
+13
-22
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
@@ -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<object, HeightService>} 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<Array<number|null>>} 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<Array<Cesium.Cartesian3|null>>} 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)
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
+11
-15
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user