mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
f640890cf6
commit
645f042450
20 files changed
+635
-244
No files matched your search
@@ -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"
|
||||
]
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user