From 645f0424505c6fb1286db31f3228f427c62c2e85 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 20:51:29 +0400 Subject: [PATCH] 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. --- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 9 +- src/data/meshFloorSampler.test.mjs | 188 +++++++++++++------------ src/data/trackedModelRegime.test.mjs | 16 ++- src/globe/cesium/createCesiumEngine.js | 5 +- src/globe/cesium/engineFromViewer.js | 18 ++- src/globe/cesium/movers.js | 6 + src/globe/cesium/surface.js | 83 ++++++++--- src/globe/cesium/surface.test.mjs | 41 ++++++ src/globe/contract.js | 13 ++ src/globe/movers.js | 5 +- src/globe/navara/surface.js | 103 ++++++++++---- src/globe/navara/surface.test.mjs | 40 ++++++ src/globe/surfaceDetail.js | 41 ++++++ src/globe/surfaceDetail.test.mjs | 36 +++++ src/globe/testing/contractSuite.mjs | 64 +++++++++ src/globe/testing/fakeEngine.js | 24 ++++ src/services/groundSnap.js | 46 +++--- src/services/groundSnap.test.mjs | 41 +++++- src/services/meshFloorSampler.js | 95 +++++-------- 20 files changed, 635 insertions(+), 244 deletions(-) create mode 100644 src/globe/surfaceDetail.js create mode 100644 src/globe/surfaceDetail.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1fc801cff..7d7851f0f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a4ec90a5d..ebd865555 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs index 4b9c649dd..f518dac42 100644 --- a/src/data/meshFloorSampler.test.mjs +++ b/src/data/meshFloorSampler.test.mjs @@ -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); }); diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 1b02df1d5..32b3cfaf6 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -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 diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 4e5bce215..ae27c56fd 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -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, { diff --git a/src/globe/cesium/engineFromViewer.js b/src/globe/cesium/engineFromViewer.js index 98825e4e6..c6ffa8640 100644 --- a/src/globe/cesium/engineFromViewer.js +++ b/src/globe/cesium/engineFromViewer.js @@ -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. */ diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index dcd382a15..8e56b5d67 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -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; diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js index b44added3..219699f84 100644 --- a/src/globe/cesium/surface.js +++ b/src/globe/cesium/surface.js @@ -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; } diff --git a/src/globe/cesium/surface.test.mjs b/src/globe/cesium/surface.test.mjs index 2ff89be20..91df264c4 100644 --- a/src/globe/cesium/surface.test.mjs +++ b/src/globe/cesium/surface.test.mjs @@ -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); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index c915afd8b..24bbfe709 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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, diff --git a/src/globe/movers.js b/src/globe/movers.js index 468276326..2fc65e009 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -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); diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index 67fb3bcbc..fa2fa54d7 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -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, diff --git a/src/globe/navara/surface.test.mjs b/src/globe/navara/surface.test.mjs index ffefb80a9..8b40f4f0c 100644 --- a/src/globe/navara/surface.test.mjs +++ b/src/globe/navara/surface.test.mjs @@ -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'); +}); diff --git a/src/globe/surfaceDetail.js b/src/globe/surfaceDetail.js new file mode 100644 index 000000000..5901e02d9 --- /dev/null +++ b/src/globe/surfaceDetail.js @@ -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; +} diff --git a/src/globe/surfaceDetail.test.mjs b/src/globe/surfaceDetail.test.mjs new file mode 100644 index 000000000..02924af79 --- /dev/null +++ b/src/globe/surfaceDetail.test.mjs @@ -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, + ); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 744597719..f595669d9 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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 () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index d21b54d48..8477bf438 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -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]); diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index be3e9c706..45c4461c6 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -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)|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; } diff --git a/src/services/groundSnap.test.mjs b/src/services/groundSnap.test.mjs index e8fbfea77..2cff50c76 100644 --- a/src/services/groundSnap.test.mjs +++ b/src/services/groundSnap.test.mjs @@ -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'); }); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index 9d62891a9..92c76f783 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -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} [options.excludeObjects] - Own billboards/models to - * exclude from the probe (Cesium matches instances/.primitive/.id — pass - * Billboard and Model instances, NOT collections). + * @param {Array} [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, }; }