feat(globe): surface height quality

engine.surface.sampleHeightDetailed(place, { exclude, lod }) answers
{ height, source: 'tiles' | 'terrain', tileSizeM? } (src/globe/surfaceDetail.js).
On Navara tileSizeM is the extent of the tile that answered and
lod 'finest' takes the finest loaded tile, drawn or not; Cesium answers
tiles without a size, else the globe's terrain. acceptedSurfaceHeight()
turns an answer into a height or a miss: tiles larger than
MAX_FLOOR_TILE_SIZE_M (4 km) are too coarse, terrain is not the mesh.

The mesh floor sampler and the ground snap are engine-only now (a viewer
or scene reaches its engine through engineFromViewer, which also registers
the scene) and treat a coarse answer as a miss, as they treat undefined.
Measured on Navara at Austin: while the tiles stream, a 27.9 km tile reads
143.2 m where the ground is 122.5 m (a 1.8 km tile).

`exclude` takes the engine's own mover sets (MoverSet.id): Cesium hides a
set's primitive for the synchronous sample (Cesium matches excluded objects
per billboard, not per collection); Navara's ray only meets tiles.

"sampleHeight undefined in the app on Navara" did not reproduce: the
startup Austin sample is 121.9 m (tile 889 m) on Navara and 122.4 m on
Cesium; one ModelMesh class is patched.

S2 QA (scripts/qa-surface-heights.mjs, 20 places at JFK, SFO, AUS, roofs
and streets): Navara within 2 m of Cesium at 19/20 from 800 m and 18/20
from 15 km with lod 'finest' (gates 18 and 15); offsets per region
-0.21 / +0.4 / +0.1 m; the Empire State roof differs by 39 m (spire
geometry). Navara tile sizes at 800 m: 112-314 m.
This commit is contained in:
German Khokhlov committed 2026-10-16 20:51:29 +04:00
1 parent f640890cf6
commit 645f042450
20 files changed
+635 -244

No files matched your search

+4 -1
View File
@@ -1089,5 +1089,8 @@
"src/globe/follow.js",
"src/globe/follow.test.mjs",
"src/globe/cesium/follow.js",
"src/globe/cesium/follow.test.mjs"
"src/globe/cesium/follow.test.mjs",
"src/globe/surfaceDetail.js",
"src/globe/surfaceDetail.test.mjs",
"scripts/qa-surface-heights.mjs"
]
+6 -3
View File
@@ -2563,7 +2563,8 @@
"src/globe/navara/movers/moverMesh.js",
"src/globe/navara/movers/pack.js",
"src/globe/navara/movers/modelMover.js",
"src/globe/navara/movers/index.js"
"src/globe/navara/movers/index.js",
"src/globe/surfaceDetail.js"
],
"external": [
"hls.js",
@@ -3214,7 +3215,8 @@
"src/geo/scaleToGeodeticSurface.js",
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js"
"src/geo/developerError.js",
"src/globe/surfaceDetail.js"
],
"external": [
"cesium",
@@ -3694,7 +3696,8 @@
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js",
"src/time/clock.js"
"src/time/clock.js",
"src/globe/surfaceDetail.js"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
+98 -90
View File
@@ -4,9 +4,10 @@
// The sampler's acceptance rule was previously covered only through
// `reportValidatedMeshFloorCell` directly, which proves the gate but not the
// decision that feeds it. These tests call `sampleMeshFloorCells` with a fake
// scene whose `sampleHeight` returns exactly what Cesium can return — a number,
// `undefined`, NaN, or a throw — and assert that a cell latches if and only if
// a REAL DEM prior exists and the sample fits its window.
// globe engine whose surface answers what an engine can answer — a height,
// `undefined`, NaN, a throw, or a coarse tile — and assert that a cell
// latches if and only if a REAL DEM prior exists, the answer is fine enough
// and the sample fits its window.
//
// That requirement is the load-bearing one: this round built a tier that kept
// the reading when no prior existed, and measured it recording a coarse-LOD
@@ -46,90 +47,8 @@ async function seedDem(cell, ellipsoid) {
}
}
/** A scene the sampler accepts: low camera, one visible tileset reporting
* tilesLoaded, and a `sampleHeight` under the test's control. */
function fakeScene(sampleHeight, { cameraHeightM = 900, tilesLoaded = true, show = true } = {}) {
// instanceof-compatible without running Cesium's constructor; `tilesLoaded` is
// a prototype getter, so it has to be shadowed with an own data property.
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
Object.defineProperty(tileset, 'tilesLoaded', { value: tilesLoaded, configurable: true });
Object.defineProperty(tileset, 'show', { value: show, configurable: true });
return {
sampleHeight,
camera: { positionCartographic: { height: cameraHeightM } },
primitives: { length: 1, get: () => tileset },
};
}
function reset() {
_clearMeshFloorCellsForTest();
setMeshFloorPreferred(true);
}
test('sampler: a finite sample with NO DEM prior is stored NOWHERE', () => {
reset();
const cell = nextSite();
sampleMeshFloorCells(fakeScene(() => 138.0), [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'nothing may latch without a prior');
assert.equal(cachedGroundFloor(cell.lat, cell.lon), null, 'the shared choke point stays clean');
});
test('sampler: a finite sample WITH a fitting DEM prior latches and becomes the shared floor', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const scene = fakeScene(() => 137.0); // +17 m of mesh over bare earth — in window
sampleMeshFloorCells(scene, [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 137.0);
assert.equal(cachedGroundFloor(cell.lat, cell.lon), 137.0, 'and it is what every consumer reads');
});
test('sampler: a sample REJECTED against a real prior is stored nowhere', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const scene = fakeScene(() => 400.0); // a tower hit — far outside the +80 m window
sampleMeshFloorCells(scene, [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
assert.equal(cachedGroundFloor(cell.lat, cell.lon), 120.0, 'the DEM prior still answers');
});
test('sampler: undefined, NaN and a throwing probe latch nothing and crash nothing', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
for (const bad of [() => undefined, () => Number.NaN, () => { throw new Error('mid-teardown'); }]) {
sampleMeshFloorCells(fakeScene(bad), [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'no negative latch, no throw');
}
sampleMeshFloorCells(fakeScene(() => 133.0), [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 133.0, 'and a later good sample still lands');
});
test('sampler: a high camera or unloaded tiles produce no records at all', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
sampleMeshFloorCells(fakeScene(() => 138.0, { cameraHeightM: 90_000 }), [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'coarse LOD under a high camera');
sampleMeshFloorCells(fakeScene(() => 138.0, { tilesLoaded: false }), [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'mid-stream tiles read coarse');
});
test('sampler: outside the google-3d regime nothing is sampled at all', () => {
reset();
const cell = nextSite();
setMeshFloorPreferred(false);
let calls = 0;
sampleMeshFloorCells(fakeScene(() => { calls += 1; return 138.0; }), [cell],
{ viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(calls, 0, 'on a globe stack the DEM IS the rendered surface');
setMeshFloorPreferred(true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
// --- On a globe engine (engine.surface / camera.height / maps.photorealActive)
/** A globe engine the sampler accepts: low camera over the cell, photoreal
* tiles loaded, and a surface under the test's control. */
async function fakeEngineAt(cell, { heightM = 900, tilesLoaded = true, photoreal = true, sample } = {}) {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
@@ -144,6 +63,74 @@ async function fakeEngineAt(cell, { heightM = 900, tilesLoaded = true, photoreal
return engine;
}
function reset() {
_clearMeshFloorCellsForTest();
setMeshFloorPreferred(true);
}
test('sampler: a finite sample with NO DEM prior is stored NOWHERE', async () => {
reset();
const cell = nextSite();
sampleMeshFloorCells(await fakeEngineAt(cell, { sample: () => 138.0 }), [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'nothing may latch without a prior');
assert.equal(cachedGroundFloor(cell.lat, cell.lon), null, 'the shared choke point stays clean');
});
test('sampler: a finite sample WITH a fitting DEM prior latches and becomes the shared floor', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const engine = await fakeEngineAt(cell, { sample: () => 137.0 }); // +17 m of mesh over bare earth — in window
sampleMeshFloorCells(engine, [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 137.0);
assert.equal(cachedGroundFloor(cell.lat, cell.lon), 137.0, 'and it is what every consumer reads');
});
test('sampler: a sample REJECTED against a real prior is stored nowhere', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const engine = await fakeEngineAt(cell, { sample: () => 400.0 }); // a tower hit — far outside the +80 m window
sampleMeshFloorCells(engine, [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
assert.equal(cachedGroundFloor(cell.lat, cell.lon), 120.0, 'the DEM prior still answers');
});
test('sampler: undefined, NaN and a throwing probe latch nothing and crash nothing', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
for (const bad of [() => undefined, () => Number.NaN, () => { throw new Error('mid-teardown'); }]) {
sampleMeshFloorCells(await fakeEngineAt(cell, { sample: bad }), [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'no negative latch, no throw');
}
sampleMeshFloorCells(await fakeEngineAt(cell, { sample: () => 133.0 }), [cell], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 133.0, 'and a later good sample still lands');
});
test('sampler: a high camera or unloaded tiles produce no records at all', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
sampleMeshFloorCells(await fakeEngineAt(cell, { sample: () => 138.0, heightM: 90_000 }), [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'coarse LOD under a high camera');
sampleMeshFloorCells(await fakeEngineAt(cell, { sample: () => 138.0, tilesLoaded: false }), [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'mid-stream tiles read coarse');
});
test('sampler: outside the google-3d regime nothing is sampled at all', async () => {
reset();
const cell = nextSite();
setMeshFloorPreferred(false);
let calls = 0;
sampleMeshFloorCells(await fakeEngineAt(cell, { sample: () => { calls += 1; return 138.0; } }), [cell],
{ viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(calls, 0, 'on a globe stack the DEM IS the rendered surface');
setMeshFloorPreferred(true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
test('sampler on a globe engine: samples engine.surface and latches against the prior', async () => {
reset();
const cell = nextSite();
@@ -151,9 +138,30 @@ test('sampler on a globe engine: samples engine.surface and latches against the
const engine = await fakeEngineAt(cell, { sample: () => 137.0 });
sampleMeshFloorCells(engine, [cell], { viewerLat: cell.lat, viewerLon: cell.lon, excludeObjects: ['own'] });
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 137.0);
const [call] = engine.fake.callsOf('surface.sampleHeight');
const [call] = engine.fake.callsOf('surface.sampleHeightDetailed');
assert.ok(Math.abs(Cesium.Math.toDegrees(call.args[0].latitude) - cell.lat) < 1e-9);
assert.deepEqual(call.args[1], ['own']);
assert.deepEqual(call.args[1], { exclude: ['own'], lod: 'visible' });
});
test('sampler: a coarse tile answer is a miss; a fine one latches', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
// Measured on Navara at Austin while the tiles streamed: a 28 km tile read
// 143.2 m where the ground is 122.5 m (a 1.8 km tile).
const engine = await fakeEngineAt(cell);
engine.fake.script('sampleHeightDetailed', () => ({ height: 143.2, source: 'tiles', tileSizeM: 27_914 }));
sampleMeshFloorCells(engine, [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'coarse: no latch');
engine.fake.script('sampleHeightDetailed', () => ({ height: 122.5, source: 'tiles', tileSizeM: 1_797 }));
sampleMeshFloorCells(engine, [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.5);
const terrainOnly = nextSite();
await seedDem(terrainOnly, 120.0);
engine.fake.script('sampleHeightDetailed', () => ({ height: 121, source: 'terrain' }));
sampleMeshFloorCells(engine, [terrainOnly], { viewerLat: terrainOnly.lat, viewerLon: terrainOnly.lon });
assert.equal(cachedMeshFloor(terrainOnly.lat, terrainOnly.lon), null, 'terrain is not the mesh');
});
test('sampler on a globe engine: a high camera, loading tiles or no photoreal tiles sample nothing', async () => {
@@ -164,7 +172,7 @@ test('sampler on a globe engine: a high camera, loading tiles or no photoreal ti
for (const options of [{ heightM: 90_000 }, { tilesLoaded: false }, { photoreal: false }]) {
const engine = await fakeEngineAt(cell, { ...options, sample: () => 138.0 });
sampleMeshFloorCells(engine, [cell], opts);
assert.equal(engine.fake.callsOf('surface.sampleHeight').length, 0, JSON.stringify(options));
assert.equal(engine.fake.callsOf('surface.sampleHeightDetailed').length, 0, JSON.stringify(options));
}
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
+15 -1
View File
@@ -26,6 +26,7 @@ import {
FLEET_MODEL_ALT_CEIL_M,
TRACKED_MODEL_EXIT_RATIO,
} from './trackedModelRegime.js';
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
import * as Cesium from 'cesium';
import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
@@ -155,7 +156,7 @@ if (typeof globalThis.window === 'undefined') {
/** A viewer stub whose camera altitude the test can move between evaluations. */
function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undefined } = {}) {
const tileset = { show: true, tilesLoaded };
return {
const viewer = {
camera: {
positionCartographic: { height },
positionWC: Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 5000),
@@ -171,6 +172,19 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef
trackedEntity: undefined,
entities: { remove() {} },
};
// The ground snap samples through the viewer's engine (engineFromViewer):
// the engine surface over this stub's tileset and scene.sampleHeight.
registerViewerEngine(viewer, {
isDestroyed: () => false,
surface: {
tilesLoaded: () => viewer.tileset.tilesLoaded === true,
sampleHeightDetailed(place, { exclude } = {}) {
const h = viewer.scene.sampleHeight(place, exclude);
return Number.isFinite(h) ? { height: h, source: 'tiles' } : undefined;
},
},
});
return viewer;
}
/** A billboard with a real position, so the tracked-model driver has somewhere
+4 -1
View File
@@ -64,7 +64,10 @@ export function createCesiumEngine(viewer, options = {}) {
...(options.moverFactories ? { factories: options.moverFactories } : {}),
})),
render,
surface: createCesiumSurface(viewer),
// Mover sets in a surface `exclude` are hidden for the sample.
surface: createCesiumSurface(viewer, {
engineExclude: (entry) => movers?.primitiveOf(entry) ?? null,
}),
input: createCesiumInput(viewer, { ...options, picker: picker.picker }),
credits,
maps: createCesiumMaps(viewer, {
+11 -7
View File
@@ -13,11 +13,15 @@ const engines = new WeakMap();
* @returns {() => void} Unregister (only while it is still the registered one).
*/
export function registerViewerEngine(viewer, engine) {
// The viewer's Cesium camera names the same engine, so callers that hold
// only viewer.camera (camera sensitivity claims) reach the engine camera.
const keys = [viewer, viewer?.camera, viewer?.scene?.camera].filter(
(key, index, all) => key != null && all.indexOf(key) === index,
);
// The viewer's scene and Cesium camera name the same engine, so callers
// that hold only viewer.scene (surface samplers) or viewer.camera (camera
// sensitivity claims) reach the engine.
const keys = [
viewer,
viewer?.scene,
viewer?.camera,
viewer?.scene?.camera,
].filter((key, index, all) => key != null && all.indexOf(key) === index);
for (const key of keys) engines.set(key, engine);
return () => {
for (const key of keys)
@@ -26,8 +30,8 @@ export function registerViewerEngine(viewer, engine) {
}
/**
* @param {object | null | undefined} viewer A Cesium viewer or its camera,
* or an engine (returned as is).
* @param {object | null | undefined} viewer A Cesium viewer, its scene or
* camera, or an engine (returned as is).
* @returns {import('../contract.js').GlobeEngine | null} The registered
* engine, or null.
*/
+6
View File
@@ -307,6 +307,7 @@ export function createCesiumMovers(
ordered.splice(at, 0, { order: options.order, primitive });
rootCollection().add(primitive, at);
return {
primitive,
commit: () => part.commit(store.dirty()),
destroy() {
part.destroy();
@@ -321,6 +322,11 @@ export function createCesiumMovers(
});
return {
movers: registry.movers,
/** The primitive drawing a live mover set of this engine, or null. */
primitiveOf(set) {
const entry = set?.id ? registry.sets.get(set.id) : null;
return entry && entry.set === set ? entry.part.primitive : null;
},
/** The set and instance a Cesium pick result belongs to, or null. */
identify(picked) {
const known = picked?.primitive ? owners.get(picked.primitive) : null;
+67 -16
View File
@@ -1,42 +1,93 @@
// GlobeSurface on a Cesium scene: the rendered-surface queries that
// services/groundSnap and services/meshFloorSampler make today.
// services/groundSnap and services/meshFloorSampler make.
//
// `exclude` takes the engine's own mover sets (engine.movers) as well as
// Cesium objects: Cesium's sampleHeight matches excluded objects by the
// picked primitive, which for a set is each billboard, point or model, so a
// set is hidden for the sample instead (synchronous: nothing draws in
// between) and shown again after it.
import { firstVisibleTileset } from '../../services/groundSnap.js';
import { normalizeSurfaceOptions } from '../surfaceDetail.js';
import { toGeo3 } from './convert.js';
const finite = (value) => (Number.isFinite(value) ? value : undefined);
/**
* @param {Cesium.Viewer} viewer
* @param {{ engineExclude?: (entry: any) => ({ show: boolean } | null) }} [options]
* `engineExclude` maps an engine object (a mover set) to the Cesium
* primitive that draws it, or null for a Cesium object.
* @returns {{ surface: import('../contract.js').GlobeSurface, destroy: () => void }}
*/
export function createCesiumSurface(viewer) {
export function createCesiumSurface(
viewer,
{ engineExclude = () => null } = {},
) {
const { scene } = viewer;
const surface = {
sampleHeight(cartographic, exclude) {
if (!scene.sampleHeightSupported) return undefined;
try {
// Throws when unsupported (no depth textures); that is a miss.
return finite(scene.sampleHeight(cartographic, exclude));
} catch {
return undefined;
// Runs `query(nativeExclude)` with the engine objects in `exclude` hidden.
function excluding(exclude, query) {
if (!exclude?.length) return query(exclude);
let native = exclude;
const hidden = [];
for (const entry of exclude) {
const primitive = engineExclude(entry);
if (!primitive) continue;
if (native === exclude)
native = exclude.filter((other) => !engineExclude(other));
if (primitive.show) {
primitive.show = false;
hidden.push(primitive);
}
}
try {
return query(native);
} finally {
for (const primitive of hidden) primitive.show = true;
}
}
const sampleHeight = (cartographic, exclude) => {
if (!scene.sampleHeightSupported) return undefined;
try {
// Throws when unsupported (no depth textures); that is a miss.
return excluding(exclude, (native) =>
finite(scene.sampleHeight(cartographic, native)),
);
} catch {
return undefined;
}
};
const terrainHeight = (cartographic) =>
scene.globe ? finite(scene.globe.getHeight(cartographic)) : undefined;
const surface = {
sampleHeight,
// Cesium samples the tiles it has loaded (lod 'finest' cannot do better
// synchronously) and does not say which tile answered.
sampleHeightDetailed(cartographic, options) {
const { exclude } = normalizeSurfaceOptions(options);
const height = sampleHeight(cartographic, exclude);
if (height !== undefined) return { height, source: 'tiles' };
const terrain = terrainHeight(cartographic);
return terrain === undefined
? undefined
: { height: terrain, source: 'terrain' };
},
get supported() {
return Boolean(scene.sampleHeightSupported);
},
terrainHeight(cartographic) {
return scene.globe
? finite(scene.globe.getHeight(cartographic))
: undefined;
},
terrainHeight,
get terrainHeightSupported() {
return Boolean(scene.globe);
},
clampToHeight(position, exclude) {
if (!scene.clampToHeightSupported) return undefined;
try {
return toGeo3(scene.clampToHeight(position, exclude));
return excluding(exclude, (native) =>
toGeo3(scene.clampToHeight(position, native)),
);
} catch {
return undefined;
}
+41
View File
@@ -84,3 +84,44 @@ test('tilesLoaded follows the first visible tileset, else the shown globe', () =
tileset.tilesLoaded = true;
assert.equal(surface.tilesLoaded(), true);
});
test('sampleHeightDetailed: tiles from sampleHeight, else terrain; no tile size', () => {
const { surface, scene } = setup();
const place = geo.cartographic.fromDegrees(20, 10, 0);
assert.deepEqual(surface.sampleHeightDetailed(place), {
height: 42,
source: 'tiles',
});
scene.sampleHeight = () => undefined;
assert.deepEqual(surface.sampleHeightDetailed(place, { lod: 'finest' }), {
height: 12.5,
source: 'terrain',
});
assert.throws(
() => surface.sampleHeightDetailed(place, { lod: 2 }),
TypeError,
);
});
test('an engine mover set in exclude is hidden for the sample, native objects pass', () => {
const viewer = createFakeViewer();
const collection = { show: true };
const set = { id: 'flights' };
const { surface } = createCesiumSurface(viewer, {
engineExclude: (entry) => (entry === set ? collection : null),
});
const shownDuring = [];
viewer.scene.sampleHeight = (position, excluded) => {
shownDuring.push(collection.show, excluded);
return 30;
};
const native = { primitive: 1 };
const place = geo.cartographic.fromDegrees(20, 10, 0);
assert.equal(surface.sampleHeight(place, [set, native]), 30);
assert.deepEqual(shownDuring, [false, [native]]);
assert.equal(collection.show, true, 'shown again after the sample');
shownDuring.length = 0;
surface.sampleHeightDetailed(place, { exclude: [set] });
assert.deepEqual(shownDuring, [false, []]);
assert.equal(collection.show, true);
});
+13
View File
@@ -183,6 +183,12 @@
* @typedef {object} GlobeSurface
* @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight
* Height of the rendered scene (tiles, models) under a point.
* @property {(cartographic: Cartographic, options?: { exclude?: any[], lod?: 'visible' | 'finest' }) => (SurfaceHeight | undefined)} sampleHeightDetailed
* sampleHeight with the answer's quality (src/globe/surfaceDetail.js):
* lod 'visible' (default) answers from the tiles drawn now, 'finest' from
* the finest loaded ones (Navara; Cesium samples what it has loaded).
* Where no tile answers, the terrain. `exclude` takes this engine's mover
* sets as well as engine objects.
* @property {boolean} supported Whether sampleHeight works.
* @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight
* Height of the terrain/globe surface only (Cesium's globe.getHeight).
@@ -192,6 +198,12 @@
* @property {boolean} clampToHeightSupported
* @property {() => boolean} tilesLoaded
*
* @typedef {object} SurfaceHeight
* @property {number} height Metres above the ellipsoid.
* @property {'tiles' | 'terrain'} source
* @property {number} [tileSizeM] The size of the tile that answered
* (Navara): the quality signal; Cesium does not say.
*
* @typedef {object} GlobeRender
* @property {(reason?: string) => void} request Renders a frame soon in
* request mode; `reason` is for diagnostics only.
@@ -585,6 +597,7 @@ const SHAPE = {
},
surface: {
sampleHeight: isFunction,
sampleHeightDetailed: isFunction,
supported: isBoolean,
terrainHeight: isFunction,
terrainHeightSupported: isBoolean,
+4 -1
View File
@@ -309,12 +309,14 @@ export function createMoverStore(options) {
* destroy. commit() hands the engine the changed range and cleans the store;
* a destroyed set refuses every call but destroy().
*/
export function createMoverSet(store, { commit, destroy }) {
export function createMoverSet(store, { id, commit, destroy }) {
let destroyed = false;
const alive = () => {
if (destroyed) throw new Error('Mover set was destroyed');
};
return {
// The set's id (createSet's `id`): surface exclusion and picks name it.
id,
set(index, fields) {
alive();
store.set(index, fields);
@@ -359,6 +361,7 @@ export function createMoverRegistry(build) {
const part = build(options, store);
const entry = { options, store, part, set: null };
entry.set = createMoverSet(store, {
id: options.id,
commit: () => part.commit(),
destroy: () => {
sets.delete(options.id);
+75 -28
View File
@@ -20,6 +20,7 @@ import {
Vector3,
} from 'three';
import * as geo from '../../geo/index.js';
import { normalizeSurfaceOptions } from '../surfaceDetail.js';
const { cartesian3, cartographic, math } = geo;
const WGS84 = geo.ellipsoid.WGS84;
@@ -62,17 +63,22 @@ const hasPositions = (object) =>
object.isMesh && Boolean(object.geometry?.attributes?.position?.array);
// The meshes with CPU positions under `object`, minus `exclude`, into
// `out`; with `shownOnly`, only through visible objects.
function collectMeshes(object, exclude, shownOnly, out) {
// `out`; with `shownOnly`, only through visible objects. `roots` (when
// given) gets the tile root of each mesh, at the same index.
function collectMeshes(object, exclude, shownOnly, out, roots, root) {
if (exclude.has(object) || (shownOnly && !object.visible)) return;
if (hasPositions(object)) out.push(object);
if (hasPositions(object)) {
out.push(object);
roots?.push(root);
}
const children = object.children;
for (let i = 0; i < children.length; i++)
collectMeshes(children[i], exclude, shownOnly, out);
collectMeshes(children[i], exclude, shownOnly, out, roots, root);
}
// Reused between calls (sampleHeight runs per frame in the ground floor).
const shownMeshes = [];
const shownRoots = [];
const tileMeshesScratch = [];
// Nearest hit along worldRay with one mesh, in metres. Not Mesh.raycast: that
@@ -114,25 +120,32 @@ function meshHitDistance(mesh, far) {
}
// Nearest hit over meshes, tried in the order the ray enters their bounding
// spheres; stops once the next sphere starts below the best hit.
function nearestHit(meshes, ray) {
// spheres; stops once the next sphere starts below the best hit. Returns
// the distance and the tile root (from `roots`) of the mesh hit.
function nearestHit(meshes, roots, ray) {
worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z);
worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z);
const candidates = [];
for (const mesh of meshes) {
for (let i = 0; i < meshes.length; i++) {
const mesh = meshes[i];
const geometry = mesh.geometry;
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
if (!worldRay.intersectSphere(sphere, entry)) continue;
candidates.push([worldRay.origin.distanceTo(entry), mesh]);
candidates.push([worldRay.origin.distanceTo(entry), i]);
}
candidates.sort((x, y) => x[0] - y[0]);
let nearest = Infinity;
for (const [enter, mesh] of candidates) {
let root = null;
for (const [enter, i] of candidates) {
if (enter > nearest) break;
nearest = Math.min(nearest, meshHitDistance(mesh, ray.far));
const distance = meshHitDistance(meshes[i], ray.far);
if (distance < nearest) {
nearest = distance;
root = roots[i];
}
}
return nearest;
return { distance: nearest, root };
}
// A tile's extent: the bounding sphere around all its meshes, in
@@ -167,20 +180,22 @@ const NO_EXCLUDE = new Set();
// the top one among them. Tiles the ray passes are tried finest first, and
// coarser ones stop being tried once a hit sets the limit, so the coarse
// parents' triangles are never walked.
// With `includeShown` (lod 'finest') the shown tiles take part too.
const hiddenTiles = [];
function finestHiddenHit(roots, exclude, ray) {
function finestHiddenHit(roots, exclude, ray, includeShown = false) {
worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z);
worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z);
hiddenTiles.length = 0;
for (let i = 0; i < roots.length; i++) {
const root = roots[i];
if (root.visible || exclude.has(root)) continue;
if ((root.visible && !includeShown) || exclude.has(root)) continue;
const extent = tileExtent(root);
if (extent && worldRay.intersectsSphere(extent)) hiddenTiles.push(root);
}
hiddenTiles.sort((x, y) => extents.get(x).radius - extents.get(y).radius);
let limit = Infinity;
let nearest = Infinity;
let answered = null;
for (const root of hiddenTiles) {
const radius = extents.get(root).radius;
if (radius > limit) break;
@@ -191,12 +206,21 @@ function finestHiddenHit(roots, exclude, ray) {
hit = Math.min(hit, meshHitDistance(mesh, ray.far));
if (!Number.isFinite(hit)) continue;
if (limit === Infinity) limit = 1.5 * radius;
nearest = Math.min(nearest, hit);
if (hit < nearest) {
nearest = hit;
answered = root;
}
}
hiddenTiles.length = 0;
return nearest;
return { distance: nearest, root: answered };
}
// The size of a tile that answered: its extent's diameter.
const tileSizeOf = (root) => {
const extent = root ? tileExtent(root) : null;
return extent ? 2 * extent.radius : undefined;
};
/**
* @param {{ sampleTerrainHeight: (place: { lat: number, lng: number }) => number | undefined }} view
* @param {{
@@ -221,25 +245,48 @@ export function createNavaraSurface(view, hooks) {
});
return Number.isFinite(height) ? height : undefined;
};
const sampleHeight = (place, exclude) => {
if (!supported) return undefined;
const terrainDetail = (place) => {
const height = terrainHeight(place);
return height === undefined ? undefined : { height, source: 'terrain' };
};
const sampleHeightDetailed = (place, options) => {
const { exclude, lod } = normalizeSurfaceOptions(options);
if (!supported) return terrainDetail(place);
const ray = surfaceRay(place);
const roots = hooks.tileRoots();
const excluded = exclude ? new Set(exclude) : NO_EXCLUDE;
// The tiles on screen first; where they do not reach (the ground under
// a camera looking ahead is outside the frustum), the hidden ones.
shownMeshes.length = 0;
for (let i = 0; i < roots.length; i++)
collectMeshes(roots[i], excluded, true, shownMeshes);
let distance = nearestHit(shownMeshes, ray);
shownMeshes.length = 0;
if (!Number.isFinite(distance))
distance = finestHiddenHit(roots, excluded, ray);
return heightAlongRay(ray, distance) ?? terrainHeight(place);
const excluded = exclude.length ? new Set(exclude) : NO_EXCLUDE;
let hit = { distance: Infinity, root: null };
if (lod === 'visible') {
// The tiles on screen first; where they do not reach (the ground
// under a camera looking ahead is outside the frustum), the hidden
// ones.
shownMeshes.length = 0;
shownRoots.length = 0;
for (let i = 0; i < roots.length; i++)
collectMeshes(
roots[i],
excluded,
true,
shownMeshes,
shownRoots,
roots[i],
);
hit = nearestHit(shownMeshes, shownRoots, ray);
shownMeshes.length = 0;
shownRoots.length = 0;
}
if (!Number.isFinite(hit.distance))
hit = finestHiddenHit(roots, excluded, ray, lod === 'finest');
const height = heightAlongRay(ray, hit.distance);
if (height === undefined) return terrainDetail(place);
return { height, source: 'tiles', tileSizeM: tileSizeOf(hit.root) };
};
const sampleHeight = (place, exclude) =>
sampleHeightDetailed(place, exclude ? { exclude } : undefined)?.height;
return {
surface: {
sampleHeight,
sampleHeightDetailed,
supported,
terrainHeight,
terrainHeightSupported: true,
+40
View File
@@ -215,3 +215,43 @@ test('among hidden tiles the finest tile wins by its extent, not by a small mesh
'the fine tile',
);
});
test('sampleHeightDetailed names the source and the size of the answering tile', () => {
// A shown coarse tile (600 m wide, 3 m high) over a hidden fine one.
const fine = tileAt(10, 45, 40);
const coarse = tileAt(10, 45, 43, 300);
fine.tile.visible = false;
const { surface } = setup({
tiles: [coarse.tile, fine.tile],
terrain: () => 7,
});
const place = cartographic.fromDegrees(10, 45, 0);
const shown = surface.sampleHeightDetailed(place);
assert.equal(shown.source, 'tiles');
near(shown.height, 43, 0.05, 'the shown tile');
near(shown.tileSizeM, 600 * Math.SQRT2, 1, 'its size');
const finest = surface.sampleHeightDetailed(place, { lod: 'finest' });
near(finest.height, 40, 0.05, 'the finest loaded tile');
near(finest.tileSizeM, 200 * Math.SQRT2, 1, 'its size');
assert.deepEqual(
surface.sampleHeightDetailed(place, { exclude: [coarse.tile, fine.tile] }),
{ height: 7, source: 'terrain' },
);
assert.throws(
() => surface.sampleHeightDetailed(place, { lod: 'best' }),
TypeError,
);
const bare = setup({ tiles: [] }).surface;
assert.equal(bare.sampleHeightDetailed(place), undefined);
});
test('a hidden tile answer carries its size too', () => {
const fine = tileAt(10, 45, 40);
fine.tile.visible = false;
const { surface } = setup({ tiles: [fine.tile] });
const detail = surface.sampleHeightDetailed(
cartographic.fromDegrees(10, 45, 0),
);
near(detail.height, 40, 0.05, 'hidden fine');
near(detail.tileSizeM, 200 * Math.SQRT2, 1, 'size');
});
+41
View File
@@ -0,0 +1,41 @@
// engine.surface.sampleHeightDetailed, the engine-neutral part: its options
// and the quality rule callers apply to an answer. An answer is
// { height, source: 'tiles' | 'terrain', tileSizeM? }: `tileSizeM` is the
// size of the tile that answered (Navara; Cesium does not say), the quality
// signal, since a coarse tile's surface can sit tens of metres off the
// ground (a 20 m error measured at Austin while the tiles were streaming).
const LODS = new Set(['visible', 'finest']);
/**
* @param {{ exclude?: any[], lod?: 'visible' | 'finest' }} [options]
* `exclude`: mover sets (or their ids), lines, or engine objects the ray
* passes through. `lod`: 'visible' answers from the tiles drawn now (where
* they miss, the finest loaded ones); 'finest' from the finest loaded
* tiles, drawn or not.
*/
export function normalizeSurfaceOptions(options = {}) {
const exclude = options.exclude ?? [];
if (!Array.isArray(exclude))
throw new TypeError('surface exclude must be an array');
const lod = options.lod ?? 'visible';
if (!LODS.has(lod))
throw new TypeError(`surface lod must be 'visible' or 'finest': ${lod}`);
return { exclude, lod };
}
/**
* The height to use from a detailed answer, or undefined (a miss): tiles
* larger than `maxTileSizeM` are too coarse, and terrain counts only with
* `terrain: true`.
*/
export function acceptedSurfaceHeight(
detail,
{ maxTileSizeM = Infinity, terrain = false } = {},
) {
if (!detail || !Number.isFinite(detail.height)) return undefined;
if (detail.source === 'terrain') return terrain ? detail.height : undefined;
if (Number.isFinite(detail.tileSizeM) && detail.tileSizeM > maxTileSizeM)
return undefined;
return detail.height;
}
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
acceptedSurfaceHeight,
normalizeSurfaceOptions,
} from './surfaceDetail.js';
test('surface options: exclude list and lod visible (default) or finest', () => {
assert.deepEqual(normalizeSurfaceOptions(), { exclude: [], lod: 'visible' });
const set = { id: 'flights' };
assert.deepEqual(normalizeSurfaceOptions({ exclude: [set], lod: 'finest' }), {
exclude: [set],
lod: 'finest',
});
assert.throws(() => normalizeSurfaceOptions({ lod: 'best' }), TypeError);
assert.throws(() => normalizeSurfaceOptions({ exclude: 'x' }), TypeError);
});
test('a coarse tile answer is a miss; terrain only when asked for', () => {
const fine = { height: 122.5, source: 'tiles', tileSizeM: 180 };
const coarse = { height: 143.2, source: 'tiles', tileSizeM: 5200 };
const cesium = { height: 122.4, source: 'tiles' };
const terrain = { height: 120, source: 'terrain' };
const limit = { maxTileSizeM: 1000 };
assert.equal(acceptedSurfaceHeight(fine, limit), 122.5);
assert.equal(acceptedSurfaceHeight(coarse, limit), undefined);
assert.equal(acceptedSurfaceHeight(coarse), 143.2, 'no limit: kept');
assert.equal(acceptedSurfaceHeight(cesium, limit), 122.4, 'no size: kept');
assert.equal(acceptedSurfaceHeight(terrain, limit), undefined);
assert.equal(acceptedSurfaceHeight(terrain, { terrain: true }), 120);
assert.equal(acceptedSurfaceHeight(undefined), undefined);
assert.equal(
acceptedSurfaceHeight({ height: NaN, source: 'tiles' }),
undefined,
);
});
+64
View File
@@ -1475,6 +1475,70 @@ export function createContractSuite(
);
},
],
[
'surface.sampleHeightDetailed: source, tile size, lod and mover-set exclusion',
async () => {
const place = cartographic.fromDegrees(20, 10, 0);
const shape = (detail, label) => {
if (detail === undefined) return;
ensure(Number.isFinite(detail.height), `${label}: height`);
ensure(
detail.source === 'tiles' || detail.source === 'terrain',
`${label}: source ${detail.source}`,
);
ensure(
detail.tileSizeM === undefined ||
(detail.source === 'tiles' && detail.tileSizeM > 0),
`${label}: tileSizeM ${detail.tileSizeM}`,
);
};
const visible = engine.surface.sampleHeightDetailed(place);
shape(visible, 'visible');
shape(
engine.surface.sampleHeightDetailed(place, { lod: 'finest' }),
'finest',
);
if (visible?.source === 'tiles')
near(
engine.surface.sampleHeight(place),
visible.height,
1e-6,
'sampleHeight and the detailed tiles answer',
);
let rejected = null;
try {
engine.surface.sampleHeightDetailed(place, { lod: 'best' });
} catch (error) {
rejected = error;
}
ensure(rejected instanceof TypeError, 'an unknown lod was accepted');
// A mover set in exclude: the sample works and the set stays.
const set = engine.movers.createSet({
id: 'contract-surface-exclude',
kind: 'point',
capacity: 1,
point: { sizePx: 8 },
});
try {
set.set(0, { position: cartesian3.fromDegrees(20, 10, 50) });
set.setCount(1);
set.commit();
shape(
engine.surface.sampleHeightDetailed(place, { exclude: [set] }),
'excluding a set',
);
ensure(set.id === 'contract-surface-exclude', 'the set has no id');
ensure(
engine.movers
.list()
.some(({ id }) => id === 'contract-surface-exclude'),
'the excluded set is gone',
);
} finally {
set.destroy();
}
},
],
[
'surface: flags, tilesLoaded, sampleHeight, terrainHeight and clampToHeight shapes',
async () => {
+24
View File
@@ -31,6 +31,7 @@ import { createLineRegistry } from '../lines.js';
import { createMoverRegistry } from '../movers.js';
import { createCpuPicker } from '../cpuPicks.js';
import { createFollowController } from '../follow.js';
import { normalizeSurfaceOptions } from '../surfaceDetail.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const WGS84 = ellipsoid.WGS84;
@@ -55,6 +56,7 @@ const SCRIPTABLE = new Set([
'pickEllipsoid',
'viewRectangle',
'sampleHeight',
'sampleHeightDetailed',
'terrainHeight',
'clampToHeight',
'tilesLoaded',
@@ -927,6 +929,28 @@ export function createFakeEngine(options = {}) {
record('surface.sampleHeight', [position, exclude]);
return scripted('sampleHeight', [position, exclude], () => undefined);
},
// Scripted as 'sampleHeightDetailed', else from the scripted
// sampleHeight (tiles, no size) and terrainHeight.
sampleHeightDetailed(position, surfaceOptions) {
const { exclude, lod } = normalizeSurfaceOptions(surfaceOptions);
record('surface.sampleHeightDetailed', [position, { exclude, lod }]);
return scripted(
'sampleHeightDetailed',
[position, { exclude, lod }],
() => {
const height = scripted(
'sampleHeight',
[position, exclude],
() => undefined,
);
if (Number.isFinite(height)) return { height, source: 'tiles' };
const terrain = scripted('terrainHeight', [position], () => 0);
return Number.isFinite(terrain)
? { height: terrain, source: 'terrain' }
: undefined;
},
);
},
supported: true,
terrainHeight(position) {
record('surface.terrainHeight', [position]);
+16 -30
View File
@@ -47,7 +47,9 @@
*/
import * as geo from '../geo/index.js';
import { isGlobeEngine } from '../globe/contract.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
import { MAX_FLOOR_TILE_SIZE_M } from './meshFloorSampler.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
@@ -131,24 +133,6 @@ const SAMPLE_BUDGET_PER_WINDOW = 4;
/** Budget window length (ms). */
const SAMPLE_WINDOW_MS = 250;
/**
* True when the active photoreal tileset has finished streaming the tiles in
* view (cctv.js's projectionTilesReady pattern: first Cesium3DTileset found in
* scene.primitives, duck-typed via its boolean tilesLoaded). Sampling before
* that both blows the frame budget on forced synchronous loads and produces
* misses that would burn retry backoff for nothing. With NO tileset present
* (OSM fallback) this returns true so the sample path is not permanently
* blocked — the sample just misses and backs off.
* @param {Cesium.Viewer} viewer
* @returns {boolean}
*/
function _tilesReady(viewer) {
const prims = viewer?.scene?.primitives;
if (!prims) return false;
const tileset = firstVisibleTileset(prims);
return tileset ? tileset.tilesLoaded === true : true;
}
/**
* The first shown primitive that reports a boolean `tilesLoaded` (a 3D
* tileset, duck-typed), or null.
@@ -282,21 +266,21 @@ export function createGroundSnap({ groundFloor }) {
* A contact that HAS resolved once keeps that answer through a resample outage;
* see `heldSnapM`.
*
* @param {object} target - The globe engine (`engine.surface`: sampleHeight
* and tilesLoaded), or a live Cesium viewer from callers that still hold
* one (scene.sampleHeight and the first visible tileset's tilesLoaded).
* @param {object} target - The globe engine (`engine.surface`:
* sampleHeightDetailed and tilesLoaded), or a viewer whose engine is
* registered (engineFromViewer); without one, no sample.
* @param {string} icao - Cache key (per-layer icao space).
* @param {Cesium.Cartesian3} pos - Current display position. Cache movement
* is measured after projecting this input to the WGS84 ellipsoid, so a
* poll-time altitude/datum change cannot invalidate a stationary snap.
* @param {(() => Array<unknown>)|undefined} getExclusions - Lazily builds the
* scene.sampleHeight objectsToExclude list (own billboards/models/entities —
* the vertical pick ray at a plane's OWN lat/lon lands on its icon/model
* otherwise). Only invoked when a sample actually fires.
* surface `exclude` list (the layer's mover sets, or its own Cesium
* billboards/models/entities — the vertical ray at a plane's OWN lat/lon
* lands on its icon/model otherwise). Only invoked when a sample fires.
* @returns {number|null}
*/
function heightFor(target, icao, pos, getExclusions) {
const engine = isGlobeEngine(target) ? target : null;
const engine = engineFromViewer(target);
const surfacePos = geo.ellipsoid.WGS84.scaleToGeodeticSurface(
pos,
scratchSurfacePos,
@@ -348,7 +332,7 @@ export function createGroundSnap({ groundFloor }) {
entries.set(icao, next);
return heldSnapM(next, surfacePos);
};
if (!(engine ? engine.surface.tilesLoaded() : _tilesReady(target)))
if (!engine || engine.isDestroyed?.() || !engine.surface.tilesLoaded())
return miss();
windowCount += 1;
let sampled;
@@ -360,9 +344,11 @@ export function createGroundSnap({ groundFloor }) {
);
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
const exclusions = getExclusions ? getExclusions() : undefined;
sampled = engine
? engine.surface.sampleHeight(carto, exclusions)
: target.scene.sampleHeight(carto, exclusions);
// A coarse tile's answer, or only terrain, is a miss.
sampled = acceptedSurfaceHeight(
engine.surface.sampleHeightDetailed(carto, { exclude: exclusions }),
{ maxTileSizeM: MAX_FLOOR_TILE_SIZE_M },
);
} catch {
sampled = undefined;
}
+36 -5
View File
@@ -23,9 +23,9 @@ test('on a globe engine a snap samples engine.surface once and caches it', () =>
};
assert.equal(snap.heightFor(engine, 'a1', KAUS, exclude), 138.5);
assert.equal(snap.heightFor(engine, 'a1', KAUS, exclude), 138.5);
const calls = engine.fake.callsOf('surface.sampleHeight');
const calls = engine.fake.callsOf('surface.sampleHeightDetailed');
assert.equal(calls.length, 1, 'one sample, then the cache');
assert.deepEqual(calls[0].args[1], ['own-model']);
assert.deepEqual(calls[0].args[1].exclude, ['own-model']);
assert.equal(exclusions, 1);
assert.ok(
Math.abs(geo.math.toDegrees(calls[0].args[0].latitude) - 30.19) < 1e-9,
@@ -39,15 +39,46 @@ test('on a globe engine loading tiles or a miss return null without a sample cac
engine.fake.script('tilesLoaded', false);
engine.fake.script('sampleHeight', () => 10);
assert.equal(snap.heightFor(engine, 'b1', KAUS), null);
assert.equal(engine.fake.callsOf('surface.sampleHeight').length, 0);
assert.equal(engine.fake.callsOf('surface.sampleHeightDetailed').length, 0);
engine.fake.script('tilesLoaded', true);
engine.fake.script('sampleHeight', () => undefined);
assert.equal(snap.heightFor(engine, 'c1', KAUS), null);
assert.equal(engine.fake.callsOf('surface.sampleHeight').length, 1);
assert.equal(engine.fake.callsOf('surface.sampleHeightDetailed').length, 1);
assert.equal(
snap.heightFor(engine, 'c1', KAUS),
null,
'backoff after a miss',
);
assert.equal(engine.fake.callsOf('surface.sampleHeight').length, 1);
assert.equal(engine.fake.callsOf('surface.sampleHeightDetailed').length, 1);
});
test('a coarse tile answer or only terrain is a miss; a viewer reaches its engine', async () => {
const { registerViewerEngine } =
await import('../globe/cesium/engineFromViewer.js');
const snap = createGroundSnap({ groundFloor: groundFloor() });
const engine = createFakeEngine();
engine.fake.script('sampleHeightDetailed', () => ({
height: 143.2,
source: 'tiles',
tileSizeM: 27_914,
}));
assert.equal(snap.heightFor(engine, 'd1', KAUS), null, 'coarse');
engine.fake.script('sampleHeightDetailed', () => ({
height: 121,
source: 'terrain',
}));
assert.equal(snap.heightFor(engine, 'd2', KAUS), null, 'terrain');
engine.fake.script('sampleHeightDetailed', () => ({
height: 122.5,
source: 'tiles',
tileSizeM: 1_797,
}));
const viewer = {};
const unregister = registerViewerEngine(viewer, engine);
try {
assert.equal(snap.heightFor(viewer, 'd3', KAUS), 122.5);
} finally {
unregister();
}
assert.equal(snap.heightFor({}, 'd4', KAUS), null, 'no engine: no sample');
});
+34 -61
View File
@@ -1,6 +1,12 @@
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { isGlobeEngine } from '../globe/contract.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
/** Answers from tiles larger than this are too coarse to latch (m): on
* Navara at Austin a 1.8 km tile read the ground (122.5 m) and the 28 km
* tile shown while streaming read 143.2 m. Cesium does not size its
* answers, so its gates are the camera height and tilesLoaded below. */
export const MAX_FLOOR_TILE_SIZE_M = 4000;
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
export function createMeshFloorSampler({
@@ -50,8 +56,6 @@ export function createMeshFloorSampler({
* streamed LOD under a high camera is coarse everywhere, so every probe
* would latch junk. */
const MAX_CAMERA_HEIGHT_M = 25_000;
/** @type {Cesium.Cartographic} Scratch for probe coordinates. */
const _scratchProbe = new Cesium.Cartographic();
const _scratchEngineProbe = geo.cartographic.create();
// Regime tracking: mesh cells only apply while the photoreal (google-3d)
@@ -60,28 +64,6 @@ export function createMeshFloorSampler({
// preference. Module-scope listener: the module only loads in the browser
// bundle, and the subscription is idempotent for the app's lifetime.
/**
* True when a VISIBLE 3D tileset in the scene reports its streaming queue
* drained (tilesLoaded) — the mirror of cctv.js's projectionTilesReady.
* Walks top-level primitives only (a handful; once per poll).
* @param {Cesium.Scene} scene
* @returns {boolean}
*/
function _visibleTilesetLoaded(scene) {
try {
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p instanceof Cesium.Cesium3DTileset && p.show) {
return !!p.tilesLoaded;
}
}
} catch {
/* mid-teardown */
}
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
@@ -99,13 +81,13 @@ export function createMeshFloorSampler({
* sampleHeight is a CPU-side query against loaded tiles.
*
* @param {object|undefined} target - The globe engine (engine.surface,
* camera.height, maps.photorealActive), or the Cesium scene from callers
* that still hold one (skipped when absent/torn down).
* camera.height, maps.photorealActive), or a viewer or scene whose
* engine is registered (engineFromViewer); without one, nothing.
* @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords.
* @param {object} [options]
* @param {Array<object>} [options.excludeObjects] - Own billboards/models to
* exclude from the probe (Cesium matches instances/.primitive/.id — pass
* Billboard and Model instances, NOT collections).
* @param {Array<object>} [options.excludeObjects] - What the probe passes
* through: the layer's engine mover sets, or (Cesium layers) its own
* Billboard and Model instances.
* @param {number} [options.viewerLat] @param {number} [options.viewerLon]
* Viewer subpoint (computed once by the caller's poll).
*/
@@ -115,25 +97,17 @@ export function createMeshFloorSampler({
{ excludeObjects = [], viewerLat, viewerLon } = {},
) {
if (!meshFloorPreferred()) return;
const engine = isGlobeEngine(target) ? target : null;
const scene = engine ? null : target;
if (engine ? !engine.surface.supported : !scene) return;
if (!engine && typeof scene.sampleHeight !== 'function') return;
const engine = engineFromViewer(target);
if (!engine || engine.isDestroyed?.() || !engine.surface.supported) 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
// numbers, wildly wrong — and the one-shot latch made them permanent.
// Only sample when the visible Google tileset reports tilesLoaded AND the
// camera is low enough that the streamed LOD near it is fine-grained.
const camH = engine
? engine.camera.height()
: scene.camera?.positionCartographic?.height;
const camH = engine.camera.height();
if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return;
if (
!(engine
? engine.maps.photorealActive() && engine.surface.tilesLoaded()
: _visibleTilesetLoaded(scene))
)
if (!(engine.maps.photorealActive() && engine.surface.tilesLoaded()))
return;
let sampled = 0;
const attempted = new Set();
@@ -154,23 +128,19 @@ export function createMeshFloorSampler({
}
let height;
try {
if (engine) {
const carto = geo.cartographic.fromDegrees(
cell.lon,
cell.lat,
0,
_scratchEngineProbe,
);
height = engine.surface.sampleHeight(carto, excludeObjects);
} else {
const carto = Cesium.Cartographic.fromDegrees(
cell.lon,
cell.lat,
0,
_scratchProbe,
);
height = scene.sampleHeight(carto, excludeObjects);
}
const carto = geo.cartographic.fromDegrees(
cell.lon,
cell.lat,
0,
_scratchEngineProbe,
);
// A coarse tile's answer, or only terrain, is a miss (no latch).
height = acceptedSurfaceHeight(
engine.surface.sampleHeightDetailed(carto, {
exclude: excludeObjects,
}),
{ maxTileSizeM: MAX_FLOOR_TILE_SIZE_M },
);
sampled += 1;
} catch {
continue; // scene mid-teardown — try again next poll
@@ -223,6 +193,9 @@ export function createMeshFloorSampler({
return {
sampleMeshFloorCells,
cachedGroundFloor,
visibleTilesetLoaded: _visibleTilesetLoaded,
/** Whether the engine of `target` (a scene or viewer) has its tiles
* loaded (street level's mesh sampler gate). */
visibleTilesetLoaded: (target) =>
engineFromViewer(target)?.surface.tilesLoaded() ?? false,
};
}