mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor: own application request operations and surface caches
This commit is contained in:
1 parent
310809e8f1
commit
ac977bd8bc
53 files changed
+4388
-3317
No files matched your search
@@ -91,6 +91,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice.
|
||||||
|
|
||||||
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
|
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
|
||||||
|
|
||||||
- Let application data and controls receive the same explicit layer catalog; keep standalone defaults and registration-before-restoration ordering.
|
- Let application data and controls receive the same explicit layer catalog; keep standalone defaults and registration-before-restoration ordering.
|
||||||
|
|||||||
@@ -430,3 +430,21 @@ exports. Browser regression probes use the registered instance's testing surface
|
|||||||
to avoid accidentally inspecting an unused compatibility instance. Existing source
|
to avoid accidentally inspecting an unused compatibility instance. Existing source
|
||||||
setters apply only to compatibility instances; the normal application supplies
|
setters apply only to compatibility instances; the normal application supplies
|
||||||
its sources at construction.
|
its sources at construction.
|
||||||
|
|
||||||
|
### Application operations
|
||||||
|
|
||||||
|
`application/operations` accepts request-service instances and an application
|
||||||
|
lifetime. It constructs terrain resolution, coarse floor/mesh caches and an
|
||||||
|
annotation resolver without selecting upstream providers. Its checked graph is
|
||||||
|
separate from standalone setup. Scene construction returns these operations; the
|
||||||
|
catalog and controls use the same surface owner. Layers still own their individual
|
||||||
|
ground-snap caches and model resources.
|
||||||
|
|
||||||
|
Terrain cancellation rejects late replies before caching, clears floor queues and
|
||||||
|
removes the map-stack listener. Annotation lookup caches are instance-owned and
|
||||||
|
cleared on cancellation. Geometry selection and floor policies are unchanged.
|
||||||
|
HUD and weather controllers accept their respective service; regional lookup and
|
||||||
|
location framing use the supplied operations. Voice shares the same boundary and
|
||||||
|
floor services, with analyst memory scoped to the runner. Direct compatibility
|
||||||
|
entrypoints retain default services; normal assembly does not configure their
|
||||||
|
source slots.
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Application scene setup owns request services, terrain/floor caches and annotation
|
||||||
|
lookup state. Controls, layers and voice share those owners, and cancellation
|
||||||
|
clears caches and removes map-stack listeners. Standalone setup supplies the
|
||||||
|
existing service clients. HUD summaries, regional brief, cockpit weather, location
|
||||||
|
framing and annotation boundaries receive their service instances explicitly.
|
||||||
|
Compatibility entrypoints retain defaults for direct callers; normal startup does
|
||||||
|
not change global service slots. Analyst follow-up memory belongs to its voice runner.
|
||||||
|
|
||||||
|
|
||||||
Application startup constructs fresh layer instances from explicit source objects.
|
Application startup constructs fresh layer instances from explicit source objects.
|
||||||
Standalone composition selects the existing providers. Controls, launch orbit
|
Standalone composition selects the existing providers. Controls, launch orbit
|
||||||
lookups, Contacts voice answers and ISS pass queries use the registered catalog.
|
lookups, Contacts voice answers and ISS pass queries use the registered catalog.
|
||||||
|
|||||||
+2
-1
@@ -194,6 +194,7 @@
|
|||||||
},
|
},
|
||||||
"./application/catalog": "./src/app/catalog.js",
|
"./application/catalog": "./src/app/catalog.js",
|
||||||
"./standalone/catalog": "./src/standalone/catalog.js",
|
"./standalone/catalog": "./src/standalone/catalog.js",
|
||||||
"./application/layers": "./src/app/constructCatalog.js"
|
"./application/layers": "./src/app/constructCatalog.js",
|
||||||
|
"./application/operations": "./src/app/operations.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,5 +577,14 @@
|
|||||||
"src/app/layers/aisLiveVessels.js",
|
"src/app/layers/aisLiveVessels.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
"src/standalone/layerSources.js",
|
"src/standalone/layerSources.js",
|
||||||
"src/app/constructCatalog.test.mjs"
|
"src/app/constructCatalog.test.mjs",
|
||||||
|
"src/app/surfaceServices.js",
|
||||||
|
"src/app/operations.js",
|
||||||
|
"src/data/surfaceServices.js",
|
||||||
|
"src/annotations/resolver.js",
|
||||||
|
"src/services/terrainHeights.js",
|
||||||
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/meshFloorSampler.js",
|
||||||
|
"src/services/groundSnap.js",
|
||||||
|
"src/app/operations.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -1521,7 +1521,15 @@
|
|||||||
"src/app/layers/overlayHost.js",
|
"src/app/layers/overlayHost.js",
|
||||||
"src/app/layers/aisLiveVessels.js",
|
"src/app/layers/aisLiveVessels.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
"src/standalone/layerSources.js"
|
"src/standalone/layerSources.js",
|
||||||
|
"src/app/surfaceServices.js",
|
||||||
|
"src/app/operations.js",
|
||||||
|
"src/data/surfaceServices.js",
|
||||||
|
"src/annotations/resolver.js",
|
||||||
|
"src/services/terrainHeights.js",
|
||||||
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/meshFloorSampler.js",
|
||||||
|
"src/services/groundSnap.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
@@ -1541,6 +1549,7 @@
|
|||||||
"exports": ["./application/layers"],
|
"exports": ["./application/layers"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/annotations/annotationResolver.js",
|
"src/annotations/annotationResolver.js",
|
||||||
|
"src/annotations/resolver.js",
|
||||||
"src/app/catalog.js",
|
"src/app/catalog.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
"src/app/layers/aisLiveVessels.js",
|
"src/app/layers/aisLiveVessels.js",
|
||||||
@@ -1581,14 +1590,11 @@
|
|||||||
"src/data/detectionCohort.js",
|
"src/data/detectionCohort.js",
|
||||||
"src/data/detectionDraw.js",
|
"src/data/detectionDraw.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/fireAnchors.js",
|
|
||||||
"src/data/firmsAdapt.js",
|
"src/data/firmsAdapt.js",
|
||||||
"src/data/firmsLabels.js",
|
"src/data/firmsLabels.js",
|
||||||
"src/data/flowMatch.js",
|
"src/data/flowMatch.js",
|
||||||
"src/data/focusDeemphasis.js",
|
"src/data/focusDeemphasis.js",
|
||||||
"src/data/geoid.js",
|
"src/data/geoid.js",
|
||||||
"src/data/groundFloor.js",
|
|
||||||
"src/data/groundSnap.js",
|
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/infrastructure.js",
|
"src/data/infrastructure.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
@@ -1601,7 +1607,6 @@
|
|||||||
"src/data/local_data/natural_earth/marine.json",
|
"src/data/local_data/natural_earth/marine.json",
|
||||||
"src/data/local_data/natural_earth/regions.json",
|
"src/data/local_data/natural_earth/regions.json",
|
||||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||||
"src/data/meshFloorSampler.js",
|
|
||||||
"src/data/militaryAwarenessEngine.js",
|
"src/data/militaryAwarenessEngine.js",
|
||||||
"src/data/militaryInstallationData.js",
|
"src/data/militaryInstallationData.js",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -1616,7 +1621,6 @@
|
|||||||
"src/data/satelliteClass.js",
|
"src/data/satelliteClass.js",
|
||||||
"src/data/scenePick.js",
|
"src/data/scenePick.js",
|
||||||
"src/data/spriteOrder.js",
|
"src/data/spriteOrder.js",
|
||||||
"src/data/terrainHeights.js",
|
|
||||||
"src/data/tomtomTiles.js",
|
"src/data/tomtomTiles.js",
|
||||||
"src/data/tr3bRegistry.js",
|
"src/data/tr3bRegistry.js",
|
||||||
"src/data/trackedCamera.js",
|
"src/data/trackedCamera.js",
|
||||||
@@ -1859,5 +1863,46 @@
|
|||||||
"pbf",
|
"pbf",
|
||||||
"mgrs"
|
"mgrs"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"application-operations": {
|
||||||
|
"exports": ["./application/operations"],
|
||||||
|
"modules": [
|
||||||
|
"src/annotations/annotationResolver.js",
|
||||||
|
"src/annotations/resolver.js",
|
||||||
|
"src/app/operations.js",
|
||||||
|
"src/app/sourceSlot.js",
|
||||||
|
"src/app/surfaceServices.js",
|
||||||
|
"src/data/dataCredits.js",
|
||||||
|
"src/data/geoid.js",
|
||||||
|
"src/data/local_data/natural_earth/marine.json",
|
||||||
|
"src/data/local_data/natural_earth/regions.json",
|
||||||
|
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||||
|
"src/data/naturalEarthRegions.js",
|
||||||
|
"src/data/neighborhoodPolygons.js",
|
||||||
|
"src/data/retryableLoad.js",
|
||||||
|
"src/data/scenePick.js",
|
||||||
|
"src/keylessGeocoder.js",
|
||||||
|
"src/layers/firms/anchors.js",
|
||||||
|
"src/locations.js",
|
||||||
|
"src/search/defaults.js",
|
||||||
|
"src/search/geospatial.js",
|
||||||
|
"src/search/google.js",
|
||||||
|
"src/search/http.js",
|
||||||
|
"src/search/placeSearch.js",
|
||||||
|
"src/services/application.js",
|
||||||
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/groundSnap.js",
|
||||||
|
"src/services/meshFloorSampler.js",
|
||||||
|
"src/services/requests.js",
|
||||||
|
"src/services/terrainHeights.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"cesium",
|
||||||
|
"egm96-universal",
|
||||||
|
"satellite.js",
|
||||||
|
"@mapbox/vector-tile",
|
||||||
|
"pbf",
|
||||||
|
"mgrs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2986,17 +2986,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
return window.__dfCountModels(icao);
|
return window.__dfCountModels(icao);
|
||||||
};
|
};
|
||||||
// Vite serves an edited source file as `…/groundFloor.js?t=<hmr stamp>`;
|
// Read the application's surface owner, then prove it is the one
|
||||||
// importing the PLAIN path then hands back a second, unrelated module
|
// used by the actual poll/render path with the unchanged floor seed.
|
||||||
// instance whose cells the app never reads. Offer the URL the app itself
|
window.__dfCandidates = window.__godsEyeView.surfaceServices?.groundFloor ? ['application surface'] : [];
|
||||||
// loaded first, then the plain path (clean, never-hot-reloaded server).
|
|
||||||
const seen = performance.getEntriesByType('resource')
|
|
||||||
.map((e) => e.name)
|
|
||||||
.filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n));
|
|
||||||
window.__dfCandidates = [...new Set([...seen.reverse(), `${window.__gevQaSourceBase || '/src'}/data/groundFloor.js`])];
|
|
||||||
return { candidates: window.__dfCandidates.length };
|
return { candidates: window.__dfCandidates.length };
|
||||||
});
|
});
|
||||||
record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0,
|
record('display-floor: ground-floor service owner found', dfSetup.candidates > 0,
|
||||||
JSON.stringify(dfSetup));
|
JSON.stringify(dfSetup));
|
||||||
|
|
||||||
// Identity probe. Seed a contact's FIX cell BEFORE its first fix arrives,
|
// Identity probe. Seed a contact's FIX cell BEFORE its first fix arrives,
|
||||||
@@ -3012,8 +3007,7 @@ async function main() {
|
|||||||
const tried = [];
|
const tried = [];
|
||||||
for (let i = 0; i < window.__dfCandidates.length; i++) {
|
for (let i = 0; i < window.__dfCandidates.length; i++) {
|
||||||
const url = window.__dfCandidates[i];
|
const url = window.__dfCandidates[i];
|
||||||
let gf;
|
const gf = window.__godsEyeView.surfaceServices.groundFloor;
|
||||||
try { gf = await import(/* @vite-ignore */ url); } catch { tried.push({ url, h: null }); continue; }
|
|
||||||
if (typeof gf.reportMeshFloorCell !== 'function') { tried.push({ url, h: null }); continue; }
|
if (typeof gf.reportMeshFloorCell !== 'function') { tried.push({ url, h: null }); continue; }
|
||||||
gf.setMeshFloorPreferred(true);
|
gf.setMeshFloorPreferred(true);
|
||||||
gf._clearMeshFloorCellsForTest();
|
gf._clearMeshFloorCellsForTest();
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -10,13 +10,13 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
|||||||
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
||||||
* and voice tool wiring are identical to the other two branches.
|
* and voice tool wiring are identical to the other two branches.
|
||||||
*/
|
*/
|
||||||
export function initAnnotations({ viewer, tileset = null, placeSearch }) {
|
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
|
||||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||||
if (tileset) {
|
if (tileset) {
|
||||||
try { tileset.enableCollision = true; } catch { /* older tileset */ }
|
try { tileset.enableCollision = true; } catch { /* older tileset */ }
|
||||||
}
|
}
|
||||||
const renderer = createHybridAnnotationRenderer(viewer);
|
const renderer = createHybridAnnotationRenderer(viewer);
|
||||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch });
|
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
|
||||||
window.__gevAnnotations = engine;
|
window.__gevAnnotations = engine;
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -47,6 +47,7 @@ const SOURCE_METHODS = Object.freeze({
|
|||||||
* The manager owns layer destruction, while abort releases classification even if startup fails.
|
* The manager owns layer destruction, while abort releases classification even if startup fails.
|
||||||
*/
|
*/
|
||||||
export function createApplicationCatalog({
|
export function createApplicationCatalog({
|
||||||
|
surface,
|
||||||
sources,
|
sources,
|
||||||
signal,
|
signal,
|
||||||
metadata = LAYER_STATE_REGISTRY,
|
metadata = LAYER_STATE_REGISTRY,
|
||||||
@@ -56,6 +57,9 @@ export function createApplicationCatalog({
|
|||||||
if (!signal?.addEventListener)
|
if (!signal?.addEventListener)
|
||||||
throw new TypeError('An application lifetime signal is required');
|
throw new TypeError('An application lifetime signal is required');
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
|
if (!surface?.groundFloor || !surface?.terrain)
|
||||||
|
throw new TypeError('Application surface services are required');
|
||||||
|
|
||||||
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
||||||
if (
|
if (
|
||||||
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
||||||
@@ -71,11 +75,13 @@ export function createApplicationCatalog({
|
|||||||
try {
|
try {
|
||||||
militaryRegistry.configureSource(sources.military, { signal });
|
militaryRegistry.configureSource(sources.military, { signal });
|
||||||
const flights = createApplicationFlights({
|
const flights = createApplicationFlights({
|
||||||
|
surface,
|
||||||
source: sources.flights,
|
source: sources.flights,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
});
|
});
|
||||||
const military = createApplicationMilitary({
|
const military = createApplicationMilitary({
|
||||||
|
surface,
|
||||||
source: sources.military,
|
source: sources.military,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
@@ -85,6 +91,7 @@ export function createApplicationCatalog({
|
|||||||
options: vesselOptions,
|
options: vesselOptions,
|
||||||
});
|
});
|
||||||
const installations = createApplicationInstallations({
|
const installations = createApplicationInstallations({
|
||||||
|
surface,
|
||||||
source: sources.installations,
|
source: sources.installations,
|
||||||
});
|
});
|
||||||
const satellites = createApplicationSatellites({
|
const satellites = createApplicationSatellites({
|
||||||
@@ -95,12 +102,12 @@ export function createApplicationCatalog({
|
|||||||
flights,
|
flights,
|
||||||
military,
|
military,
|
||||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||||
createApplicationAlpr({ source: sources.alpr }),
|
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||||
satellites,
|
satellites,
|
||||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||||
createApplicationTraffic({ source: sources.traffic }),
|
createApplicationTraffic({ source: sources.traffic }),
|
||||||
createApplicationCctv({ source: sources.cctv }),
|
createApplicationCctv({ surface, source: sources.cctv }),
|
||||||
createApplicationRadio({ source: sources.radio }),
|
createApplicationRadio({ surface, source: sources.radio }),
|
||||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||||
vessels,
|
vessels,
|
||||||
installations,
|
installations,
|
||||||
@@ -113,6 +120,7 @@ export function createApplicationCatalog({
|
|||||||
...createInfrastructureLayers(localGeoJsonServices),
|
...createInfrastructureLayers(localGeoJsonServices),
|
||||||
createApplicationCables({ source: sources.cables }),
|
createApplicationCables({ source: sources.cables }),
|
||||||
createApplicationFirms({
|
createApplicationFirms({
|
||||||
|
surface,
|
||||||
id: 'local-firms',
|
id: 'local-firms',
|
||||||
name: 'FIRMS Active Fires',
|
name: 'FIRMS Active Fires',
|
||||||
icon: '▲',
|
icon: '▲',
|
||||||
@@ -122,7 +130,7 @@ export function createApplicationCatalog({
|
|||||||
],
|
],
|
||||||
metadata,
|
metadata,
|
||||||
);
|
);
|
||||||
return Object.freeze({ ...catalog, militaryRegistry });
|
return Object.freeze({ ...catalog, militaryRegistry, surface });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispose();
|
dispose();
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createSurfaceServices } from './surfaceServices.js';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createApplicationCatalog } from './constructCatalog.js';
|
import { createApplicationCatalog } from './constructCatalog.js';
|
||||||
@@ -30,10 +31,12 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
const first = createApplicationCatalog({
|
const first = createApplicationCatalog({
|
||||||
sources: fixtureSources(['abc123'], callsA),
|
sources: fixtureSources(['abc123'], callsA),
|
||||||
signal: a.signal,
|
signal: a.signal,
|
||||||
|
surface: fixtureSurface(a.signal),
|
||||||
});
|
});
|
||||||
const second = createApplicationCatalog({
|
const second = createApplicationCatalog({
|
||||||
sources: fixtureSources(['def456'], callsB),
|
sources: fixtureSources(['def456'], callsB),
|
||||||
signal: b.signal,
|
signal: b.signal,
|
||||||
|
surface: fixtureSurface(b.signal),
|
||||||
});
|
});
|
||||||
assert.equal(first.layers.length, 17);
|
assert.equal(first.layers.length, 17);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -62,7 +65,12 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
test('invalid or already cancelled construction fails before classification can acquire', () => {
|
test('invalid or already cancelled construction fails before classification can acquire', () => {
|
||||||
const lifetime = new AbortController();
|
const lifetime = new AbortController();
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createApplicationCatalog({ sources: {}, signal: lifetime.signal }),
|
() =>
|
||||||
|
createApplicationCatalog({
|
||||||
|
sources: {},
|
||||||
|
signal: lifetime.signal,
|
||||||
|
surface: fixtureSurface(lifetime.signal),
|
||||||
|
}),
|
||||||
/catalog source/,
|
/catalog source/,
|
||||||
);
|
);
|
||||||
lifetime.abort();
|
lifetime.abort();
|
||||||
@@ -75,3 +83,11 @@ test('invalid or already cancelled construction fails before classification can
|
|||||||
{ name: 'AbortError' },
|
{ name: 'AbortError' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fixtureSurface(signal) {
|
||||||
|
return createSurfaceServices({
|
||||||
|
terrainSource: { getHeights: async () => [] },
|
||||||
|
signal,
|
||||||
|
eventTarget: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+13
-3
@@ -5,7 +5,7 @@ import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
|||||||
|
|
||||||
/** Construct the existing controls and camera presentation. */
|
/** Construct the existing controls and camera presentation. */
|
||||||
export function createApplicationControls({
|
export function createApplicationControls({
|
||||||
scene: { viewer, mapStackController },
|
scene: { viewer, mapStackController, operations },
|
||||||
loaderStatus,
|
loaderStatus,
|
||||||
Controls = StyleManager,
|
Controls = StyleManager,
|
||||||
services,
|
services,
|
||||||
@@ -15,7 +15,15 @@ export function createApplicationControls({
|
|||||||
}) {
|
}) {
|
||||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||||
const styleManager = new Controls(viewer, {
|
const styleManager = new Controls(viewer, {
|
||||||
services: { ...services, ...catalogControlServices(catalog) },
|
services: {
|
||||||
|
...services,
|
||||||
|
...operations.surface.controlServices,
|
||||||
|
searchAndFlyTo: operations.searchAndFlyTo,
|
||||||
|
fetchRegionalBrief: (...args) =>
|
||||||
|
operations.requests.regional.getBrief(...args),
|
||||||
|
...catalogControlServices(catalog),
|
||||||
|
},
|
||||||
|
requestServices: operations.requests,
|
||||||
mapStackController,
|
mapStackController,
|
||||||
placeSearch,
|
placeSearch,
|
||||||
});
|
});
|
||||||
@@ -26,7 +34,9 @@ export function createApplicationControls({
|
|||||||
// clouds use a separate, capped low-resolution GPU pass that never attaches
|
// clouds use a separate, capped low-resolution GPU pass that never attaches
|
||||||
// Cesium fog or post-process stages and is fully stopped in map mode.
|
// Cesium fog or post-process stages and is fully stopped in map mode.
|
||||||
const weatherEffects = null;
|
const weatherEffects = null;
|
||||||
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
|
const cockpitCloudEffects = initCockpitCloudEffects(viewer, {
|
||||||
|
weatherService: operations.requests.weather,
|
||||||
|
});
|
||||||
defer(() => cockpitCloudEffects?.destroy());
|
defer(() => cockpitCloudEffects?.destroy());
|
||||||
|
|
||||||
// If no share link state, do default fly-to Austin
|
// If no share link state, do default fly-to Austin
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
|||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as context from '../../data/contextStore.js';
|
import * as context from '../../data/contextStore.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as groundFloor from '../../data/groundFloor.js';
|
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationAlpr({ source }) {
|
export function createApplicationAlpr({ surface, source }) {
|
||||||
|
const { groundFloor } = surface;
|
||||||
return createAlprCamerasLayer({
|
return createAlprCamerasLayer({
|
||||||
source,
|
source,
|
||||||
services: { render, context, picking, groundFloor },
|
services: { render, context, picking, groundFloor },
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import * as activation from '../../cctvFocusRequest.js';
|
|||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import * as locations from '../../locations.js';
|
import * as locations from '../../locations.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as terrain from '../../data/terrainHeights.js';
|
|
||||||
import * as ground from '../../data/groundFloor.js';
|
|
||||||
import * as mesh from '../../data/meshFloorSampler.js';
|
|
||||||
import * as focus from '../../data/focusDeemphasis.js';
|
import * as focus from '../../data/focusDeemphasis.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationCctv({ source }) {
|
export function createApplicationCctv({ surface, source }) {
|
||||||
|
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
|
||||||
return createCctvLayer({
|
return createCctvLayer({
|
||||||
source,
|
source,
|
||||||
services: {
|
services: {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as render from '../../renderGovernor.js';
|
|||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
import * as context from '../../data/contextStore.js';
|
import * as context from '../../data/contextStore.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as anchors from '../../data/fireAnchors.js';
|
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import * as focus from '../../worldFocus.js';
|
import * as focus from '../../worldFocus.js';
|
||||||
export const firmsServices = {
|
export const firmsServices = {
|
||||||
@@ -12,19 +11,18 @@ export const firmsServices = {
|
|||||||
sprites,
|
sprites,
|
||||||
context,
|
context,
|
||||||
picking,
|
picking,
|
||||||
anchors,
|
|
||||||
overlays,
|
overlays,
|
||||||
focus,
|
focus,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Bind a supplied fire feed to the application scene service owners. */
|
/** Bind a supplied fire feed to the application scene service owners. */
|
||||||
export function createApplicationFirms(options) {
|
export function createApplicationFirms({ surface, ...options }) {
|
||||||
return createLayer({
|
return createLayer({
|
||||||
...options,
|
...options,
|
||||||
icon: options.icon ?? '▲',
|
icon: options.icon ?? '▲',
|
||||||
source: options.source ?? 'NASA FIRMS',
|
source: options.source ?? 'NASA FIRMS',
|
||||||
feed: options.feed,
|
feed: options.feed,
|
||||||
services: firmsServices,
|
services: { ...firmsServices, anchors: surface.anchors },
|
||||||
overlayHost: options.overlayHost ?? {
|
overlayHost: options.overlayHost ?? {
|
||||||
setEntries: overlays.setOverlayEntries,
|
setEntries: overlays.setOverlayEntries,
|
||||||
setVisible: overlays.setOverlaySourceVisible,
|
setVisible: overlays.setOverlaySourceVisible,
|
||||||
|
|||||||
@@ -5,23 +5,22 @@ import * as trails from '../../data/trailRenderer.js';
|
|||||||
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
||||||
import * as camera from '../../data/trackedCamera.js';
|
import * as camera from '../../data/trackedCamera.js';
|
||||||
import * as labels from '../../data/detectionDraw.js';
|
import * as labels from '../../data/detectionDraw.js';
|
||||||
import * as groundFloor from '../../data/groundFloor.js';
|
|
||||||
import * as meshFloor from '../../data/meshFloorSampler.js';
|
|
||||||
import * as geoid from '../../data/geoid.js';
|
import * as geoid from '../../data/geoid.js';
|
||||||
import * as focus from '../../data/focusDeemphasis.js';
|
import * as focus from '../../data/focusDeemphasis.js';
|
||||||
import * as readout from '../../data/trackedReadout.js';
|
import * as readout from '../../data/trackedReadout.js';
|
||||||
import * as context from '../../data/contextStore.js';
|
import * as context from '../../data/contextStore.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as groundSnap from '../../data/groundSnap.js';
|
|
||||||
import * as recession from '../../data/aircraftRecession.js';
|
import * as recession from '../../data/aircraftRecession.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationFlights({
|
export function createApplicationFlights({
|
||||||
|
surface,
|
||||||
source,
|
source,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
resolveAsset = (url) =>
|
resolveAsset = (url) =>
|
||||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||||
}) {
|
}) {
|
||||||
|
const { groundFloor, meshFloor, groundSnap } = surface;
|
||||||
return createCivilFlightLayer({
|
return createCivilFlightLayer({
|
||||||
source,
|
source,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
|
|||||||
@@ -5,23 +5,22 @@ import * as trails from '../../data/trailRenderer.js';
|
|||||||
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
||||||
import * as camera from '../../data/trackedCamera.js';
|
import * as camera from '../../data/trackedCamera.js';
|
||||||
import * as labels from '../../data/detectionDraw.js';
|
import * as labels from '../../data/detectionDraw.js';
|
||||||
import * as groundFloor from '../../data/groundFloor.js';
|
|
||||||
import * as meshFloor from '../../data/meshFloorSampler.js';
|
|
||||||
import * as geoid from '../../data/geoid.js';
|
import * as geoid from '../../data/geoid.js';
|
||||||
import * as focus from '../../data/focusDeemphasis.js';
|
import * as focus from '../../data/focusDeemphasis.js';
|
||||||
import * as readout from '../../data/trackedReadout.js';
|
import * as readout from '../../data/trackedReadout.js';
|
||||||
import * as context from '../../data/contextStore.js';
|
import * as context from '../../data/contextStore.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as groundSnap from '../../data/groundSnap.js';
|
|
||||||
import * as recession from '../../data/aircraftRecession.js';
|
import * as recession from '../../data/aircraftRecession.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationMilitary({
|
export function createApplicationMilitary({
|
||||||
|
surface,
|
||||||
source,
|
source,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
resolveAsset = (url) =>
|
resolveAsset = (url) =>
|
||||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||||
}) {
|
}) {
|
||||||
|
const { groundFloor, meshFloor, groundSnap } = surface;
|
||||||
return createMilitaryFlightLayer({
|
return createMilitaryFlightLayer({
|
||||||
source,
|
source,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { createInstallationsLayer } from '../../layers/installations/index.js';
|
import { createInstallationsLayer } from '../../layers/installations/index.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as context from '../../data/contextStore.js';
|
import * as context from '../../data/contextStore.js';
|
||||||
import * as ground from '../../data/groundFloor.js';
|
|
||||||
import * as anchors from '../../data/fireAnchors.js';
|
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationInstallations({ source }) {
|
export function createApplicationInstallations({ surface, source }) {
|
||||||
|
const { groundFloor: ground, anchors } = surface;
|
||||||
return createInstallationsLayer({
|
return createInstallationsLayer({
|
||||||
source,
|
source,
|
||||||
services: { render, context, ground, anchors, picking },
|
services: { render, context, ground, anchors, picking },
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createRadioLayer } from '../../layers/radio/index.js';
|
import { createRadioLayer } from '../../layers/radio/index.js';
|
||||||
import * as ground from '../../data/groundFloor.js';
|
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import * as globe from '../../celestialRing.js';
|
import * as globe from '../../celestialRing.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationRadio({ source }) {
|
export function createApplicationRadio({ surface, source }) {
|
||||||
|
const { groundFloor: ground } = surface;
|
||||||
return createRadioLayer({
|
return createRadioLayer({
|
||||||
source,
|
source,
|
||||||
services: { ground, picking, overlays, globe, render },
|
services: { ground, picking, overlays, globe, render },
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createSurfaceServices } from './surfaceServices.js';
|
||||||
|
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||||
|
import { searchAndFlyTo } from '../locations.js';
|
||||||
|
|
||||||
|
/** Assemble application operations from the caller's request services. */
|
||||||
|
export function createApplicationOperations({ requests, signal, eventTarget }) {
|
||||||
|
for (const [name, method] of Object.entries({
|
||||||
|
boundaries: 'query',
|
||||||
|
terrain: 'getHeights',
|
||||||
|
regional: 'getBrief',
|
||||||
|
weather: 'getConditions',
|
||||||
|
summary: 'summarize',
|
||||||
|
})) {
|
||||||
|
if (typeof requests?.[name]?.[method] !== 'function')
|
||||||
|
throw new TypeError(`Missing application request service: ${name}`);
|
||||||
|
}
|
||||||
|
const surface = createSurfaceServices({
|
||||||
|
terrainSource: requests.terrain,
|
||||||
|
signal,
|
||||||
|
eventTarget,
|
||||||
|
});
|
||||||
|
const annotationResolver = createAnnotationResolver({
|
||||||
|
boundarySource: requests.boundaries,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
requests,
|
||||||
|
surface,
|
||||||
|
annotationResolver,
|
||||||
|
searchAndFlyTo: (viewer, query, options = {}) =>
|
||||||
|
searchAndFlyTo(viewer, query, {
|
||||||
|
...options,
|
||||||
|
boundaries: requests.boundaries,
|
||||||
|
recoverNearView: annotationResolver.placesNearViewRecovery,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createApplicationOperations } from './operations.js';
|
||||||
|
|
||||||
|
function requests(terrain, boundaries = { query: async () => [] }) {
|
||||||
|
return {
|
||||||
|
terrain,
|
||||||
|
boundaries,
|
||||||
|
regional: { getBrief: async () => ({}) },
|
||||||
|
weather: { getConditions: async () => ({}) },
|
||||||
|
summary: { summarize: async () => ({}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('surface cache and stack listeners belong to the supplied application lifetime', async (t) => {
|
||||||
|
const a = new AbortController();
|
||||||
|
const b = new AbortController();
|
||||||
|
t.after(() => {
|
||||||
|
a.abort();
|
||||||
|
b.abort();
|
||||||
|
});
|
||||||
|
const target = new EventTarget();
|
||||||
|
let calls = 0;
|
||||||
|
const first = createApplicationOperations({
|
||||||
|
signal: a.signal,
|
||||||
|
eventTarget: target,
|
||||||
|
requests: requests({
|
||||||
|
async getHeights(points) {
|
||||||
|
calls++;
|
||||||
|
return points.map(() => ({ ellipsoid: 123 }));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const second = createApplicationOperations({
|
||||||
|
signal: b.signal,
|
||||||
|
eventTarget: target,
|
||||||
|
requests: requests({
|
||||||
|
async getHeights(points) {
|
||||||
|
return points.map(() => ({ ellipsoid: 456 }));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const point = { lat: 30, lon: -97 };
|
||||||
|
await first.surface.terrain.resolveEllipsoidalGround([point]);
|
||||||
|
await first.surface.terrain.resolveEllipsoidalGround([point]);
|
||||||
|
await second.surface.terrain.resolveEllipsoidalGround([point]);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(first.surface.groundFloor.cachedGroundFloor(30, -97), 123);
|
||||||
|
assert.equal(second.surface.groundFloor.cachedGroundFloor(30, -97), 456);
|
||||||
|
target.dispatchEvent(
|
||||||
|
new CustomEvent('gev:map-stack-changed', {
|
||||||
|
detail: { activeId: 'esri-imagery' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(first.surface.groundFloor.meshFloorPreferred(), false);
|
||||||
|
a.abort();
|
||||||
|
assert.equal(first.surface.terrain.cachedEllipsoidalGround(30, -97), null);
|
||||||
|
target.dispatchEvent(
|
||||||
|
new CustomEvent('gev:map-stack-changed', {
|
||||||
|
detail: { activeId: 'photoreal' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
first.surface.groundFloor.meshFloorPreferred(),
|
||||||
|
false,
|
||||||
|
'disposed listener cannot update the old owner',
|
||||||
|
);
|
||||||
|
assert.equal(second.surface.groundFloor.meshFloorPreferred(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a terrain reply after application abort cannot refill the cache or become a fallback', async () => {
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
let respond;
|
||||||
|
let receivedSignal;
|
||||||
|
const operations = createApplicationOperations({
|
||||||
|
signal: lifetime.signal,
|
||||||
|
eventTarget: null,
|
||||||
|
requests: requests({
|
||||||
|
getHeights(_points, { signal }) {
|
||||||
|
receivedSignal = signal;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
respond = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const pending = operations.surface.terrain.resolveEllipsoidalGround([
|
||||||
|
{ lat: 30, lon: -97 },
|
||||||
|
]);
|
||||||
|
lifetime.abort();
|
||||||
|
respond([{ ellipsoid: 123 }]);
|
||||||
|
await assert.rejects(pending, { name: 'AbortError' });
|
||||||
|
assert.equal(receivedSignal.aborted, true);
|
||||||
|
assert.equal(
|
||||||
|
operations.surface.terrain.cachedEllipsoidalGround(30, -97),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotation lookup and cached outlines use the supplied source independently', async (t) => {
|
||||||
|
const priorWindow = globalThis.window;
|
||||||
|
globalThis.window = { setTimeout, clearTimeout };
|
||||||
|
const a = new AbortController();
|
||||||
|
const b = new AbortController();
|
||||||
|
t.after(() => {
|
||||||
|
a.abort();
|
||||||
|
b.abort();
|
||||||
|
if (priorWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = priorWindow;
|
||||||
|
});
|
||||||
|
const calls = [0, 0];
|
||||||
|
const make = (signal, index) =>
|
||||||
|
createApplicationOperations({
|
||||||
|
signal,
|
||||||
|
eventTarget: null,
|
||||||
|
requests: requests(
|
||||||
|
{ getHeights: async () => [] },
|
||||||
|
{
|
||||||
|
query: async () => {
|
||||||
|
calls[index]++;
|
||||||
|
const delta = index * 0.0001;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'way',
|
||||||
|
id: index + 1,
|
||||||
|
tags: { building: 'yes', name: 'Source fixture hall' },
|
||||||
|
geometry: [
|
||||||
|
{ lat: 30 - 0.0001, lon: -97 - 0.0001 + delta },
|
||||||
|
{ lat: 30 - 0.0001, lon: -97 + 0.0001 + delta },
|
||||||
|
{ lat: 30 + 0.0001, lon: -97 + 0.0001 + delta },
|
||||||
|
{ lat: 30 + 0.0001, lon: -97 - 0.0001 + delta },
|
||||||
|
{ lat: 30 - 0.0001, lon: -97 - 0.0001 + delta },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const first = make(a.signal, 0);
|
||||||
|
const second = make(b.signal, 1);
|
||||||
|
const query = {
|
||||||
|
viewer: {},
|
||||||
|
target: 'Source fixture hall',
|
||||||
|
latitude: 30,
|
||||||
|
longitude: -97,
|
||||||
|
footprint: true,
|
||||||
|
entityKind: 'building',
|
||||||
|
};
|
||||||
|
const resultA = await first.annotationResolver.resolveAnnotationTarget(query);
|
||||||
|
const resultB =
|
||||||
|
await second.annotationResolver.resolveAnnotationTarget(query);
|
||||||
|
assert.ok(resultA.ring?.length >= 4);
|
||||||
|
assert.ok(resultB.ring?.length >= 4);
|
||||||
|
assert.notDeepEqual(resultA.ring, resultB.ring);
|
||||||
|
const before = calls[0];
|
||||||
|
const cached = await first.annotationResolver.resolveAnnotationTarget(query);
|
||||||
|
assert.deepEqual(cached.ring, resultA.ring);
|
||||||
|
assert.equal(calls[0], before);
|
||||||
|
a.abort();
|
||||||
|
await assert.rejects(
|
||||||
|
first.annotationResolver.resolveAnnotationTarget(query),
|
||||||
|
{ name: 'AbortError' },
|
||||||
|
);
|
||||||
|
});
|
||||||
+7
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { createApplicationOperations } from './operations.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createApplicationViewer } from '../app/viewer.js';
|
import { createApplicationViewer } from '../app/viewer.js';
|
||||||
import { registerDataCredits } from '../data/dataCredits.js';
|
import { registerDataCredits } from '../data/dataCredits.js';
|
||||||
@@ -13,6 +14,7 @@ import { describeError } from './errors.js';
|
|||||||
|
|
||||||
/** Construct the application globe using the caller's local configuration. */
|
/** Construct the application globe using the caller's local configuration. */
|
||||||
export async function createApplicationScene({
|
export async function createApplicationScene({
|
||||||
|
requestServices,
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
cesiumToken,
|
cesiumToken,
|
||||||
credits,
|
credits,
|
||||||
@@ -22,6 +24,10 @@ export async function createApplicationScene({
|
|||||||
signal,
|
signal,
|
||||||
defer,
|
defer,
|
||||||
}) {
|
}) {
|
||||||
|
const operations = createApplicationOperations({
|
||||||
|
requests: requestServices,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
defer(initLogoGaze());
|
defer(initLogoGaze());
|
||||||
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
|
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
|
||||||
if (googleApiKey) {
|
if (googleApiKey) {
|
||||||
@@ -109,5 +115,5 @@ export async function createApplicationScene({
|
|||||||
});
|
});
|
||||||
|
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
return { viewer, tileset, mapStackController };
|
return { viewer, tileset, mapStackController, operations };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||||
|
import { createGroundFloor } from '../services/groundFloor.js';
|
||||||
|
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
|
||||||
|
import { createGroundSnap } from '../services/groundSnap.js';
|
||||||
|
import { createFireAnchors } from '../layers/firms/anchors.js';
|
||||||
|
|
||||||
|
/** Own the DEM, coarse floors and mesh samples used by one application. */
|
||||||
|
export function createSurfaceServices({
|
||||||
|
terrainSource,
|
||||||
|
signal,
|
||||||
|
eventTarget = globalThis.window,
|
||||||
|
}) {
|
||||||
|
const terrain = createTerrainHeights({ source: terrainSource, signal });
|
||||||
|
const groundFloor = createGroundFloor({ terrain, signal });
|
||||||
|
const meshFloor = createMeshFloorSampler({
|
||||||
|
groundFloor,
|
||||||
|
signal,
|
||||||
|
eventTarget,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
terrain,
|
||||||
|
groundFloor,
|
||||||
|
meshFloor,
|
||||||
|
groundSnap: Object.freeze({
|
||||||
|
createGroundSnap: () => createGroundSnap({ groundFloor }),
|
||||||
|
}),
|
||||||
|
anchors: createFireAnchors(groundFloor),
|
||||||
|
controlServices: Object.freeze({
|
||||||
|
cachedGroundFloor: groundFloor.cachedGroundFloor,
|
||||||
|
cachedMeshFloor: groundFloor.cachedMeshFloor,
|
||||||
|
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
|
||||||
|
meshFloorPreferred: groundFloor.meshFloorPreferred,
|
||||||
|
warmGroundFloor: groundFloor.warmGroundFloor,
|
||||||
|
sampleMeshFloorCells: meshFloor.sampleMeshFloorCells,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+11
-2
@@ -23,13 +23,18 @@ export function createApplicationTools({
|
|||||||
signal,
|
signal,
|
||||||
defer,
|
defer,
|
||||||
}) {
|
}) {
|
||||||
const { viewer, tileset, mapStackController } = scene;
|
const { viewer, tileset, mapStackController, operations } = scene;
|
||||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||||
const { dataManager } = data;
|
const { dataManager } = data;
|
||||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
|
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
|
||||||
defer(() => sceneDirector.destroy());
|
defer(() => sceneDirector.destroy());
|
||||||
onSceneDirector?.(sceneDirector);
|
onSceneDirector?.(sceneDirector);
|
||||||
const annotations = initAnnotations({ viewer, tileset, placeSearch });
|
const annotations = initAnnotations({
|
||||||
|
viewer,
|
||||||
|
tileset,
|
||||||
|
placeSearch,
|
||||||
|
resolver: operations.annotationResolver,
|
||||||
|
});
|
||||||
defer(() => {
|
defer(() => {
|
||||||
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
|
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
|
||||||
annotations.destroy();
|
annotations.destroy();
|
||||||
@@ -95,6 +100,7 @@ export function createApplicationTools({
|
|||||||
weatherEffects,
|
weatherEffects,
|
||||||
cockpitCloudEffects,
|
cockpitCloudEffects,
|
||||||
getRenderGovernorDiagnostics,
|
getRenderGovernorDiagnostics,
|
||||||
|
surfaceServices: operations.surface,
|
||||||
requestRender: governorRequestRender,
|
requestRender: governorRequestRender,
|
||||||
};
|
};
|
||||||
const debug = window.__godsEyeView;
|
const debug = window.__godsEyeView;
|
||||||
@@ -103,6 +109,9 @@ export function createApplicationTools({
|
|||||||
});
|
});
|
||||||
const voiceCommands = initGevVoiceCommands({
|
const voiceCommands = initGevVoiceCommands({
|
||||||
...voice,
|
...voice,
|
||||||
|
floorServices: operations.surface.groundFloor,
|
||||||
|
annotationResolver: operations.annotationResolver,
|
||||||
|
searchNavigation: operations.searchAndFlyTo,
|
||||||
signal,
|
signal,
|
||||||
placeSearch,
|
placeSearch,
|
||||||
viewer,
|
viewer,
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ test('validated voice camera destinations share the UI navigation authority faca
|
|||||||
// The corridor warm is injected the same way the floor READ is — the dolly
|
// The corridor warm is injected the same way the floor READ is — the dolly
|
||||||
// never reaches into the data layer itself, and the voice dispatch is the one
|
// never reaches into the data layer itself, and the voice dispatch is the one
|
||||||
// place that binds both.
|
// place that binds both.
|
||||||
assert.match(voice, /\(lat, lon\) => cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => warmGroundFloor\(cells\),/);
|
assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deferred search releases only after its final authority check', () => {
|
test('deferred search releases only after its final authority check', () => {
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
|||||||
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||||
*/
|
*/
|
||||||
export class CockpitCloudEffectsController {
|
export class CockpitCloudEffectsController {
|
||||||
constructor(viewer) {
|
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
|
||||||
|
this.weatherService = weatherService;
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this.canvas = document.createElement('canvas');
|
this.canvas = document.createElement('canvas');
|
||||||
this.canvas.id = 'cockpit-cloud-effects';
|
this.canvas.id = 'cockpit-cloud-effects';
|
||||||
@@ -369,7 +370,7 @@ export class CockpitCloudEffectsController {
|
|||||||
|
|
||||||
this.abort?.abort();
|
this.abort?.abort();
|
||||||
this.abort = new AbortController();
|
this.abort = new AbortController();
|
||||||
const pending = applicationServices.weather.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
|
const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
|
||||||
const request = this.abort;
|
const request = this.abort;
|
||||||
this.pending = pending.then(async (payload) => {
|
this.pending = pending.then(async (payload) => {
|
||||||
if (this.abort !== request || request.signal.aborted) return null;
|
if (this.abort !== request || request.signal.aborted) return null;
|
||||||
@@ -522,6 +523,6 @@ export class CockpitCloudEffectsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initCockpitCloudEffects(viewer) {
|
export function initCockpitCloudEffects(viewer, options) {
|
||||||
return new CockpitCloudEffectsController(viewer);
|
return new CockpitCloudEffectsController(viewer, options);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defaultSurface } from './surfaceServices.js';
|
||||||
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
||||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||||
import { createOverpassAlprSource } from '../layers/alpr/index.js';
|
import { createOverpassAlprSource } from '../layers/alpr/index.js';
|
||||||
@@ -9,5 +10,6 @@ const slot = createSourceSlot(
|
|||||||
);
|
);
|
||||||
export const configureAlprSource = slot.configure;
|
export const configureAlprSource = slot.configure;
|
||||||
export default createApplicationAlpr({
|
export default createApplicationAlpr({
|
||||||
|
surface: defaultSurface,
|
||||||
source: slot.source,
|
source: slot.source,
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defaultSurface } from './surfaceServices.js';
|
||||||
import { createApplicationCctv } from '../app/layers/cctv.js';
|
import { createApplicationCctv } from '../app/layers/cctv.js';
|
||||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||||
import { createCctvSource } from '../layers/cctv/source.js';
|
import { createCctvSource } from '../layers/cctv/source.js';
|
||||||
@@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot(
|
|||||||
);
|
);
|
||||||
export const configureCctvSource = sourceSlot.configure;
|
export const configureCctvSource = sourceSlot.configure;
|
||||||
const layer = createApplicationCctv({
|
const layer = createApplicationCctv({
|
||||||
|
surface: defaultSurface,
|
||||||
source: sourceSlot.source,
|
source: sourceSlot.source,
|
||||||
});
|
});
|
||||||
export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor;
|
export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defaultSurface } from './surfaceServices.js';
|
||||||
import { createApplicationFirms, firmsServices } from '../app/layers/firms.js';
|
import { createApplicationFirms, firmsServices } from '../app/layers/firms.js';
|
||||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +11,9 @@ const sourceSlot = createSourceSlot(
|
|||||||
'Fire source',
|
'Fire source',
|
||||||
);
|
);
|
||||||
export const configureFirmsSource = sourceSlot.configure;
|
export const configureFirmsSource = sourceSlot.configure;
|
||||||
const helpers = createFirmsHelpers({ services: firmsServices });
|
const helpers = createFirmsHelpers({
|
||||||
|
services: { ...firmsServices, anchors: defaultSurface.anchors },
|
||||||
|
});
|
||||||
export const mapAnalystRecord = helpers.mapAnalystRecord;
|
export const mapAnalystRecord = helpers.mapAnalystRecord;
|
||||||
export const fireCullPosition = helpers.fireCullPosition;
|
export const fireCullPosition = helpers.fireCullPosition;
|
||||||
export const applyHorizonCull = helpers.applyHorizonCull;
|
export const applyHorizonCull = helpers.applyHorizonCull;
|
||||||
@@ -22,6 +25,7 @@ export const applyFirmsOverlayPolicy = helpers.applyFirmsOverlayPolicy;
|
|||||||
/** Retain the existing default feed and helper exports for direct callers. */
|
/** Retain the existing default feed and helper exports for direct callers. */
|
||||||
export function createFirmsHeatmapLayer(options) {
|
export function createFirmsHeatmapLayer(options) {
|
||||||
return createApplicationFirms({
|
return createApplicationFirms({
|
||||||
|
surface: defaultSurface,
|
||||||
...options,
|
...options,
|
||||||
feed: options.feed ?? sourceSlot.source,
|
feed: options.feed ?? sourceSlot.source,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { defaultSurface } from './surfaceServices.js';
|
||||||
import { createApplicationFlights } from '../app/layers/flights.js';
|
import { createApplicationFlights } from '../app/layers/flights.js';
|
||||||
|
|
||||||
import { createOpenSkySource } from '../sources/live/standalone.js';
|
import { createOpenSkySource } from '../sources/live/standalone.js';
|
||||||
import * as militaryRegistry from './militaryRegistry.js';
|
import * as militaryRegistry from './militaryRegistry.js';
|
||||||
|
|
||||||
const flightsLayer = createApplicationFlights({
|
const flightsLayer = createApplicationFlights({
|
||||||
|
surface: defaultSurface,
|
||||||
source: createOpenSkySource(),
|
source: createOpenSkySource(),
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-580
@@ -1,580 +1,4 @@
|
|||||||
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
|
import { createGroundFloor } from '../services/groundFloor.js';
|
||||||
// heights (field-test round 2026-07-06).
|
import * as terrain from './terrainHeights.js';
|
||||||
//
|
const groundFloor = createGroundFloor({ terrain });
|
||||||
// Two owner findings drove this module:
|
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor;
|
||||||
// - RS46 (military H60): baro-only low-altitude contacts near steep terrain
|
|
||||||
// render INSIDE the hillside (no alt_geom → baro+N is off by more than the
|
|
||||||
// local relief).
|
|
||||||
// - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a
|
|
||||||
// fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB
|
|
||||||
// (field elevation ~1590 m ellipsoidal).
|
|
||||||
//
|
|
||||||
// The fix everywhere is the same: never render below the local ellipsoidal
|
|
||||||
// surface. This module provides the shared floor: a COARSE (3-decimal,
|
|
||||||
// ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving
|
|
||||||
// aircraft and multi-hundred-point trails collapse onto a small set of cells
|
|
||||||
// with heavy cache reuse (the exact 5-decimal grid would mint a new cache key
|
|
||||||
// every fix — the taxiing-cache lesson from the adversarial review).
|
|
||||||
// A ~111 m cell means the floor can be off by the relief within the cell in
|
|
||||||
// steep terrain; the clamp is a visual floor, not a survey — lifting an icon
|
|
||||||
// a few metres short of a ridge line still beats burying it.
|
|
||||||
//
|
|
||||||
// Zero-network on the render path: reads are synchronous cache lookups;
|
|
||||||
// `warmGroundFloor` batches the misses through resolveEllipsoidalGround
|
|
||||||
// (single-flight, fire-and-forget) exactly like flights.js's grounded-surface
|
|
||||||
// warm — results land for later polls/appends.
|
|
||||||
import { cachedRealEllipsoidalGround, resolveEllipsoidalGround } from './terrainHeights.js';
|
|
||||||
|
|
||||||
/** @constant {number} Metres added above the resolved floor so a clamped
|
|
||||||
* billboard/waypoint sits ON the surface instead of z-fighting it. */
|
|
||||||
export const GROUND_FLOOR_LIFT_M = 1.5;
|
|
||||||
/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */
|
|
||||||
export const MESH_FLOOR_BELOW_PRIOR_M = 15;
|
|
||||||
export const MESH_FLOOR_ABOVE_PRIOR_M = 80;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid.
|
|
||||||
* @param {number} lat
|
|
||||||
* @param {number} lon
|
|
||||||
* @returns {{lat: number, lon: number}} Grid-cell coordinate.
|
|
||||||
*/
|
|
||||||
export function coarseFloorCoord(lat, lon) {
|
|
||||||
return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure floor clamp.
|
|
||||||
* - finite alt + finite ground → max(alt, ground + lift)
|
|
||||||
* - finite alt + unknown ground → alt (no data, no clamp)
|
|
||||||
* - null alt + finite ground → ground + lift (the 'ground'-waypoint case:
|
|
||||||
* the point IS on the surface; put it there)
|
|
||||||
* - null alt + unknown ground → null (caller applies its own fallback)
|
|
||||||
* @param {number|null|undefined} altM - Proposed ellipsoidal render height.
|
|
||||||
* @param {number|null|undefined} groundM - Local ellipsoidal ground, if known.
|
|
||||||
* @param {number} [liftM] - Clearance above the floor.
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
export function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) {
|
|
||||||
const hasAlt = Number.isFinite(altM);
|
|
||||||
const hasGround = Number.isFinite(groundM);
|
|
||||||
if (hasAlt && hasGround) return Math.max(altM, groundM + liftM);
|
|
||||||
if (hasAlt) return altM;
|
|
||||||
if (hasGround) return groundM + liftM;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floor decision at the point of DISPLAY (2026-08-19).
|
|
||||||
*
|
|
||||||
* `floorAltitudeM` above answers "what height should this FIX render at" and
|
|
||||||
* runs once per poll. But what the user sees is the DEAD-RECKONED position:
|
|
||||||
* a Cartesian interpolation between two fixes for the whole segment, and a
|
|
||||||
* velocity extrapolation of the newest fix while a contact coasts through its
|
|
||||||
* stale-feed grace. Both drift the rendered point horizontally away from the
|
|
||||||
* fix whose cell supplied the height — across a graded apron (KAUS spans
|
|
||||||
* ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it
|
|
||||||
* has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`).
|
|
||||||
*
|
|
||||||
* This is the same "never below the visible surface" rule applied at the
|
|
||||||
* displayed coordinate instead of the fix coordinate. It returns `null` rather
|
|
||||||
* than the unchanged height whenever nothing should move, so the per-frame
|
|
||||||
* caller can skip rebuilding a Cartesian entirely — the common case by far.
|
|
||||||
*
|
|
||||||
* @param {number|null|undefined} displayHeightM - Ellipsoidal height the
|
|
||||||
* dead-reckoned display position currently carries.
|
|
||||||
* @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED
|
|
||||||
* coordinate (`cachedGroundFloor`), or null when that cell is not warm.
|
|
||||||
* @param {number} [liftM] - Clearance above the floor.
|
|
||||||
* @returns {number|null} The height to render at, or null for "leave it alone".
|
|
||||||
*/
|
|
||||||
export function displayFloorHeightM(displayHeightM, floorM, liftM = GROUND_FLOOR_LIFT_M) {
|
|
||||||
if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM)) return null;
|
|
||||||
const lifted = floorM + liftM;
|
|
||||||
return displayHeightM < lifted ? lifted : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @constant {number} Pitch of the coarse floor grid in degrees — the spacing
|
|
||||||
* `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */
|
|
||||||
export const FLOOR_CELL_DEG = 0.001;
|
|
||||||
|
|
||||||
/** @constant {number} Resolved neighbours required before one may be borrowed.
|
|
||||||
* A single reading cannot be checked against anything: a hangar roof and an
|
|
||||||
* apron are both "a warm cell 111 m away", and there is no way to tell them
|
|
||||||
* apart from one number. Two readings at least agree or disagree. */
|
|
||||||
export const NEIGHBOR_FLOOR_MIN_SAMPLES = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A floor for `cell` borrowed from the eight cells ADJACENT to it.
|
|
||||||
*
|
|
||||||
* Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the
|
|
||||||
* Re:Earth proxy timed out four times in a row and a grounded contact standing
|
|
||||||
* on a cold cell rendered at the geoid — tens of metres under the mesh it was
|
|
||||||
* parked on). A neighbour is ~111 m away; on the aprons and taxiways where
|
|
||||||
* grounded contacts live that is the same surface, and it is a MEASURED height
|
|
||||||
* rather than an invented one.
|
|
||||||
*
|
|
||||||
* Leans to the LOWEST resolved neighbour, and refuses to answer at all from
|
|
||||||
* fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them.
|
|
||||||
*
|
|
||||||
* An earlier cut leaned HIGH, reasoning from the locked "never below the
|
|
||||||
* visible surface" principle. That principle is about a contact's OWN measured
|
|
||||||
* ground; applied to a BORROWED cell it inverts, and an owner playtest found
|
|
||||||
* why — planes floating in midair at terminal gates. The two errors are not
|
|
||||||
* symmetric:
|
|
||||||
* - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a
|
|
||||||
* floor under the contact simply does not lift it — the same "no clamp" the
|
|
||||||
* cold-cell case already has, bounded by one cell of grade (metres).
|
|
||||||
* - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT,
|
|
||||||
* not by grade, and a parked contact holds it: measured on the A/B rig,
|
|
||||||
* a lone roof neighbour put a contact 29.5 m up and left it there
|
|
||||||
* permanently when its own cell never warmed, and 6.3 s of visible
|
|
||||||
* stair-stepping when it did.
|
|
||||||
* A plane at a gate is on the apron, never on the terminal roof, so on a
|
|
||||||
* structure edge the LOW neighbour is the answer and the high one is an
|
|
||||||
* artefact of the photogrammetric mesh.
|
|
||||||
*
|
|
||||||
* The honest residual: on a genuine SLOPE the lowest neighbour under-reads by
|
|
||||||
* the grade across ~111 m, so the clamp lifts a little less than it could. That
|
|
||||||
* shows up as no lift rather than as a wrong one, and the contact's own cell
|
|
||||||
* corrects it as soon as it warms.
|
|
||||||
*
|
|
||||||
* @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell.
|
|
||||||
* @returns {number|null} Ellipsoidal floor, or null when too few neighbours are
|
|
||||||
* warm to borrow from.
|
|
||||||
*/
|
|
||||||
export function neighborFloorM(cell) {
|
|
||||||
if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null;
|
|
||||||
let lowest = null;
|
|
||||||
let resolved = 0;
|
|
||||||
for (let dLat = -1; dLat <= 1; dLat += 1) {
|
|
||||||
for (let dLon = -1; dLon <= 1; dLon += 1) {
|
|
||||||
if (dLat === 0 && dLon === 0) continue;
|
|
||||||
const h = cachedGroundFloor(cell.lat + dLat * FLOOR_CELL_DEG, cell.lon + dLon * FLOOR_CELL_DEG);
|
|
||||||
if (h == null) continue;
|
|
||||||
resolved += 1;
|
|
||||||
if (lowest == null || h < lowest) lowest = h;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @constant {number} Cap on cells one corridor may contribute — a fast rollout
|
|
||||||
* or a spliced fix must not turn one contact into an unbounded warm batch.
|
|
||||||
* Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m
|
|
||||||
* (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */
|
|
||||||
export const CORRIDOR_MAX_CELLS = 12;
|
|
||||||
/** @constant {number} Step along a corridor leg when collecting cells (~14 m,
|
|
||||||
* an eighth of a cell). Half-cell steps were only cell-complete on LONG legs:
|
|
||||||
* a short leg rounds to a single step, so the walk degenerates to testing the
|
|
||||||
* leg's endpoints and a cell clipped between them is skipped. At this step any
|
|
||||||
* cell the path occupies for ~14 m of ground or more contains a sample — the
|
|
||||||
* guarantee this walk actually makes. The loop still exits the moment the cell
|
|
||||||
* budget fills, so a finer step costs iterations only on short corridors. */
|
|
||||||
export const CORRIDOR_WALK_STEP_DEG = 0.000125;
|
|
||||||
/** @constant {number} Degrees added around a cell before a contact is allowed
|
|
||||||
* to adopt the next one (~22 m). A dead-reckoned position that jitters across
|
|
||||||
* a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop
|
|
||||||
* the sprite between two heights. */
|
|
||||||
export const CELL_HYSTERESIS_DEG = 0.0002;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The floor cell a contact should read, with boundary hysteresis.
|
|
||||||
*
|
|
||||||
* Cells are 0.001° squares, so a display position sitting on an edge (parked
|
|
||||||
* GPS jitter, the tracked reconciliation's decaying correction) flips between
|
|
||||||
* two cells frame to frame — and their floors can differ by the local grade or,
|
|
||||||
* next to a building, several metres. The contact keeps its previous cell until
|
|
||||||
* it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two
|
|
||||||
* comparisons and no allocation on the sticky path.
|
|
||||||
*
|
|
||||||
* @param {number} lat @param {number} lon - Displayed coordinate.
|
|
||||||
* @param {{lat: number, lon: number}|null|undefined} previousCell - The cell
|
|
||||||
* this contact read last tick, if any.
|
|
||||||
* @returns {{lat: number, lon: number}} The cell to read.
|
|
||||||
*/
|
|
||||||
export function stickyFloorCell(lat, lon, previousCell) {
|
|
||||||
if (previousCell
|
|
||||||
&& Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG
|
|
||||||
&& Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG) {
|
|
||||||
return previousCell;
|
|
||||||
}
|
|
||||||
return coarseFloorCoord(lat, lon);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The coarse cells a contact's DISPLAY is about to walk through.
|
|
||||||
*
|
|
||||||
* The dead-reckoned display runs one render delay behind and advances toward
|
|
||||||
* the newest fix, so the segment [display → newest fix] is exactly the ground
|
|
||||||
* it will render over during the next poll or two. Warming only the cell it is
|
|
||||||
* standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a
|
|
||||||
* ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it
|
|
||||||
* stays permanently ahead of its own floor data and the display clamp has
|
|
||||||
* nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the
|
|
||||||
* whole observation).
|
|
||||||
*
|
|
||||||
* Walks in half-cell steps so no cell between the ends is skipped. The step
|
|
||||||
* COUNT follows the segment length (never a fixed cap — a capped count over a
|
|
||||||
* long segment samples sparsely and leaves holes in the middle of ground the
|
|
||||||
* contact does cross). Length is bounded instead, and honestly: the walk emits
|
|
||||||
* a GAP-FREE PREFIX from `from`, and the destination cell always takes the last
|
|
||||||
* slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated —
|
|
||||||
* contiguous near ground plus the far endpoint — rather than thinned; the
|
|
||||||
* ground in between is picked up on a later poll as the display advances into
|
|
||||||
* it. Loop work stays bounded because the walk exits as soon as the cell budget
|
|
||||||
* fills, whatever the segment length.
|
|
||||||
*
|
|
||||||
* Takes the display PATH as a polyline, not two endpoints: while the contact
|
|
||||||
* is extrapolating, that path is an arc (the dead-reckon integrates a
|
|
||||||
* constant-rate turn), and walking a straight chord between its ends would
|
|
||||||
* warm ground the contact never crosses while its actual arc stayed cold.
|
|
||||||
*
|
|
||||||
* @param {Array<{lat: number, lon: number}>} points - Display path, current
|
|
||||||
* position first, destination last.
|
|
||||||
* @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start
|
|
||||||
* first, destination last.
|
|
||||||
*/
|
|
||||||
export function corridorFloorCells(points) {
|
|
||||||
if (!Array.isArray(points) || !points.length) return [];
|
|
||||||
const first = points[0];
|
|
||||||
if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return [];
|
|
||||||
const start = coarseFloorCoord(first.lat, first.lon);
|
|
||||||
const out = [start];
|
|
||||||
const seen = new Set([`${start.lat},${start.lon}`]);
|
|
||||||
let destination = null;
|
|
||||||
for (let i = points.length - 1; i >= 1; i--) {
|
|
||||||
if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) {
|
|
||||||
destination = coarseFloorCoord(points[i].lat, points[i].lon);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!destination) return out;
|
|
||||||
const destKey = `${destination.lat},${destination.lon}`;
|
|
||||||
// One slot is reserved for the destination, appended after the prefix.
|
|
||||||
const prefixLimit = CORRIDOR_MAX_CELLS - 1;
|
|
||||||
walk:
|
|
||||||
for (let s = 0; s < points.length - 1; s++) {
|
|
||||||
const a = points[s];
|
|
||||||
const b = points[s + 1];
|
|
||||||
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
|
|
||||||
const steps = Math.ceil(
|
|
||||||
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) / CORRIDOR_WALK_STEP_DEG,
|
|
||||||
);
|
|
||||||
for (let i = 1; i <= steps; i++) {
|
|
||||||
if (out.length >= prefixLimit) break walk;
|
|
||||||
const t = i / steps;
|
|
||||||
const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, a.lon + (b.lon - a.lon) * t);
|
|
||||||
const key = `${cell.lat},${cell.lon}`;
|
|
||||||
if (seen.has(key) || key === destKey) continue;
|
|
||||||
seen.add(key);
|
|
||||||
out.push(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!seen.has(destKey)) out.push(destination);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shares one poll's corridor-cell budget across the contacts that need it.
|
|
||||||
*
|
|
||||||
* The first cut walked contacts in Map insertion order and decremented the
|
|
||||||
* budget BEFORE deduping, so parked contacts burned slots on cells the poll had
|
|
||||||
* already collected and the contacts actually outrunning their floor data
|
|
||||||
* starved. Three rules fix that:
|
|
||||||
* 1. Cells already collected are FREE — a parked contact, whose corridor is
|
|
||||||
* its own fix cell, never competes for budget at all.
|
|
||||||
* 2. Candidates are ordered by how many of their cells are genuinely cold,
|
|
||||||
* then by speed (whoever leaves their floor behind soonest).
|
|
||||||
* 3. Allocation runs in widening rounds — one cell each, then `fairShare`
|
|
||||||
* each, then the remainder — so no contact is left with NOTHING while
|
|
||||||
* another takes seconds. The nearest cell is the one a contact needs first
|
|
||||||
* anyway; the far end of its corridor can wait a poll.
|
|
||||||
*
|
|
||||||
* @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates
|
|
||||||
* @param {Set<string>} seen - Keys ("lat,lon") already collected; MUTATED with
|
|
||||||
* everything this call allocates, so the caller's dedupe stays consistent.
|
|
||||||
* Ranking alone still starves a TIE longer than the budget: 80 equally needy
|
|
||||||
* movers means the same stable prefix wins every poll and the tail never moves.
|
|
||||||
* The `epoch` rotates each run of equal-ranked candidates, so a tie cycles
|
|
||||||
* round-robin across polls while a genuinely needier contact is never demoted
|
|
||||||
* below a less needy one (rotation happens WITHIN a rank, never across ranks).
|
|
||||||
*
|
|
||||||
* `seen` alone is NOT a sufficient free-list: in production it holds only the
|
|
||||||
* CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and
|
|
||||||
* spent budget again, letting a contact's near cells consume its whole share
|
|
||||||
* poll after poll while the ground ahead of it stayed cold. Budget is therefore
|
|
||||||
* charged only for cells with no floor yet (`isWarm`) — but warm cells are
|
|
||||||
* still EMITTED, because the mesh sampler must see a cell again after its DEM
|
|
||||||
* prior lands or it never latches a rendered-surface height there.
|
|
||||||
*
|
|
||||||
* That gives a service bound derivable from the policy rather than a magic
|
|
||||||
* number: the first round charges at most one cell per candidate, so at least
|
|
||||||
* `min(candidates, budget)` candidates are served per poll; a candidate that
|
|
||||||
* misses out keeps its cold count while every served candidate's drops, so it
|
|
||||||
* outranks them next poll. Every candidate is therefore served within
|
|
||||||
* `ceil(candidates / budget)` polls once the warms land.
|
|
||||||
*
|
|
||||||
* @param {number} budget - Max cold cells to charge for.
|
|
||||||
* @param {number} fairShare - Per-candidate cap for the middle round.
|
|
||||||
* @param {number} [epoch] - Poll counter; rotates equal-ranked runs.
|
|
||||||
* @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a
|
|
||||||
* cell already has a floor (defaults to the shared cache).
|
|
||||||
* @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order.
|
|
||||||
*/
|
|
||||||
export function allocateCorridorCells(
|
|
||||||
candidates, seen, budget, fairShare, epoch = 0,
|
|
||||||
isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null,
|
|
||||||
) {
|
|
||||||
const out = [];
|
|
||||||
if (!Array.isArray(candidates) || !candidates.length || !(budget > 0)) return out;
|
|
||||||
const ranked = [...candidates].sort((a, b) => (b.cold - a.cold) || (b.speedMps - a.speedMps));
|
|
||||||
if (Number.isFinite(epoch) && epoch !== 0) {
|
|
||||||
for (let i = 0; i < ranked.length;) {
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < ranked.length
|
|
||||||
&& ranked[j].cold === ranked[i].cold
|
|
||||||
&& ranked[j].speedMps === ranked[i].speedMps) j += 1;
|
|
||||||
const runLength = j - i;
|
|
||||||
if (runLength > 1) {
|
|
||||||
const shift = ((epoch % runLength) + runLength) % runLength;
|
|
||||||
const run = ranked.slice(i, j);
|
|
||||||
for (let m = 0; m < runLength; m += 1) ranked[i + m] = run[(m + shift) % runLength];
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Cursor per candidate, carried ACROSS rounds: a later round resumes where
|
|
||||||
// the previous one stopped instead of re-walking cells already handled.
|
|
||||||
const cursor = new Array(ranked.length).fill(0);
|
|
||||||
let left = budget;
|
|
||||||
const round = (limit) => {
|
|
||||||
for (let i = 0; i < ranked.length; i++) {
|
|
||||||
const cells = ranked[i].cells || [];
|
|
||||||
while (cursor[i] < cells.length && cursor[i] < limit) {
|
|
||||||
const cell = cells[cursor[i]];
|
|
||||||
const key = `${cell.lat},${cell.lon}`;
|
|
||||||
if (seen.has(key)) { cursor[i] += 1; continue; } // already in this batch
|
|
||||||
if (isWarm(cell)) {
|
|
||||||
// Free: it has a floor already. Still emitted, so the mesh sampler
|
|
||||||
// gets another look now that its DEM prior exists.
|
|
||||||
cursor[i] += 1;
|
|
||||||
seen.add(key);
|
|
||||||
out.push(cell);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (left <= 0) return;
|
|
||||||
cursor[i] += 1;
|
|
||||||
seen.add(key);
|
|
||||||
out.push(cell);
|
|
||||||
left -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
round(1); // nobody goes home empty-handed
|
|
||||||
round(fairShare); // then a fair slice each
|
|
||||||
round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mesh-floor cells (round 4, owner-approved design) ---------------------
|
|
||||||
// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime
|
|
||||||
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
|
|
||||||
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
|
|
||||||
// inside the mesh. These cells hold the RENDERED surface height, sampled
|
|
||||||
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
|
|
||||||
// source groundSnap uses, which is why 3D models always looked right) and
|
|
||||||
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
|
|
||||||
// below the visible surface; slightly above is always fine. The DEM stays
|
|
||||||
// the instant global prior/fallback and the sanity gate for samples.
|
|
||||||
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
|
|
||||||
const _meshCells = new Map();
|
|
||||||
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
|
|
||||||
* regime (photoreal stack). On globe stacks the rendered surface IS the
|
|
||||||
* terrain provider (Re:Earth on keyless), so the DEM is already correct
|
|
||||||
* there and mesh heights would float. Kept current by meshFloorSampler's
|
|
||||||
* 'gev:map-stack-changed' listener. Photoreal is the boot default. */
|
|
||||||
let _meshPreferred = true;
|
|
||||||
|
|
||||||
/** @param {boolean} preferred - google-3d regime active. */
|
|
||||||
export function setMeshFloorPreferred(preferred) {
|
|
||||||
_meshPreferred = !!preferred;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns {boolean} Whether mesh-floor cells currently apply. */
|
|
||||||
export function meshFloorPreferred() {
|
|
||||||
return _meshPreferred;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a sampled rendered-surface height for the cell containing the
|
|
||||||
* coordinate (one-shot latch — first accepted sample wins).
|
|
||||||
* @param {number} lat @param {number} lon @param {number} heightM
|
|
||||||
*/
|
|
||||||
export function reportMeshFloorCell(lat, lon, heightM) {
|
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(heightM)) return;
|
|
||||||
const c = coarseFloorCoord(lat, lon);
|
|
||||||
const key = `${c.lat},${c.lon}`;
|
|
||||||
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
|
|
||||||
export function meshFloorSampleWithinPrior(heightM, priorM) {
|
|
||||||
return Number.isFinite(heightM)
|
|
||||||
&& Number.isFinite(priorM)
|
|
||||||
&& heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M
|
|
||||||
&& heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and record a rendered-mesh floor through the single shared gate.
|
|
||||||
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
|
|
||||||
* rooftop, or aircraft hit would otherwise win the session-long first-write
|
|
||||||
* latch and poison every floor consumer in the cell.
|
|
||||||
* @returns {boolean} Whether the sample passed and was reported.
|
|
||||||
*/
|
|
||||||
export function reportValidatedMeshFloorCell(lat, lon, heightM) {
|
|
||||||
if (![lat, lon, heightM].every(Number.isFinite)) return false;
|
|
||||||
const cell = coarseFloorCoord(lat, lon);
|
|
||||||
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
|
|
||||||
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
|
|
||||||
reportMeshFloorCell(cell.lat, cell.lon, heightM);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous mesh-cell read — null when the cell is unsampled OR the
|
|
||||||
* google-3d regime is inactive (globe stacks must floor on the DEM).
|
|
||||||
* @param {number} lat @param {number} lon
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
export function cachedMeshFloor(lat, lon) {
|
|
||||||
if (!_meshPreferred) return null;
|
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
||||||
const c = coarseFloorCoord(lat, lon);
|
|
||||||
const h = _meshCells.get(`${c.lat},${c.lon}`);
|
|
||||||
return h != null ? h : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
|
|
||||||
export function _clearMeshFloorCellsForTest() {
|
|
||||||
_meshCells.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous coarse-cell ground read (warm cache only — never a fetch).
|
|
||||||
* Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back
|
|
||||||
* to the Re:Earth DEM cell — the single choke point every ground-adjacent
|
|
||||||
* consumer (fleet clamp, grounded surface chain, trail floors) reads, so
|
|
||||||
* they all agree on one surface.
|
|
||||||
* @param {number} lat
|
|
||||||
* @param {number} lon
|
|
||||||
* @returns {number|null} Ellipsoidal floor of the cell, or null if not warm.
|
|
||||||
*/
|
|
||||||
export function cachedGroundFloor(lat, lon) {
|
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
||||||
const mesh = cachedMeshFloor(lat, lon);
|
|
||||||
if (mesh != null) return mesh;
|
|
||||||
const c = coarseFloorCoord(lat, lon);
|
|
||||||
return cachedRealEllipsoidalGround(c.lat, c.lon);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Awaitable variant for one-shot consumers (trail backfills): dedupes the
|
|
||||||
* points to unique coarse cells and resolves them, so `cachedGroundFloor`
|
|
||||||
* reads immediately after are warm. The underlying resolver chunks (≤200/req)
|
|
||||||
* and falls back per-point, so any trace length is safe. Never throws.
|
|
||||||
* @param {Array<{lat: number, lon: number}>} points
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
/** @constant {number} Max ms a one-shot consumer (trail backfill) waits for
|
|
||||||
* the floor resolve before painting with whatever cells are warm. Owner
|
|
||||||
* field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints
|
|
||||||
* hostage to cold Re:Earth lookups spanning a whole flight path (seconds to
|
|
||||||
* the 30 s proxy timeout) — trails showed up late, short, or not at all.
|
|
||||||
* The resolve keeps running past the deadline and lands in the cache for
|
|
||||||
* the next paint/select. */
|
|
||||||
export const FLOOR_RESOLVE_DEADLINE_MS = 1200;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bounded-latency variant of resolveGroundFloorCells: waits at most
|
|
||||||
* FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the
|
|
||||||
* cells that ARE warm. The underlying resolve continues in the background
|
|
||||||
* and fills the cache for later reads. Never throws.
|
|
||||||
* @param {Array<{lat: number, lon: number}>} points
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export function resolveGroundFloorCellsBounded(points) {
|
|
||||||
return Promise.race([
|
|
||||||
resolveGroundFloorCells(points),
|
|
||||||
new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveGroundFloorCells(points) {
|
|
||||||
if (!Array.isArray(points) || !points.length) return;
|
|
||||||
const cells = new Map();
|
|
||||||
for (const p of points) {
|
|
||||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
|
||||||
const c = coarseFloorCoord(p.lat, p.lon);
|
|
||||||
const key = `${c.lat},${c.lon}`;
|
|
||||||
if (cells.has(key)) continue;
|
|
||||||
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue;
|
|
||||||
cells.set(key, c);
|
|
||||||
}
|
|
||||||
if (!cells.size) return;
|
|
||||||
try {
|
|
||||||
await resolveEllipsoidalGround([...cells.values()]);
|
|
||||||
} catch { /* best-effort — unresolved cells simply don't clamp */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {boolean} Single-flight guard for the warm batch. */
|
|
||||||
let _floorBatchInFlight = false;
|
|
||||||
/** @type {Map<string, {lat: number, lon: number}>} Cells requested while a
|
|
||||||
* batch was in flight — flushed as the NEXT batch the moment it completes.
|
|
||||||
* Round 6: the old guard silently DROPPED contending calls (flights every
|
|
||||||
* 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new
|
|
||||||
* airport could take minutes of lost polls before a given cell resolved —
|
|
||||||
* the owner's "you can't just go to a place and start inspecting it". */
|
|
||||||
const _pendingFloorCells = new Map();
|
|
||||||
|
|
||||||
/** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */
|
|
||||||
function _resolveFloorCells(cells) {
|
|
||||||
_floorBatchInFlight = true;
|
|
||||||
resolveEllipsoidalGround([...cells.values()])
|
|
||||||
.catch(() => { /* best-effort — unclamped this tick, floor lands later */ })
|
|
||||||
.finally(() => {
|
|
||||||
_floorBatchInFlight = false;
|
|
||||||
if (_pendingFloorCells.size) {
|
|
||||||
const next = new Map(_pendingFloorCells);
|
|
||||||
_pendingFloorCells.clear();
|
|
||||||
_resolveFloorCells(next);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire-and-forget batch warm of the coarse floor cells for the given points.
|
|
||||||
* Dedupes to unique cells, skips already-warm ones, and never blocks or
|
|
||||||
* throws into the caller. Contending calls QUEUE (never drop): their cells
|
|
||||||
* ride the next batch as soon as the in-flight one completes. Results are
|
|
||||||
* picked up by later `cachedGroundFloor` reads.
|
|
||||||
* @param {Array<{lat: number, lon: number}>} points
|
|
||||||
*/
|
|
||||||
export function warmGroundFloor(points) {
|
|
||||||
if (!Array.isArray(points) || !points.length) return;
|
|
||||||
const cells = new Map();
|
|
||||||
for (const p of points) {
|
|
||||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
|
||||||
const c = coarseFloorCoord(p.lat, p.lon);
|
|
||||||
const key = `${c.lat},${c.lon}`;
|
|
||||||
if (cells.has(key) || _pendingFloorCells.has(key)) continue;
|
|
||||||
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm
|
|
||||||
cells.set(key, c);
|
|
||||||
}
|
|
||||||
if (!cells.size) return;
|
|
||||||
if (_floorBatchInFlight) {
|
|
||||||
for (const [key, c] of cells) _pendingFloorCells.set(key, c);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_resolveFloorCells(cells);
|
|
||||||
}
|
|
||||||
+4
-352
@@ -1,352 +1,4 @@
|
|||||||
/**
|
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
|
||||||
* groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
|
import * as groundFloor from './groundFloor.js';
|
||||||
*
|
/** Compatibility constructor using the default floor cache. */
|
||||||
* Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere
|
export function createGroundSnap() { return createSnap({ groundFloor }); }
|
||||||
* near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland
|
|
||||||
* airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid
|
|
||||||
* sits below the ellipsoid). A 3D model placed at the meta altitude therefore
|
|
||||||
* renders inside the tiles or hovering above them. Billboards dodge this with
|
|
||||||
* disableDepthTestDistance; a depth-tested glTF model cannot.
|
|
||||||
*
|
|
||||||
* Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen
|
|
||||||
* pick render, so it is NEVER called per frame. Each grounded modeled plane gets
|
|
||||||
* ONE successful sample, cached per icao; the cache stops answering directly when
|
|
||||||
* the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles
|
|
||||||
* still streaming, OSM fallback with no tileset skin, sample outside loaded
|
|
||||||
* geometry) are a retry-later signal with per-icao exponential backoff. A
|
|
||||||
* per-window budget stops a freshly modeled airport cluster from firing dozens of
|
|
||||||
* pick renders in one fleet tick.
|
|
||||||
*
|
|
||||||
* What a caller gets while a sample is outstanding depends on whether this
|
|
||||||
* contact has ever HAD one, and the difference is the difference between two
|
|
||||||
* kinds of wrong:
|
|
||||||
* - COLD (nothing ever resolved here) — null. There is no evidence of where the
|
|
||||||
* ground is, and the only honest answer for a depth-tested model is not to
|
|
||||||
* place it at all; the caller keeps the depth-test-free billboard.
|
|
||||||
* - WARM (a snap resolved, then taxi invalidated it and the resample has not
|
|
||||||
* landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding
|
|
||||||
* the model here would pop a taxiing aircraft from 3D back to 2D for a whole
|
|
||||||
* retry backoff, and the ground it was measured on a few dozen metres back is
|
|
||||||
* real evidence, not a guess. Same shape as the billboard chain's
|
|
||||||
* `_heldDisplayFloorM` (flights.js): hold the last thing that resolved,
|
|
||||||
* bounded by how far the contact can have travelled from it.
|
|
||||||
*
|
|
||||||
* A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks
|
|
||||||
* fresh evidence that contradicts it. Distance travelled is only a proxy for
|
|
||||||
* "does this still describe the ground here", and it is a loose one — a contact
|
|
||||||
* can taxi 200 m onto a different surface well inside the bound, and because a
|
|
||||||
* miss preserves the hold, nothing in the retry path would ever correct that.
|
|
||||||
* So where the mesh-floor cells have actually MEASURED the surface under the
|
|
||||||
* contact's current position, and that surface is ABOVE the held value by more
|
|
||||||
* than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and
|
|
||||||
* the contact is COLD again. The fallback is the floored 2D billboard, which is
|
|
||||||
* the honest picture — a model buried in the apron is not a better one.
|
|
||||||
*
|
|
||||||
* Shared by flights.js and militaryFlights.js (one instance each — the caches
|
|
||||||
* are per-layer, keyed by that layer's icao space).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } from './groundFloor.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
|
|
||||||
* resample runs. */
|
|
||||||
const MOVE_INVALIDATE_M = 50;
|
|
||||||
/** @constant {number} How far a contact may travel from the spot its snap was
|
|
||||||
* measured at before that measurement stops describing the ground under it, and
|
|
||||||
* the hold is dropped rather than stretched.
|
|
||||||
*
|
|
||||||
* Sized to two things at once. It has to outlast a resample outage, or it buys
|
|
||||||
* nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a
|
|
||||||
* hold that expires inside ~25 s of rolling would hand back the pop it exists to
|
|
||||||
* prevent. And it has to stay inside the grade error an airfield can hide: the
|
|
||||||
* KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so
|
|
||||||
* 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets
|
|
||||||
* already being added, and two orders under the ~100+ m burial the snap exists
|
|
||||||
* to prevent.
|
|
||||||
*
|
|
||||||
* Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That
|
|
||||||
* hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an
|
|
||||||
* under-read there is inert; here the held value IS the placement, so the error
|
|
||||||
* shows in both directions and the bound has to be tighter. */
|
|
||||||
const HELD_SNAP_MAX_DRIFT_M = 250;
|
|
||||||
/** @constant {number} How far a held measurement may disagree with FRESH
|
|
||||||
* measured evidence at the contact's current position before it is dropped
|
|
||||||
* rather than trusted.
|
|
||||||
*
|
|
||||||
* The drift bound above asks "how far has the contact travelled?", which is a
|
|
||||||
* proxy for "does this value still describe the ground?" — and a proxy is all
|
|
||||||
* it is. 202 m of taxi onto a different surface is inside the bound and still
|
|
||||||
* wrong, and because a miss preserves the hold, repeated misses preserve the
|
|
||||||
* error for as long as they last. So the bound is not the only guard: where
|
|
||||||
* something has actually MEASURED the surface the contact is standing on now,
|
|
||||||
* that measurement outranks the memory, and a memory that contradicts it is
|
|
||||||
* discarded rather than stretched to cover the gap.
|
|
||||||
*
|
|
||||||
* 5 m sits between the two scales this has to separate:
|
|
||||||
* - ABOVE the honest disagreement between two measurements of the SAME
|
|
||||||
* surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the
|
|
||||||
* ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep
|
|
||||||
* 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of
|
|
||||||
* those should cost a taxiing aircraft its model.
|
|
||||||
* - BELOW the scale at which a placement reads as wrong. The belly offsets
|
|
||||||
* this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a
|
|
||||||
* model displaced more than ~5 m is visibly off its gear rather than
|
|
||||||
* imprecisely on it — and it is two orders under the ~100+ m burial the
|
|
||||||
* snap exists to prevent.
|
|
||||||
*
|
|
||||||
* Applied in ONE direction: the hold is released only when fresh evidence sits
|
|
||||||
* ABOVE it, which is the BURIAL case — the model sunk into the surface, which
|
|
||||||
* is the entire reason this gate exists. The other sign is deliberately left
|
|
||||||
* alone, and the asymmetry is not tidiness:
|
|
||||||
*
|
|
||||||
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
|
|
||||||
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
|
|
||||||
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
|
|
||||||
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
|
|
||||||
* built to under-read rather than to invent. Releasing on that sign would
|
|
||||||
* discard good measurements wherever the two disagree downward. Measured on
|
|
||||||
* the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at
|
|
||||||
* the SAME spot, and a two-sided rule dropped a hold that was correct.
|
|
||||||
* - The residual is stated rather than hidden, and stated WITH its condition.
|
|
||||||
* A contact taxiing DOWNHILL keeps a held value above its new surface and
|
|
||||||
* its model floats. The correction is a SUCCESSFUL resample, and nothing on
|
|
||||||
* this path guarantees one: on the OSM fallback sampleHeight misses forever,
|
|
||||||
* so the retries never land and the float persists for as long as the
|
|
||||||
* contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it
|
|
||||||
* and no vertical cap of its own — only whatever the ground drops within
|
|
||||||
* that radius. The other releases (a ground flip via forget(), travelling
|
|
||||||
* past the bound, a mesh cell measuring ABOVE the hold) are not ones a
|
|
||||||
* downhill taxi is guaranteed to hit either. It is accepted over the
|
|
||||||
* alternative because it errs UPWARD and stays visible, where a buried model
|
|
||||||
* errs into the terrain and cannot be seen at all. */
|
|
||||||
const HELD_SNAP_CONTRADICTION_M = 5;
|
|
||||||
/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */
|
|
||||||
const RETRY_BASE_MS = 2000;
|
|
||||||
/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */
|
|
||||||
const RETRY_MAX_MS = 30000;
|
|
||||||
/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */
|
|
||||||
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;
|
|
||||||
for (let i = 0; i < prims.length; i++) {
|
|
||||||
let p = null;
|
|
||||||
try { p = prims.get(i); } catch { continue; }
|
|
||||||
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') {
|
|
||||||
return p.tilesLoaded === true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a per-layer ground-snap cache.
|
|
||||||
* @returns {{
|
|
||||||
* heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3,
|
|
||||||
* getExclusions?: () => Array<unknown>) => number|null,
|
|
||||||
* forget: (icao: string) => void,
|
|
||||||
* clear: () => void,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
export function createGroundSnap() {
|
|
||||||
/** @type {Map<string, {h: number|null, samplePos: Cesium.Cartesian3|null,
|
|
||||||
* held: boolean, nextRetryMs: number, misses: number}>} Per-icao snap state:
|
|
||||||
* the sampled height and the surface point it was measured at, whether that
|
|
||||||
* pair is still the fresh answer or has been demoted to a bounded last-known
|
|
||||||
* (`held`), and the retry backoff a miss earned. */
|
|
||||||
const entries = new Map();
|
|
||||||
let windowStartMs = 0;
|
|
||||||
let windowCount = 0;
|
|
||||||
const scratchCarto = new Cesium.Cartographic();
|
|
||||||
const scratchSurfacePos = new Cesium.Cartesian3();
|
|
||||||
/** Separate from `scratchCarto`: the held-evidence check runs on paths that
|
|
||||||
* return BEFORE the sample writes `scratchCarto`, and on the sampling path it
|
|
||||||
* must not disturb the cartographic that `reportValidatedMeshFloorCell` reads
|
|
||||||
* back after the sample lands. */
|
|
||||||
const scratchHeldCarto = new Cesium.Cartographic();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last measurement for a contact whose fresh snap has been demoted — it
|
|
||||||
* taxied past MOVE_INVALIDATE_M and the resample has not landed yet.
|
|
||||||
*
|
|
||||||
* Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the
|
|
||||||
* spot the value was measured at; past that the memory is DROPPED rather than
|
|
||||||
* stretched, and the contact is back to no evidence (null, model hidden). The
|
|
||||||
* bound is purely spatial on purpose: the ground under a contact that has not
|
|
||||||
* moved does not change with time, so what invalidates the value is the
|
|
||||||
* contact MOVING, which is exactly what this measures.
|
|
||||||
*
|
|
||||||
* @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry
|
|
||||||
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
function heldSnapM(entry, surfacePos) {
|
|
||||||
if (!entry || !entry.held || entry.h == null || !entry.samplePos) return null;
|
|
||||||
if (Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos)
|
|
||||||
> HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M) {
|
|
||||||
return dropHold(entry);
|
|
||||||
}
|
|
||||||
// Held memory never outranks fresh contradicting evidence. Inside the drift
|
|
||||||
// bound the contact can still have taxied onto a different surface — an
|
|
||||||
// apron edge, a ramp, the far side of a rise — and since a miss PRESERVES
|
|
||||||
// the hold, nothing else would ever correct it: the model would stay buried
|
|
||||||
// (or floating) for as long as the resample kept missing.
|
|
||||||
const fresh = freshMeasuredFloorAt(surfacePos);
|
|
||||||
if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) {
|
|
||||||
return dropHold(entry);
|
|
||||||
}
|
|
||||||
return entry.h;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forget a demoted measurement: it no longer describes anywhere this contact
|
|
||||||
* is, so the contact is COLD again (model hidden, floored billboard). */
|
|
||||||
function dropHold(entry) {
|
|
||||||
entry.h = null;
|
|
||||||
entry.samplePos = null;
|
|
||||||
entry.held = false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Independently measured surface height at the contact's CURRENT position, or
|
|
||||||
* null where nothing has measured there yet.
|
|
||||||
*
|
|
||||||
* MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a
|
|
||||||
* `sampleHeight` of the RENDERED skin, and the mesh cells are that same
|
|
||||||
* measurement through the same validation gate (written by this module's own
|
|
||||||
* successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls
|
|
||||||
* back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between
|
|
||||||
* skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for
|
|
||||||
* (15 m / 80 m), so testing a snap against a DEM cell would throw away good
|
|
||||||
* holds over a disagreement that says nothing about where the model sits.
|
|
||||||
*
|
|
||||||
* The contact's OWN cell only. A neighbour is ~111 m away and may be a
|
|
||||||
* terminal roof — that is why `neighborFloorM` leans to the lowest and refuses
|
|
||||||
* to answer from a single sample — and borrowing one to DISCARD a real
|
|
||||||
* measurement would let a roof cell hide a correctly placed model.
|
|
||||||
*
|
|
||||||
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
function freshMeasuredFloorAt(surfacePos) {
|
|
||||||
const carto = Cesium.Cartographic.fromCartesian(
|
|
||||||
surfacePos, Cesium.Ellipsoid.WGS84, scratchHeldCarto,
|
|
||||||
);
|
|
||||||
if (!carto) return null;
|
|
||||||
return cachedMeshFloor(
|
|
||||||
Cesium.Math.toDegrees(carto.latitude),
|
|
||||||
Cesium.Math.toDegrees(carto.longitude),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null
|
|
||||||
* when this contact has NO ground evidence at all (caller keeps the model
|
|
||||||
* hidden and calls again on a later tick — cheap map hit until a retry is due).
|
|
||||||
* A contact that HAS resolved once keeps that answer through a resample outage;
|
|
||||||
* see `heldSnapM`.
|
|
||||||
*
|
|
||||||
* @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check).
|
|
||||||
* @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.
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
function heightFor(viewer, icao, pos, getExclusions) {
|
|
||||||
const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(pos, scratchSurfacePos);
|
|
||||||
if (!surfacePos) return null;
|
|
||||||
const cached = entries.get(icao);
|
|
||||||
if (cached && cached.h != null && cached.samplePos && !cached.held) {
|
|
||||||
if (Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <= MOVE_INVALIDATE_M * MOVE_INVALIDATE_M) {
|
|
||||||
return cached.h;
|
|
||||||
}
|
|
||||||
// Taxied away from the sampled spot. The measurement is DEMOTED to a
|
|
||||||
// bounded last-known rather than deleted, and the resample runs below: a
|
|
||||||
// miss now holds the aircraft on the ground it just left instead of
|
|
||||||
// hiding a model that was already up.
|
|
||||||
cached.held = true;
|
|
||||||
cached.nextRetryMs = 0;
|
|
||||||
cached.misses = 0;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = entries.get(icao);
|
|
||||||
if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force
|
|
||||||
// Per-window budget: deny WITHOUT a retry stamp so the overflow simply
|
|
||||||
// tries again next tick instead of waiting out a backoff it didn't earn.
|
|
||||||
if (now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; }
|
|
||||||
if (windowCount >= SAMPLE_BUDGET_PER_WINDOW) return heldSnapM(entry, surfacePos);
|
|
||||||
const misses = entry ? entry.misses : 0;
|
|
||||||
const miss = () => {
|
|
||||||
// A held measurement survives the miss — only the retry schedule moves.
|
|
||||||
const held = !!(entry && entry.held && entry.h != null && entry.samplePos);
|
|
||||||
const next = {
|
|
||||||
h: held ? entry.h : null,
|
|
||||||
samplePos: held ? entry.samplePos : null,
|
|
||||||
held,
|
|
||||||
nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses),
|
|
||||||
misses: misses + 1,
|
|
||||||
};
|
|
||||||
entries.set(icao, next);
|
|
||||||
return heldSnapM(next, surfacePos);
|
|
||||||
};
|
|
||||||
if (!_tilesReady(viewer)) return miss();
|
|
||||||
windowCount += 1;
|
|
||||||
let sampled;
|
|
||||||
try {
|
|
||||||
const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, scratchCarto);
|
|
||||||
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
|
|
||||||
sampled = viewer.scene.sampleHeight(carto, getExclusions ? getExclusions() : undefined);
|
|
||||||
} catch {
|
|
||||||
sampled = undefined;
|
|
||||||
}
|
|
||||||
// Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the
|
|
||||||
// ellipsoid is pick garbage, not ground.
|
|
||||||
if (!Number.isFinite(sampled) || sampled < -150) return miss();
|
|
||||||
if (meshFloorPreferred()) {
|
|
||||||
reportValidatedMeshFloorCell(
|
|
||||||
Cesium.Math.toDegrees(scratchCarto.latitude),
|
|
||||||
Cesium.Math.toDegrees(scratchCarto.longitude),
|
|
||||||
sampled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// A fresh sample RELEASES any hold: this is a measurement of where the
|
|
||||||
// contact is now, and it outranks a memory of where it was.
|
|
||||||
entries.set(icao, {
|
|
||||||
h: sampled,
|
|
||||||
samplePos: Cesium.Cartesian3.clone(surfacePos),
|
|
||||||
held: false,
|
|
||||||
nextRetryMs: 0,
|
|
||||||
misses: 0,
|
|
||||||
});
|
|
||||||
return sampled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */
|
|
||||||
function forget(icao) { entries.delete(icao); }
|
|
||||||
|
|
||||||
/** Drop everything (layer destroy). */
|
|
||||||
function clear() { entries.clear(); }
|
|
||||||
|
|
||||||
return { heightFor, forget, clear };
|
|
||||||
}
|
|
||||||
@@ -1,165 +1,4 @@
|
|||||||
// src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling
|
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
|
||||||
// (field-test round 4, 2026-07-06).
|
import * as groundFloor from './groundFloor.js';
|
||||||
//
|
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
|
||||||
// The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH,
|
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
|
||||||
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
|
|
||||||
// airport apron) — so DEM-floored sprites/trails still buried themselves in
|
|
||||||
// the mesh while 3D models (which groundSnap against the mesh) looked right.
|
|
||||||
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
|
|
||||||
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
|
|
||||||
//
|
|
||||||
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
|
|
||||||
// tile-load prioritization at the probe point, so sampling is (a) gated to
|
|
||||||
// cells NEAR the viewer — where tiles are streamed and where an error is
|
|
||||||
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
|
|
||||||
// (d) skipped entirely outside the google-3d regime. A returned-undefined
|
|
||||||
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
|
|
||||||
//
|
|
||||||
// Sample acceptance is sanity-gated against the DEM prior when warm (a
|
|
||||||
// vertical probe can hit a rooftop or another aircraft instead of pavement):
|
|
||||||
// the mesh legitimately sits ABOVE bare earth, so the window is asymmetric.
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import {
|
|
||||||
coarseFloorCoord, cachedMeshFloor, reportValidatedMeshFloorCell,
|
|
||||||
setMeshFloorPreferred, meshFloorPreferred, cachedGroundFloor,
|
|
||||||
} from './groundFloor.js';
|
|
||||||
|
|
||||||
/** @constant {number} Max scene samples per call (one call per layer poll). */
|
|
||||||
const MAX_SAMPLES_PER_CALL = 40;
|
|
||||||
/** @constant {number} Only cells within this range of the viewer subpoint are
|
|
||||||
* sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed
|
|
||||||
* fine-LOD area a probe returns COARSE-tile heights (not undefined), and the
|
|
||||||
* one-shot latch made those permanent (owner round 5: grounded planes stuck
|
|
||||||
* at a uniform wrong height). */
|
|
||||||
const MAX_SAMPLE_DIST_KM = 15;
|
|
||||||
/** @constant {number} No sampling when the camera is above this height — the
|
|
||||||
* 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();
|
|
||||||
|
|
||||||
// Regime tracking: mesh cells only apply while the photoreal (google-3d)
|
|
||||||
// stack renders. main.js re-dispatches MapStackController.onChange as this
|
|
||||||
// CustomEvent; the boot default is photoreal, matching groundFloor's initial
|
|
||||||
// preference. Module-scope listener: the module only loads in the browser
|
|
||||||
// bundle, and the subscription is idempotent for the app's lifetime.
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.addEventListener('gev:map-stack-changed', (event) => {
|
|
||||||
const activeId = event?.detail?.activeId;
|
|
||||||
if (activeId) setMeshFloorPreferred(activeId === 'photoreal');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function _approxKm(lat1, lon1, lat2, lon2) {
|
|
||||||
const dLat = (lat2 - lat1) * 111.32;
|
|
||||||
const dLon = (lon2 - lon1) * 111.32 * Math.cos(((lat1 + lat2) / 2) * Math.PI / 180);
|
|
||||||
return Math.hypot(dLat, dLon);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Samples the rendered mesh height for the coarse cells containing `points`,
|
|
||||||
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
|
|
||||||
* layer poll (never per contact, never per frame). Synchronous — Cesium's
|
|
||||||
* sampleHeight is a CPU-side query against loaded tiles.
|
|
||||||
*
|
|
||||||
* @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down).
|
|
||||||
* @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 {number} [options.viewerLat] @param {number} [options.viewerLon]
|
|
||||||
* Viewer subpoint (computed once by the caller's poll).
|
|
||||||
*/
|
|
||||||
export function sampleMeshFloorCells(scene, points, { excludeObjects = [], viewerLat, viewerLon } = {}) {
|
|
||||||
if (!meshFloorPreferred()) return;
|
|
||||||
if (!scene || typeof scene.sampleHeight !== 'function') 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 = scene.camera?.positionCartographic?.height;
|
|
||||||
if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return;
|
|
||||||
if (!_visibleTilesetLoaded(scene)) return;
|
|
||||||
let sampled = 0;
|
|
||||||
const attempted = new Set();
|
|
||||||
for (const p of points) {
|
|
||||||
if (sampled >= MAX_SAMPLES_PER_CALL) break;
|
|
||||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
|
||||||
const cell = coarseFloorCoord(p.lat, p.lon);
|
|
||||||
const key = `${cell.lat},${cell.lon}`;
|
|
||||||
if (attempted.has(key)) continue;
|
|
||||||
attempted.add(key);
|
|
||||||
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
|
|
||||||
if (Number.isFinite(viewerLat) && Number.isFinite(viewerLon) &&
|
|
||||||
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM) {
|
|
||||||
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
|
|
||||||
}
|
|
||||||
let height;
|
|
||||||
try {
|
|
||||||
const carto = Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _scratchProbe);
|
|
||||||
height = scene.sampleHeight(carto, excludeObjects);
|
|
||||||
sampled += 1;
|
|
||||||
} catch {
|
|
||||||
continue; // scene mid-teardown — try again next poll
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch
|
|
||||||
// Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches.
|
|
||||||
// - A geoid-fallback prior (cached while the proxy was failing) is a
|
|
||||||
// sea-level poison that rejected legitimate samples at elevated
|
|
||||||
// fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever).
|
|
||||||
// - No prior at all means no way to tell a real surface from a
|
|
||||||
// coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch
|
|
||||||
// latched a 37 m "surface" at Austin permanently. The DEM lands
|
|
||||||
// within a poll (the same warm batch that queued this cell), so
|
|
||||||
// waiting costs one poll, not correctness.
|
|
||||||
// 2026-08-21: a "provisional" tier that KEPT the reading when no prior
|
|
||||||
// existed was built here and then removed, because it was measured failing.
|
|
||||||
// Run twice against the real GPU at the same Austin apron with the proxy
|
|
||||||
// down, it recorded 122.1 m once and 20.6 m the next time — the second is a
|
|
||||||
// coarse-LOD read of ground that is really ~122 m, and it left the contact
|
|
||||||
// ~100 m under the mesh. The DEM requirement above is not a formality.
|
|
||||||
//
|
|
||||||
// Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is
|
|
||||||
// NOT a resolution signal. It reports that the streaming queue has drained
|
|
||||||
// for the CURRENT camera's screen-space-error target, and a distant or high
|
|
||||||
// camera is entirely satisfied by coarse tiles — which is exactly the state
|
|
||||||
// that reported "loaded" during the measurement above. Gating harder on it
|
|
||||||
// buys nothing. A correct gate has to ask about the specific tile covering
|
|
||||||
// the sample point: walk the tileset tree to the tile containing the
|
|
||||||
// coordinate and require its `geometricError` to be below a threshold tied
|
|
||||||
// to the accuracy the caller needs, before trusting the sample. That is
|
|
||||||
// feasible and deliberately deferred — see the P2 in
|
|
||||||
// the project roadmap, which bundles it with the fly_route probe so
|
|
||||||
// one post-launch pass settles rendered-mesh sampling everywhere.
|
|
||||||
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { cachedGroundFloor }; // re-export for callers that want one import site
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { defaultSurface } from './surfaceServices.js';
|
||||||
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
|
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
|
||||||
|
|
||||||
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||||
import * as militaryRegistry from './militaryRegistry.js';
|
import * as militaryRegistry from './militaryRegistry.js';
|
||||||
|
|
||||||
const militaryFlightsLayer = createApplicationMilitary({
|
const militaryFlightsLayer = createApplicationMilitary({
|
||||||
|
surface: defaultSurface,
|
||||||
source: createAdsbLolSource(),
|
source: createAdsbLolSource(),
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defaultSurface } from './surfaceServices.js';
|
||||||
import { createApplicationInstallations } from '../app/layers/militaryInstallations.js';
|
import { createApplicationInstallations } from '../app/layers/militaryInstallations.js';
|
||||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||||
import { createInstallationSource } from '../layers/installations/index.js';
|
import { createInstallationSource } from '../layers/installations/index.js';
|
||||||
@@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot(
|
|||||||
);
|
);
|
||||||
export const configureInstallationSource = sourceSlot.configure;
|
export const configureInstallationSource = sourceSlot.configure;
|
||||||
const layer = createApplicationInstallations({
|
const layer = createApplicationInstallations({
|
||||||
|
surface: defaultSurface,
|
||||||
source: sourceSlot.source,
|
source: sourceSlot.source,
|
||||||
});
|
});
|
||||||
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
|
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defaultSurface } from './surfaceServices.js';
|
||||||
import { createApplicationRadio } from '../app/layers/radio.js';
|
import { createApplicationRadio } from '../app/layers/radio.js';
|
||||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||||
import { createRadioSource } from '../layers/radio/source.js';
|
import { createRadioSource } from '../layers/radio/source.js';
|
||||||
@@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot(
|
|||||||
);
|
);
|
||||||
export const configureRadioSource = sourceSlot.configure;
|
export const configureRadioSource = sourceSlot.configure;
|
||||||
const layer = createApplicationRadio({
|
const layer = createApplicationRadio({
|
||||||
|
surface: defaultSurface,
|
||||||
source: sourceSlot.source,
|
source: sourceSlot.source,
|
||||||
});
|
});
|
||||||
export const radioGlobeLabel = layer.radioGlobeLabel;
|
export const radioGlobeLabel = layer.radioGlobeLabel;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as terrain from './terrainHeights.js';
|
||||||
|
import * as groundFloor from './groundFloor.js';
|
||||||
|
import * as meshFloor from './meshFloorSampler.js';
|
||||||
|
import * as groundSnap from './groundSnap.js';
|
||||||
|
import * as anchors from './fireAnchors.js';
|
||||||
|
/** Default scene services for direct data-module compatibility callers. */
|
||||||
|
export const defaultSurface = Object.freeze({
|
||||||
|
terrain,
|
||||||
|
groundFloor,
|
||||||
|
meshFloor,
|
||||||
|
groundSnap,
|
||||||
|
anchors,
|
||||||
|
});
|
||||||
+3
-242
@@ -1,243 +1,4 @@
|
|||||||
|
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||||
import { applicationServices } from '../services/application.js';
|
import { applicationServices } from '../services/application.js';
|
||||||
// src/data/terrainHeights.js — batched, cached client terrain-height resolver
|
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain });
|
||||||
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
|
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights;
|
||||||
//
|
|
||||||
// Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side
|
|
||||||
// `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached,
|
|
||||||
// serve-stale). When the proxy is unreachable (or errors) AND the point isn't
|
|
||||||
// already warm in this module's in-memory cache, falls back to bundled-geoid
|
|
||||||
// math (Task 1, `src/data/geoid.js`):
|
|
||||||
//
|
|
||||||
// sourceOrthometricM (if finite) + geoidHeight(lat, lon) → source:'geoid-fallback'
|
|
||||||
// else geoidHeight(lat, lon) alone (H≈0 "at the geoid/coast" prior) → source:'geoid-fallback'
|
|
||||||
//
|
|
||||||
// Two things learned building the Task 2 proxy (see its report, and the
|
|
||||||
// ledger's T2 entry) that this module builds in:
|
|
||||||
//
|
|
||||||
// 1. The proxy's GET has a Node http header-size ceiling — empirically a
|
|
||||||
// single request much past ~700-1500 points (depending on coordinate
|
|
||||||
// precision) risks a raw socket-level 431 *before* the proxy's own
|
|
||||||
// request handler runs. So every network request this module issues is
|
|
||||||
// chunked at CHUNK_SIZE (64 points), well under that ceiling, and
|
|
||||||
// chunks are sent SEQUENTIALLY (not in parallel) to keep this client
|
|
||||||
// well-behaved against a single dev-server proxy.
|
|
||||||
// 2. The proxy's disk cache is keyed by the raw `points` query string, with
|
|
||||||
// no eviction — so a client that varies coordinate precision or point
|
|
||||||
// order defeats the warm cache and grows the cache file unbounded. This
|
|
||||||
// module rounds every coordinate to 5 decimal places (~1.1 m of
|
|
||||||
// precision at the equator — comfortably tighter than any of this app's
|
|
||||||
// placement needs) BOTH for its own in-memory cache key AND for the
|
|
||||||
// points string sent to the proxy, so repeated calls (including calls
|
|
||||||
// from a fresh page load, a different camera batch, etc.) consistently
|
|
||||||
// hit the same warm proxy cache entry.
|
|
||||||
import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
|
||||||
|
|
||||||
/** Max points per outgoing request to `/api/terrain/heights` (see file header, point 1). */
|
|
||||||
// Match the server's upstream batch so sequential upstream work also fits
|
|
||||||
// within this client's 30-second request deadline when Re:Earth slows.
|
|
||||||
const CHUNK_SIZE = 64;
|
|
||||||
|
|
||||||
/** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */
|
|
||||||
const GEOID_FALLBACK_COOLDOWN_MS = 60_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`.
|
|
||||||
* Module-scoped (not exported) — the only reads are through
|
|
||||||
* `cachedEllipsoidalGround` and the internal lookup in
|
|
||||||
* `resolveEllipsoidalGround`.
|
|
||||||
* @type {Map<string, {ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}>}
|
|
||||||
*/
|
|
||||||
const cache = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the rounded cache key shared between the in-memory cache and the
|
|
||||||
* `points` string sent to the proxy (see file header, point 2).
|
|
||||||
* @param {number} lat
|
|
||||||
* @param {number} lon
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function cacheKey(lat, lon) {
|
|
||||||
return `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous read of a previously resolved ellipsoidal ground height.
|
|
||||||
* Returns null if this exact (rounded) coordinate hasn't been resolved yet
|
|
||||||
* by either the proxy path or the geoid-fallback path.
|
|
||||||
* @param {number} lat
|
|
||||||
* @param {number} lon
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
export function cachedEllipsoidalGround(lat, lon) {
|
|
||||||
const entry = cache.get(cacheKey(lat, lon));
|
|
||||||
return entry ? entry.ellipsoid : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Field-test round 5 (2026-07-06, the "sea-level poison"): like
|
|
||||||
* cachedEllipsoidalGround but returns null for geoid-FALLBACK entries —
|
|
||||||
* only a real Re:Earth value counts. A fallback (cached when the proxy
|
|
||||||
* failed mid-burst) is the geoid surface, which at Austin sits ~165 m below
|
|
||||||
* the airport: floors built on it sank every sprite/trail, and the mesh
|
|
||||||
* sampler's sanity gate rejected REAL surface samples against it. Floor
|
|
||||||
* consumers read THIS; the plain read stays for display-only consumers.
|
|
||||||
* @param {number} lat
|
|
||||||
* @param {number} lon
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
export function cachedRealEllipsoidalGround(lat, lon) {
|
|
||||||
const entry = cache.get(cacheKey(lat, lon));
|
|
||||||
return entry && entry.source === 'reearth' ? entry.ellipsoid : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches one chunk (<=CHUNK_SIZE points) from the `/api/terrain/heights`
|
|
||||||
* proxy. Returns a Map keyed by the same rounded cache key so callers can
|
|
||||||
* look results up positionally-independent of upstream response ordering.
|
|
||||||
* Throws on any failure (non-ok response, network error, malformed body) —
|
|
||||||
* callers decide the fallback behavior per point.
|
|
||||||
* @param {Array<{key: string, lat: number, lon: number}>} chunk
|
|
||||||
* @returns {Promise<Map<string, number>>} key -> ellipsoid height (m)
|
|
||||||
*/
|
|
||||||
async function fetchChunk(chunk) {
|
|
||||||
// lon,lat order (matches the proxy's documented `points=lon,lat;…` contract
|
|
||||||
// and Task 2's implementation).
|
|
||||||
const results = await applicationServices.terrain.getHeights(chunk, { signal: AbortSignal.timeout(30000) });
|
|
||||||
const body = { results };
|
|
||||||
if (!Array.isArray(body?.results)) throw new Error('malformed terrain heights response (no results array)');
|
|
||||||
if (body.results.length !== chunk.length) {
|
|
||||||
throw new Error(
|
|
||||||
`terrain heights response length mismatch (expected ${chunk.length}, got ${body.results.length})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const out = new Map();
|
|
||||||
// The proxy is documented to preserve input order (Task 2 report verifies
|
|
||||||
// this against the live upstream), so map positionally rather than trust
|
|
||||||
// the response's own lon/lat fields to re-match — those are also present
|
|
||||||
// for callers who want them, but this module doesn't need them.
|
|
||||||
for (let i = 0; i < chunk.length; i += 1) {
|
|
||||||
const ellipsoid = body.results[i]?.ellipsoid;
|
|
||||||
if (!Number.isFinite(ellipsoid)) throw new Error(`non-finite ellipsoid height at index ${i}`);
|
|
||||||
out.set(chunk[i].key, ellipsoid);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the geoid-fallback ellipsoidal height for one point per the
|
|
||||||
* brief's fallback chain: `sourceOrthometricM + geoidHeight` when a finite
|
|
||||||
* orthometric source height is available, else `geoidHeight` alone (treats
|
|
||||||
* the point as if it were at the geoid, i.e. H≈0 — a coast-level prior).
|
|
||||||
* Assumes `ensureGeoidReady()` has already resolved.
|
|
||||||
* @param {number} lat
|
|
||||||
* @param {number} lon
|
|
||||||
* @param {number} [sourceOrthometricM]
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function geoidFallback(lat, lon, sourceOrthometricM) {
|
|
||||||
const n = geoidHeight(lat, lon);
|
|
||||||
return Number.isFinite(sourceOrthometricM) ? sourceOrthometricM + n : n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves ellipsoidal ground height for a batch of coordinates, in order.
|
|
||||||
*
|
|
||||||
* - Results already warm in the in-memory cache are returned without any
|
|
||||||
* network call.
|
|
||||||
* - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights`
|
|
||||||
* in sequential chunks of <=64 points.
|
|
||||||
* - If a chunk request fails (network error, non-ok HTTP, malformed body),
|
|
||||||
* every point in THAT chunk falls back to geoid math
|
|
||||||
* (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than
|
|
||||||
* failing the whole batch — a transient proxy outage in the middle of a
|
|
||||||
* large batch shouldn't blank out entities whose chunk happened to land
|
|
||||||
* earlier or later.
|
|
||||||
* - Every resolved value (proxy or fallback) is written back into the
|
|
||||||
* in-memory cache before this function returns, so a later call — even
|
|
||||||
* with a real proxy round-trip pending — sees a warm hit.
|
|
||||||
*
|
|
||||||
* @param {Array<{lat:number, lon:number, sourceOrthometricM?:number}>} coords
|
|
||||||
* @returns {Promise<Array<{ellipsoid:number, source:'reearth'|'geoid-fallback'}>>}
|
|
||||||
* Same length and order as `coords`.
|
|
||||||
*/
|
|
||||||
export async function resolveEllipsoidalGround(coords) {
|
|
||||||
if (!Array.isArray(coords) || coords.length === 0) return [];
|
|
||||||
|
|
||||||
// Build the per-input work list (key + original index) up front so the
|
|
||||||
// final assembly can map back positionally regardless of how many inputs
|
|
||||||
// shared a cache key or a chunk.
|
|
||||||
const work = coords.map((c, index) => ({
|
|
||||||
index,
|
|
||||||
lat: c.lat,
|
|
||||||
lon: c.lon,
|
|
||||||
sourceOrthometricM: c.sourceOrthometricM,
|
|
||||||
key: cacheKey(c.lat, c.lon),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Points not yet warm in the cache, deduplicated by key (multiple inputs —
|
|
||||||
// even within the same call — naming the same rounded coordinate should
|
|
||||||
// only ever hit the network once). Round 5: a geoid-FALLBACK entry does
|
|
||||||
// NOT count as permanently warm — it was cached when the proxy failed
|
|
||||||
// mid-burst and (at elevated terrain) is a sea-level poison that sank every
|
|
||||||
// floor built on it. Give failed keys a short cooldown before re-requesting
|
|
||||||
// so warm consumers do not hammer a failing proxy; after it expires, a
|
|
||||||
// successful response replaces the fallback entry and clears the path.
|
|
||||||
const uncached = [];
|
|
||||||
const seenKeys = new Set();
|
|
||||||
const now = Date.now();
|
|
||||||
for (const item of work) {
|
|
||||||
const entry = cache.get(item.key);
|
|
||||||
const fallbackCooling = entry?.source === 'geoid-fallback'
|
|
||||||
&& Number.isFinite(entry.retryAt)
|
|
||||||
&& now < entry.retryAt;
|
|
||||||
if ((entry && entry.source === 'reearth') || fallbackCooling || seenKeys.has(item.key)) continue;
|
|
||||||
seenKeys.add(item.key);
|
|
||||||
uncached.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
|
|
||||||
// failure is isolated to that chunk's points (geoid fallback), so a single
|
|
||||||
// bad chunk doesn't lose results for the rest of a large batch.
|
|
||||||
for (let i = 0; i < uncached.length; i += CHUNK_SIZE) {
|
|
||||||
const chunk = uncached.slice(i, i + CHUNK_SIZE);
|
|
||||||
try {
|
|
||||||
const resolved = await fetchChunk(chunk);
|
|
||||||
for (const item of chunk) {
|
|
||||||
const ellipsoid = resolved.get(item.key);
|
|
||||||
// Round 6: only a FINITE value may be cached as 'reearth'. A point
|
|
||||||
// the upstream response omitted used to cache {ellipsoid: undefined,
|
|
||||||
// source: 'reearth'} — permanently "warm" yet empty, so its floor
|
|
||||||
// read null forever and every later warm skipped it (ATL verify:
|
|
||||||
// one contact frozen at the geoid while its neighbors resolved).
|
|
||||||
// An omitted point now caches nothing and retries on the next warm.
|
|
||||||
if (Number.isFinite(ellipsoid)) {
|
|
||||||
cache.set(item.key, { ellipsoid, source: 'reearth' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Proxy down (or cold cache had nothing to serve-stale) — fall back to
|
|
||||||
// geoid math for every point in this chunk. `ensureGeoidReady()` is
|
|
||||||
// awaited lazily, only on the fallback path, so the common (proxy
|
|
||||||
// healthy) case never pays for the geoid grid's dynamic import.
|
|
||||||
await ensureGeoidReady();
|
|
||||||
for (const item of chunk) {
|
|
||||||
const ellipsoid = geoidFallback(item.lat, item.lon, item.sourceOrthometricM);
|
|
||||||
cache.set(item.key, {
|
|
||||||
ellipsoid,
|
|
||||||
source: 'geoid-fallback',
|
|
||||||
retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assemble the output in the original input order from the cache. A point
|
|
||||||
// the upstream omitted has NO entry (round 6 — deliberately uncached so it
|
|
||||||
// retries later): report it unresolved instead of throwing.
|
|
||||||
return work.map((item) => {
|
|
||||||
const entry = cache.get(item.key);
|
|
||||||
return entry
|
|
||||||
? { ellipsoid: entry.ellipsoid, source: entry.source }
|
|
||||||
: { ellipsoid: null, source: 'unresolved' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+3
-2
@@ -66,7 +66,8 @@ export class IntelHUD {
|
|||||||
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
|
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
|
||||||
* camera telemetry and coordinate derivation.
|
* camera telemetry and coordinate derivation.
|
||||||
*/
|
*/
|
||||||
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {} } = {}) {
|
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) {
|
||||||
|
this.summaryService = summaryService;
|
||||||
this.summaryPolicy = summaryPolicy;
|
this.summaryPolicy = summaryPolicy;
|
||||||
this.basemapContext = basemapContext;
|
this.basemapContext = basemapContext;
|
||||||
this.placeSearch = placeSearch;
|
this.placeSearch = placeSearch;
|
||||||
@@ -664,7 +665,7 @@ export class IntelHUD {
|
|||||||
this._summaryRequest = controller;
|
this._summaryRequest = controller;
|
||||||
try {
|
try {
|
||||||
this.summaryPolicy.onRequest?.();
|
this.summaryPolicy.onRequest?.();
|
||||||
const response = await applicationServices.summary.summarize(context, { signal: controller.signal });
|
const response = await this.summaryService.summarize(context, { signal: controller.signal });
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (revision !== this._summaryRevision) return;
|
if (revision !== this._summaryRevision) return;
|
||||||
if (isHudSummaryUnconfigured(response.status, data)) {
|
if (isHudSummaryUnconfigured(response.status, data)) {
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ test('accents and punctuation do not change what a name compares as', () => {
|
|||||||
|
|
||||||
test('forward geocoding is composed once and consumers do not call a source directly', () => {
|
test('forward geocoding is composed once and consumers do not call a source directly', () => {
|
||||||
for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) {
|
for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) {
|
||||||
const source = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8');
|
const source = readGeocodingConsumer(file);
|
||||||
assert.match(source, /placeSearch\.geocode\(/);
|
assert.match(source, /placeSearch\.geocode\(/);
|
||||||
assert.doesNotMatch(source, /geocodeKeyless|geocode\/json\?address/);
|
assert.doesNotMatch(source, /geocodeKeyless|geocode\/json\?address/);
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,7 @@ test('a missing key is never a thrown error on the client', () => {
|
|||||||
// It was one, in the Radio layer: a keyless install surfaced "play radio near
|
// It was one, in the Radio layer: a keyless install surfaced "play radio near
|
||||||
// Hanoi" as a failed voice turn rather than as a station it could not place.
|
// Hanoi" as a failed voice turn rather than as a station it could not place.
|
||||||
for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) {
|
for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) {
|
||||||
const body = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8');
|
const body = readGeocodingConsumer(file);
|
||||||
assert.doesNotMatch(body, /throw new Error\([^)]*No Google Maps API key/, `${file} throws on a missing key`);
|
assert.doesNotMatch(body, /throw new Error\([^)]*No Google Maps API key/, `${file} throws on a missing key`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -446,3 +446,9 @@ test('a biased pass that never answered leaves the whole lookup unanswered', asy
|
|||||||
assert.deepEqual(outcome, { place: null, answered: false });
|
assert.deepEqual(outcome, { place: null, answered: false });
|
||||||
assert.equal(urls.length, 2);
|
assert.equal(urls.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function readGeocodingConsumer(file) {
|
||||||
|
const files = file === 'annotations/annotationResolver.js'
|
||||||
|
? [file, 'annotations/resolver.js'] : [file];
|
||||||
|
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
|
||||||
|
}
|
||||||
+4
-4
@@ -794,7 +794,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
|
|||||||
let viewport = result?.viewport || null;
|
let viewport = result?.viewport || null;
|
||||||
|
|
||||||
// Nearby landmark recovery retains precedence over a fallback geocoder hit.
|
// Nearby landmark recovery retains precedence over a fallback geocoder hit.
|
||||||
const recovered = await placesNearViewRecovery(
|
const recovered = await (options.recoverNearView || placesNearViewRecovery)(
|
||||||
viewer,
|
viewer,
|
||||||
query,
|
query,
|
||||||
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
||||||
@@ -885,7 +885,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
|
|||||||
|
|
||||||
const shouldResolveBuilding = navigationMode === 'precise-place';
|
const shouldResolveBuilding = navigationMode === 'precise-place';
|
||||||
const buildingBounds = shouldResolveBuilding
|
const buildingBounds = shouldResolveBuilding
|
||||||
? await resolveBuildingBounds(lat, lng, query)
|
? await resolveBuildingBounds(lat, lng, query, options.boundaries)
|
||||||
: null;
|
: null;
|
||||||
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
|
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
|
||||||
if (!mayFly()) return CANCELLED_SEARCH;
|
if (!mayFly()) return CANCELLED_SEARCH;
|
||||||
@@ -1330,7 +1330,7 @@ function buildingPitch(bounds) {
|
|||||||
return -32;
|
return -32;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBuildingBounds(lat, lon, query) {
|
async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) {
|
||||||
const overpassQuery = `
|
const overpassQuery = `
|
||||||
[out:json][timeout:10];
|
[out:json][timeout:10];
|
||||||
(
|
(
|
||||||
@@ -1346,7 +1346,7 @@ async function resolveBuildingBounds(lat, lon, query) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
||||||
try {
|
try {
|
||||||
const elements = await applicationServices.boundaries.query(overpassQuery, { signal: controller.signal });
|
const elements = await boundaries.query(overpassQuery, { signal: controller.signal });
|
||||||
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query);
|
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
/** Construct an instance-owned groundFloor service with explicit dependencies. */
|
||||||
|
export function createGroundFloor({ terrain, signal }) {
|
||||||
|
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
|
||||||
|
// heights (field-test round 2026-07-06).
|
||||||
|
//
|
||||||
|
// Two owner findings drove this module:
|
||||||
|
// - RS46 (military H60): baro-only low-altitude contacts near steep terrain
|
||||||
|
// render INSIDE the hillside (no alt_geom → baro+N is off by more than the
|
||||||
|
// local relief).
|
||||||
|
// - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a
|
||||||
|
// fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB
|
||||||
|
// (field elevation ~1590 m ellipsoidal).
|
||||||
|
//
|
||||||
|
// The fix everywhere is the same: never render below the local ellipsoidal
|
||||||
|
// surface. This module provides the shared floor: a COARSE (3-decimal,
|
||||||
|
// ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving
|
||||||
|
// aircraft and multi-hundred-point trails collapse onto a small set of cells
|
||||||
|
// with heavy cache reuse (the exact 5-decimal grid would mint a new cache key
|
||||||
|
// every fix — the taxiing-cache lesson from the adversarial review).
|
||||||
|
// A ~111 m cell means the floor can be off by the relief within the cell in
|
||||||
|
// steep terrain; the clamp is a visual floor, not a survey — lifting an icon
|
||||||
|
// a few metres short of a ridge line still beats burying it.
|
||||||
|
//
|
||||||
|
// Zero-network on the render path: reads are synchronous cache lookups;
|
||||||
|
// `warmGroundFloor` batches the misses through resolveEllipsoidalGround
|
||||||
|
// (single-flight, fire-and-forget) exactly like flights.js's grounded-surface
|
||||||
|
// warm — results land for later polls/appends.
|
||||||
|
|
||||||
|
/** @constant {number} Metres added above the resolved floor so a clamped
|
||||||
|
* billboard/waypoint sits ON the surface instead of z-fighting it. */
|
||||||
|
const GROUND_FLOOR_LIFT_M = 1.5;
|
||||||
|
/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */
|
||||||
|
const MESH_FLOOR_BELOW_PRIOR_M = 15;
|
||||||
|
const MESH_FLOOR_ABOVE_PRIOR_M = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid.
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @returns {{lat: number, lon: number}} Grid-cell coordinate.
|
||||||
|
*/
|
||||||
|
function coarseFloorCoord(lat, lon) {
|
||||||
|
return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure floor clamp.
|
||||||
|
* - finite alt + finite ground → max(alt, ground + lift)
|
||||||
|
* - finite alt + unknown ground → alt (no data, no clamp)
|
||||||
|
* - null alt + finite ground → ground + lift (the 'ground'-waypoint case:
|
||||||
|
* the point IS on the surface; put it there)
|
||||||
|
* - null alt + unknown ground → null (caller applies its own fallback)
|
||||||
|
* @param {number|null|undefined} altM - Proposed ellipsoidal render height.
|
||||||
|
* @param {number|null|undefined} groundM - Local ellipsoidal ground, if known.
|
||||||
|
* @param {number} [liftM] - Clearance above the floor.
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) {
|
||||||
|
const hasAlt = Number.isFinite(altM);
|
||||||
|
const hasGround = Number.isFinite(groundM);
|
||||||
|
if (hasAlt && hasGround) return Math.max(altM, groundM + liftM);
|
||||||
|
if (hasAlt) return altM;
|
||||||
|
if (hasGround) return groundM + liftM;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor decision at the point of DISPLAY (2026-08-19).
|
||||||
|
*
|
||||||
|
* `floorAltitudeM` above answers "what height should this FIX render at" and
|
||||||
|
* runs once per poll. But what the user sees is the DEAD-RECKONED position:
|
||||||
|
* a Cartesian interpolation between two fixes for the whole segment, and a
|
||||||
|
* velocity extrapolation of the newest fix while a contact coasts through its
|
||||||
|
* stale-feed grace. Both drift the rendered point horizontally away from the
|
||||||
|
* fix whose cell supplied the height — across a graded apron (KAUS spans
|
||||||
|
* ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it
|
||||||
|
* has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`).
|
||||||
|
*
|
||||||
|
* This is the same "never below the visible surface" rule applied at the
|
||||||
|
* displayed coordinate instead of the fix coordinate. It returns `null` rather
|
||||||
|
* than the unchanged height whenever nothing should move, so the per-frame
|
||||||
|
* caller can skip rebuilding a Cartesian entirely — the common case by far.
|
||||||
|
*
|
||||||
|
* @param {number|null|undefined} displayHeightM - Ellipsoidal height the
|
||||||
|
* dead-reckoned display position currently carries.
|
||||||
|
* @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED
|
||||||
|
* coordinate (`cachedGroundFloor`), or null when that cell is not warm.
|
||||||
|
* @param {number} [liftM] - Clearance above the floor.
|
||||||
|
* @returns {number|null} The height to render at, or null for "leave it alone".
|
||||||
|
*/
|
||||||
|
function displayFloorHeightM(
|
||||||
|
displayHeightM,
|
||||||
|
floorM,
|
||||||
|
liftM = GROUND_FLOOR_LIFT_M,
|
||||||
|
) {
|
||||||
|
if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM))
|
||||||
|
return null;
|
||||||
|
const lifted = floorM + liftM;
|
||||||
|
return displayHeightM < lifted ? lifted : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @constant {number} Pitch of the coarse floor grid in degrees — the spacing
|
||||||
|
* `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */
|
||||||
|
const FLOOR_CELL_DEG = 0.001;
|
||||||
|
|
||||||
|
/** @constant {number} Resolved neighbours required before one may be borrowed.
|
||||||
|
* A single reading cannot be checked against anything: a hangar roof and an
|
||||||
|
* apron are both "a warm cell 111 m away", and there is no way to tell them
|
||||||
|
* apart from one number. Two readings at least agree or disagree. */
|
||||||
|
const NEIGHBOR_FLOOR_MIN_SAMPLES = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A floor for `cell` borrowed from the eight cells ADJACENT to it.
|
||||||
|
*
|
||||||
|
* Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the
|
||||||
|
* Re:Earth proxy timed out four times in a row and a grounded contact standing
|
||||||
|
* on a cold cell rendered at the geoid — tens of metres under the mesh it was
|
||||||
|
* parked on). A neighbour is ~111 m away; on the aprons and taxiways where
|
||||||
|
* grounded contacts live that is the same surface, and it is a MEASURED height
|
||||||
|
* rather than an invented one.
|
||||||
|
*
|
||||||
|
* Leans to the LOWEST resolved neighbour, and refuses to answer at all from
|
||||||
|
* fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them.
|
||||||
|
*
|
||||||
|
* An earlier cut leaned HIGH, reasoning from the locked "never below the
|
||||||
|
* visible surface" principle. That principle is about a contact's OWN measured
|
||||||
|
* ground; applied to a BORROWED cell it inverts, and an owner playtest found
|
||||||
|
* why — planes floating in midair at terminal gates. The two errors are not
|
||||||
|
* symmetric:
|
||||||
|
* - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a
|
||||||
|
* floor under the contact simply does not lift it — the same "no clamp" the
|
||||||
|
* cold-cell case already has, bounded by one cell of grade (metres).
|
||||||
|
* - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT,
|
||||||
|
* not by grade, and a parked contact holds it: measured on the A/B rig,
|
||||||
|
* a lone roof neighbour put a contact 29.5 m up and left it there
|
||||||
|
* permanently when its own cell never warmed, and 6.3 s of visible
|
||||||
|
* stair-stepping when it did.
|
||||||
|
* A plane at a gate is on the apron, never on the terminal roof, so on a
|
||||||
|
* structure edge the LOW neighbour is the answer and the high one is an
|
||||||
|
* artefact of the photogrammetric mesh.
|
||||||
|
*
|
||||||
|
* The honest residual: on a genuine SLOPE the lowest neighbour under-reads by
|
||||||
|
* the grade across ~111 m, so the clamp lifts a little less than it could. That
|
||||||
|
* shows up as no lift rather than as a wrong one, and the contact's own cell
|
||||||
|
* corrects it as soon as it warms.
|
||||||
|
*
|
||||||
|
* @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell.
|
||||||
|
* @returns {number|null} Ellipsoidal floor, or null when too few neighbours are
|
||||||
|
* warm to borrow from.
|
||||||
|
*/
|
||||||
|
function neighborFloorM(cell) {
|
||||||
|
if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null;
|
||||||
|
let lowest = null;
|
||||||
|
let resolved = 0;
|
||||||
|
for (let dLat = -1; dLat <= 1; dLat += 1) {
|
||||||
|
for (let dLon = -1; dLon <= 1; dLon += 1) {
|
||||||
|
if (dLat === 0 && dLon === 0) continue;
|
||||||
|
const h = cachedGroundFloor(
|
||||||
|
cell.lat + dLat * FLOOR_CELL_DEG,
|
||||||
|
cell.lon + dLon * FLOOR_CELL_DEG,
|
||||||
|
);
|
||||||
|
if (h == null) continue;
|
||||||
|
resolved += 1;
|
||||||
|
if (lowest == null || h < lowest) lowest = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @constant {number} Cap on cells one corridor may contribute — a fast rollout
|
||||||
|
* or a spliced fix must not turn one contact into an unbounded warm batch.
|
||||||
|
* Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m
|
||||||
|
* (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */
|
||||||
|
const CORRIDOR_MAX_CELLS = 12;
|
||||||
|
/** @constant {number} Step along a corridor leg when collecting cells (~14 m,
|
||||||
|
* an eighth of a cell). Half-cell steps were only cell-complete on LONG legs:
|
||||||
|
* a short leg rounds to a single step, so the walk degenerates to testing the
|
||||||
|
* leg's endpoints and a cell clipped between them is skipped. At this step any
|
||||||
|
* cell the path occupies for ~14 m of ground or more contains a sample — the
|
||||||
|
* guarantee this walk actually makes. The loop still exits the moment the cell
|
||||||
|
* budget fills, so a finer step costs iterations only on short corridors. */
|
||||||
|
const CORRIDOR_WALK_STEP_DEG = 0.000125;
|
||||||
|
/** @constant {number} Degrees added around a cell before a contact is allowed
|
||||||
|
* to adopt the next one (~22 m). A dead-reckoned position that jitters across
|
||||||
|
* a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop
|
||||||
|
* the sprite between two heights. */
|
||||||
|
const CELL_HYSTERESIS_DEG = 0.0002;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The floor cell a contact should read, with boundary hysteresis.
|
||||||
|
*
|
||||||
|
* Cells are 0.001° squares, so a display position sitting on an edge (parked
|
||||||
|
* GPS jitter, the tracked reconciliation's decaying correction) flips between
|
||||||
|
* two cells frame to frame — and their floors can differ by the local grade or,
|
||||||
|
* next to a building, several metres. The contact keeps its previous cell until
|
||||||
|
* it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two
|
||||||
|
* comparisons and no allocation on the sticky path.
|
||||||
|
*
|
||||||
|
* @param {number} lat @param {number} lon - Displayed coordinate.
|
||||||
|
* @param {{lat: number, lon: number}|null|undefined} previousCell - The cell
|
||||||
|
* this contact read last tick, if any.
|
||||||
|
* @returns {{lat: number, lon: number}} The cell to read.
|
||||||
|
*/
|
||||||
|
function stickyFloorCell(lat, lon, previousCell) {
|
||||||
|
if (
|
||||||
|
previousCell &&
|
||||||
|
Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
|
||||||
|
Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG
|
||||||
|
) {
|
||||||
|
return previousCell;
|
||||||
|
}
|
||||||
|
return coarseFloorCoord(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The coarse cells a contact's DISPLAY is about to walk through.
|
||||||
|
*
|
||||||
|
* The dead-reckoned display runs one render delay behind and advances toward
|
||||||
|
* the newest fix, so the segment [display → newest fix] is exactly the ground
|
||||||
|
* it will render over during the next poll or two. Warming only the cell it is
|
||||||
|
* standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a
|
||||||
|
* ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it
|
||||||
|
* stays permanently ahead of its own floor data and the display clamp has
|
||||||
|
* nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the
|
||||||
|
* whole observation).
|
||||||
|
*
|
||||||
|
* Walks in half-cell steps so no cell between the ends is skipped. The step
|
||||||
|
* COUNT follows the segment length (never a fixed cap — a capped count over a
|
||||||
|
* long segment samples sparsely and leaves holes in the middle of ground the
|
||||||
|
* contact does cross). Length is bounded instead, and honestly: the walk emits
|
||||||
|
* a GAP-FREE PREFIX from `from`, and the destination cell always takes the last
|
||||||
|
* slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated —
|
||||||
|
* contiguous near ground plus the far endpoint — rather than thinned; the
|
||||||
|
* ground in between is picked up on a later poll as the display advances into
|
||||||
|
* it. Loop work stays bounded because the walk exits as soon as the cell budget
|
||||||
|
* fills, whatever the segment length.
|
||||||
|
*
|
||||||
|
* Takes the display PATH as a polyline, not two endpoints: while the contact
|
||||||
|
* is extrapolating, that path is an arc (the dead-reckon integrates a
|
||||||
|
* constant-rate turn), and walking a straight chord between its ends would
|
||||||
|
* warm ground the contact never crosses while its actual arc stayed cold.
|
||||||
|
*
|
||||||
|
* @param {Array<{lat: number, lon: number}>} points - Display path, current
|
||||||
|
* position first, destination last.
|
||||||
|
* @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start
|
||||||
|
* first, destination last.
|
||||||
|
*/
|
||||||
|
function corridorFloorCells(points) {
|
||||||
|
if (!Array.isArray(points) || !points.length) return [];
|
||||||
|
const first = points[0];
|
||||||
|
if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return [];
|
||||||
|
const start = coarseFloorCoord(first.lat, first.lon);
|
||||||
|
const out = [start];
|
||||||
|
const seen = new Set([`${start.lat},${start.lon}`]);
|
||||||
|
let destination = null;
|
||||||
|
for (let i = points.length - 1; i >= 1; i--) {
|
||||||
|
if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) {
|
||||||
|
destination = coarseFloorCoord(points[i].lat, points[i].lon);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!destination) return out;
|
||||||
|
const destKey = `${destination.lat},${destination.lon}`;
|
||||||
|
// One slot is reserved for the destination, appended after the prefix.
|
||||||
|
const prefixLimit = CORRIDOR_MAX_CELLS - 1;
|
||||||
|
walk: for (let s = 0; s < points.length - 1; s++) {
|
||||||
|
const a = points[s];
|
||||||
|
const b = points[s + 1];
|
||||||
|
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
|
||||||
|
const steps = Math.ceil(
|
||||||
|
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) /
|
||||||
|
CORRIDOR_WALK_STEP_DEG,
|
||||||
|
);
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
if (out.length >= prefixLimit) break walk;
|
||||||
|
const t = i / steps;
|
||||||
|
const cell = coarseFloorCoord(
|
||||||
|
a.lat + (b.lat - a.lat) * t,
|
||||||
|
a.lon + (b.lon - a.lon) * t,
|
||||||
|
);
|
||||||
|
const key = `${cell.lat},${cell.lon}`;
|
||||||
|
if (seen.has(key) || key === destKey) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seen.has(destKey)) out.push(destination);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shares one poll's corridor-cell budget across the contacts that need it.
|
||||||
|
*
|
||||||
|
* The first cut walked contacts in Map insertion order and decremented the
|
||||||
|
* budget BEFORE deduping, so parked contacts burned slots on cells the poll had
|
||||||
|
* already collected and the contacts actually outrunning their floor data
|
||||||
|
* starved. Three rules fix that:
|
||||||
|
* 1. Cells already collected are FREE — a parked contact, whose corridor is
|
||||||
|
* its own fix cell, never competes for budget at all.
|
||||||
|
* 2. Candidates are ordered by how many of their cells are genuinely cold,
|
||||||
|
* then by speed (whoever leaves their floor behind soonest).
|
||||||
|
* 3. Allocation runs in widening rounds — one cell each, then `fairShare`
|
||||||
|
* each, then the remainder — so no contact is left with NOTHING while
|
||||||
|
* another takes seconds. The nearest cell is the one a contact needs first
|
||||||
|
* anyway; the far end of its corridor can wait a poll.
|
||||||
|
*
|
||||||
|
* @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates
|
||||||
|
* @param {Set<string>} seen - Keys ("lat,lon") already collected; MUTATED with
|
||||||
|
* everything this call allocates, so the caller's dedupe stays consistent.
|
||||||
|
* Ranking alone still starves a TIE longer than the budget: 80 equally needy
|
||||||
|
* movers means the same stable prefix wins every poll and the tail never moves.
|
||||||
|
* The `epoch` rotates each run of equal-ranked candidates, so a tie cycles
|
||||||
|
* round-robin across polls while a genuinely needier contact is never demoted
|
||||||
|
* below a less needy one (rotation happens WITHIN a rank, never across ranks).
|
||||||
|
*
|
||||||
|
* `seen` alone is NOT a sufficient free-list: in production it holds only the
|
||||||
|
* CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and
|
||||||
|
* spent budget again, letting a contact's near cells consume its whole share
|
||||||
|
* poll after poll while the ground ahead of it stayed cold. Budget is therefore
|
||||||
|
* charged only for cells with no floor yet (`isWarm`) — but warm cells are
|
||||||
|
* still EMITTED, because the mesh sampler must see a cell again after its DEM
|
||||||
|
* prior lands or it never latches a rendered-surface height there.
|
||||||
|
*
|
||||||
|
* That gives a service bound derivable from the policy rather than a magic
|
||||||
|
* number: the first round charges at most one cell per candidate, so at least
|
||||||
|
* `min(candidates, budget)` candidates are served per poll; a candidate that
|
||||||
|
* misses out keeps its cold count while every served candidate's drops, so it
|
||||||
|
* outranks them next poll. Every candidate is therefore served within
|
||||||
|
* `ceil(candidates / budget)` polls once the warms land.
|
||||||
|
*
|
||||||
|
* @param {number} budget - Max cold cells to charge for.
|
||||||
|
* @param {number} fairShare - Per-candidate cap for the middle round.
|
||||||
|
* @param {number} [epoch] - Poll counter; rotates equal-ranked runs.
|
||||||
|
* @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a
|
||||||
|
* cell already has a floor (defaults to the shared cache).
|
||||||
|
* @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order.
|
||||||
|
*/
|
||||||
|
function allocateCorridorCells(
|
||||||
|
candidates,
|
||||||
|
seen,
|
||||||
|
budget,
|
||||||
|
fairShare,
|
||||||
|
epoch = 0,
|
||||||
|
isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null,
|
||||||
|
) {
|
||||||
|
const out = [];
|
||||||
|
if (!Array.isArray(candidates) || !candidates.length || !(budget > 0))
|
||||||
|
return out;
|
||||||
|
const ranked = [...candidates].sort(
|
||||||
|
(a, b) => b.cold - a.cold || b.speedMps - a.speedMps,
|
||||||
|
);
|
||||||
|
if (Number.isFinite(epoch) && epoch !== 0) {
|
||||||
|
for (let i = 0; i < ranked.length;) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (
|
||||||
|
j < ranked.length &&
|
||||||
|
ranked[j].cold === ranked[i].cold &&
|
||||||
|
ranked[j].speedMps === ranked[i].speedMps
|
||||||
|
)
|
||||||
|
j += 1;
|
||||||
|
const runLength = j - i;
|
||||||
|
if (runLength > 1) {
|
||||||
|
const shift = ((epoch % runLength) + runLength) % runLength;
|
||||||
|
const run = ranked.slice(i, j);
|
||||||
|
for (let m = 0; m < runLength; m += 1)
|
||||||
|
ranked[i + m] = run[(m + shift) % runLength];
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cursor per candidate, carried ACROSS rounds: a later round resumes where
|
||||||
|
// the previous one stopped instead of re-walking cells already handled.
|
||||||
|
const cursor = new Array(ranked.length).fill(0);
|
||||||
|
let left = budget;
|
||||||
|
const round = (limit) => {
|
||||||
|
for (let i = 0; i < ranked.length; i++) {
|
||||||
|
const cells = ranked[i].cells || [];
|
||||||
|
while (cursor[i] < cells.length && cursor[i] < limit) {
|
||||||
|
const cell = cells[cursor[i]];
|
||||||
|
const key = `${cell.lat},${cell.lon}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
cursor[i] += 1;
|
||||||
|
continue;
|
||||||
|
} // already in this batch
|
||||||
|
if (isWarm(cell)) {
|
||||||
|
// Free: it has a floor already. Still emitted, so the mesh sampler
|
||||||
|
// gets another look now that its DEM prior exists.
|
||||||
|
cursor[i] += 1;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(cell);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (left <= 0) return;
|
||||||
|
cursor[i] += 1;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(cell);
|
||||||
|
left -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
round(1); // nobody goes home empty-handed
|
||||||
|
round(fairShare); // then a fair slice each
|
||||||
|
round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mesh-floor cells (round 4, owner-approved design) ---------------------
|
||||||
|
// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime
|
||||||
|
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
|
||||||
|
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
|
||||||
|
// inside the mesh. These cells hold the RENDERED surface height, sampled
|
||||||
|
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
|
||||||
|
// source groundSnap uses, which is why 3D models always looked right) and
|
||||||
|
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
|
||||||
|
// below the visible surface; slightly above is always fine. The DEM stays
|
||||||
|
// the instant global prior/fallback and the sanity gate for samples.
|
||||||
|
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
|
||||||
|
const _meshCells = new Map();
|
||||||
|
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
|
||||||
|
* regime (photoreal stack). On globe stacks the rendered surface IS the
|
||||||
|
* terrain provider (Re:Earth on keyless), so the DEM is already correct
|
||||||
|
* there and mesh heights would float. Kept current by meshFloorSampler's
|
||||||
|
* 'gev:map-stack-changed' listener. Photoreal is the boot default. */
|
||||||
|
let _meshPreferred = true;
|
||||||
|
|
||||||
|
/** @param {boolean} preferred - google-3d regime active. */
|
||||||
|
function setMeshFloorPreferred(preferred) {
|
||||||
|
_meshPreferred = !!preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} Whether mesh-floor cells currently apply. */
|
||||||
|
function meshFloorPreferred() {
|
||||||
|
return _meshPreferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a sampled rendered-surface height for the cell containing the
|
||||||
|
* coordinate (one-shot latch — first accepted sample wins).
|
||||||
|
* @param {number} lat @param {number} lon @param {number} heightM
|
||||||
|
*/
|
||||||
|
function reportMeshFloorCell(lat, lon, heightM) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(lat) ||
|
||||||
|
!Number.isFinite(lon) ||
|
||||||
|
!Number.isFinite(heightM)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const c = coarseFloorCoord(lat, lon);
|
||||||
|
const key = `${c.lat},${c.lon}`;
|
||||||
|
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
|
||||||
|
function meshFloorSampleWithinPrior(heightM, priorM) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(heightM) &&
|
||||||
|
Number.isFinite(priorM) &&
|
||||||
|
heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M &&
|
||||||
|
heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and record a rendered-mesh floor through the single shared gate.
|
||||||
|
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
|
||||||
|
* rooftop, or aircraft hit would otherwise win the session-long first-write
|
||||||
|
* latch and poison every floor consumer in the cell.
|
||||||
|
* @returns {boolean} Whether the sample passed and was reported.
|
||||||
|
*/
|
||||||
|
function reportValidatedMeshFloorCell(lat, lon, heightM) {
|
||||||
|
if (![lat, lon, heightM].every(Number.isFinite)) return false;
|
||||||
|
const cell = coarseFloorCoord(lat, lon);
|
||||||
|
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
|
||||||
|
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
|
||||||
|
reportMeshFloorCell(cell.lat, cell.lon, heightM);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous mesh-cell read — null when the cell is unsampled OR the
|
||||||
|
* google-3d regime is inactive (globe stacks must floor on the DEM).
|
||||||
|
* @param {number} lat @param {number} lon
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function cachedMeshFloor(lat, lon) {
|
||||||
|
if (!_meshPreferred) return null;
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
const c = coarseFloorCoord(lat, lon);
|
||||||
|
const h = _meshCells.get(`${c.lat},${c.lon}`);
|
||||||
|
return h != null ? h : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
|
||||||
|
function _clearMeshFloorCellsForTest() {
|
||||||
|
_meshCells.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous coarse-cell ground read (warm cache only — never a fetch).
|
||||||
|
* Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back
|
||||||
|
* to the Re:Earth DEM cell — the single choke point every ground-adjacent
|
||||||
|
* consumer (fleet clamp, grounded surface chain, trail floors) reads, so
|
||||||
|
* they all agree on one surface.
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @returns {number|null} Ellipsoidal floor of the cell, or null if not warm.
|
||||||
|
*/
|
||||||
|
function cachedGroundFloor(lat, lon) {
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
const mesh = cachedMeshFloor(lat, lon);
|
||||||
|
if (mesh != null) return mesh;
|
||||||
|
const c = coarseFloorCoord(lat, lon);
|
||||||
|
return cachedRealEllipsoidalGround(c.lat, c.lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Awaitable variant for one-shot consumers (trail backfills): dedupes the
|
||||||
|
* points to unique coarse cells and resolves them, so `cachedGroundFloor`
|
||||||
|
* reads immediately after are warm. The underlying resolver chunks (≤200/req)
|
||||||
|
* and falls back per-point, so any trace length is safe. Never throws.
|
||||||
|
* @param {Array<{lat: number, lon: number}>} points
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
/** @constant {number} Max ms a one-shot consumer (trail backfill) waits for
|
||||||
|
* the floor resolve before painting with whatever cells are warm. Owner
|
||||||
|
* field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints
|
||||||
|
* hostage to cold Re:Earth lookups spanning a whole flight path (seconds to
|
||||||
|
* the 30 s proxy timeout) — trails showed up late, short, or not at all.
|
||||||
|
* The resolve keeps running past the deadline and lands in the cache for
|
||||||
|
* the next paint/select. */
|
||||||
|
const FLOOR_RESOLVE_DEADLINE_MS = 1200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded-latency variant of resolveGroundFloorCells: waits at most
|
||||||
|
* FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the
|
||||||
|
* cells that ARE warm. The underlying resolve continues in the background
|
||||||
|
* and fills the cache for later reads. Never throws.
|
||||||
|
* @param {Array<{lat: number, lon: number}>} points
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function resolveGroundFloorCellsBounded(points) {
|
||||||
|
return Promise.race([
|
||||||
|
resolveGroundFloorCells(points),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGroundFloorCells(points) {
|
||||||
|
if (!Array.isArray(points) || !points.length) return;
|
||||||
|
const cells = new Map();
|
||||||
|
for (const p of points) {
|
||||||
|
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||||
|
const c = coarseFloorCoord(p.lat, p.lon);
|
||||||
|
const key = `${c.lat},${c.lon}`;
|
||||||
|
if (cells.has(key)) continue;
|
||||||
|
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue;
|
||||||
|
cells.set(key, c);
|
||||||
|
}
|
||||||
|
if (!cells.size) return;
|
||||||
|
try {
|
||||||
|
await resolveEllipsoidalGround([...cells.values()]);
|
||||||
|
} catch {
|
||||||
|
/* best-effort — unresolved cells simply don't clamp */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {boolean} Single-flight guard for the warm batch. */
|
||||||
|
let _floorBatchInFlight = false;
|
||||||
|
/** @type {Map<string, {lat: number, lon: number}>} Cells requested while a
|
||||||
|
* batch was in flight — flushed as the NEXT batch the moment it completes.
|
||||||
|
* Round 6: the old guard silently DROPPED contending calls (flights every
|
||||||
|
* 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new
|
||||||
|
* airport could take minutes of lost polls before a given cell resolved —
|
||||||
|
* the owner's "you can't just go to a place and start inspecting it". */
|
||||||
|
const _pendingFloorCells = new Map();
|
||||||
|
|
||||||
|
/** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */
|
||||||
|
function _resolveFloorCells(cells) {
|
||||||
|
_floorBatchInFlight = true;
|
||||||
|
resolveEllipsoidalGround([...cells.values()])
|
||||||
|
.catch(() => {
|
||||||
|
/* best-effort — unclamped this tick, floor lands later */
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_floorBatchInFlight = false;
|
||||||
|
if (!signal?.aborted && _pendingFloorCells.size) {
|
||||||
|
const next = new Map(_pendingFloorCells);
|
||||||
|
_pendingFloorCells.clear();
|
||||||
|
_resolveFloorCells(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget batch warm of the coarse floor cells for the given points.
|
||||||
|
* Dedupes to unique cells, skips already-warm ones, and never blocks or
|
||||||
|
* throws into the caller. Contending calls QUEUE (never drop): their cells
|
||||||
|
* ride the next batch as soon as the in-flight one completes. Results are
|
||||||
|
* picked up by later `cachedGroundFloor` reads.
|
||||||
|
* @param {Array<{lat: number, lon: number}>} points
|
||||||
|
*/
|
||||||
|
function warmGroundFloor(points) {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
if (!Array.isArray(points) || !points.length) return;
|
||||||
|
const cells = new Map();
|
||||||
|
for (const p of points) {
|
||||||
|
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||||
|
const c = coarseFloorCoord(p.lat, p.lon);
|
||||||
|
const key = `${c.lat},${c.lon}`;
|
||||||
|
if (cells.has(key) || _pendingFloorCells.has(key)) continue;
|
||||||
|
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm
|
||||||
|
cells.set(key, c);
|
||||||
|
}
|
||||||
|
if (!cells.size) return;
|
||||||
|
if (_floorBatchInFlight) {
|
||||||
|
for (const [key, c] of cells) _pendingFloorCells.set(key, c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_resolveFloorCells(cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
_meshCells.clear();
|
||||||
|
_pendingFloorCells.clear();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
GROUND_FLOOR_LIFT_M,
|
||||||
|
MESH_FLOOR_BELOW_PRIOR_M,
|
||||||
|
MESH_FLOOR_ABOVE_PRIOR_M,
|
||||||
|
coarseFloorCoord,
|
||||||
|
floorAltitudeM,
|
||||||
|
displayFloorHeightM,
|
||||||
|
FLOOR_CELL_DEG,
|
||||||
|
NEIGHBOR_FLOOR_MIN_SAMPLES,
|
||||||
|
neighborFloorM,
|
||||||
|
CORRIDOR_MAX_CELLS,
|
||||||
|
CORRIDOR_WALK_STEP_DEG,
|
||||||
|
CELL_HYSTERESIS_DEG,
|
||||||
|
stickyFloorCell,
|
||||||
|
corridorFloorCells,
|
||||||
|
allocateCorridorCells,
|
||||||
|
setMeshFloorPreferred,
|
||||||
|
meshFloorPreferred,
|
||||||
|
reportMeshFloorCell,
|
||||||
|
meshFloorSampleWithinPrior,
|
||||||
|
reportValidatedMeshFloorCell,
|
||||||
|
cachedMeshFloor,
|
||||||
|
_clearMeshFloorCellsForTest,
|
||||||
|
cachedGroundFloor,
|
||||||
|
FLOOR_RESOLVE_DEADLINE_MS,
|
||||||
|
resolveGroundFloorCellsBounded,
|
||||||
|
resolveGroundFloorCells,
|
||||||
|
warmGroundFloor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
|
||||||
|
*
|
||||||
|
* Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere
|
||||||
|
* near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland
|
||||||
|
* airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid
|
||||||
|
* sits below the ellipsoid). A 3D model placed at the meta altitude therefore
|
||||||
|
* renders inside the tiles or hovering above them. Billboards dodge this with
|
||||||
|
* disableDepthTestDistance; a depth-tested glTF model cannot.
|
||||||
|
*
|
||||||
|
* Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen
|
||||||
|
* pick render, so it is NEVER called per frame. Each grounded modeled plane gets
|
||||||
|
* ONE successful sample, cached per icao; the cache stops answering directly when
|
||||||
|
* the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles
|
||||||
|
* still streaming, OSM fallback with no tileset skin, sample outside loaded
|
||||||
|
* geometry) are a retry-later signal with per-icao exponential backoff. A
|
||||||
|
* per-window budget stops a freshly modeled airport cluster from firing dozens of
|
||||||
|
* pick renders in one fleet tick.
|
||||||
|
*
|
||||||
|
* What a caller gets while a sample is outstanding depends on whether this
|
||||||
|
* contact has ever HAD one, and the difference is the difference between two
|
||||||
|
* kinds of wrong:
|
||||||
|
* - COLD (nothing ever resolved here) — null. There is no evidence of where the
|
||||||
|
* ground is, and the only honest answer for a depth-tested model is not to
|
||||||
|
* place it at all; the caller keeps the depth-test-free billboard.
|
||||||
|
* - WARM (a snap resolved, then taxi invalidated it and the resample has not
|
||||||
|
* landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding
|
||||||
|
* the model here would pop a taxiing aircraft from 3D back to 2D for a whole
|
||||||
|
* retry backoff, and the ground it was measured on a few dozen metres back is
|
||||||
|
* real evidence, not a guess. Same shape as the billboard chain's
|
||||||
|
* `_heldDisplayFloorM` (flights.js): hold the last thing that resolved,
|
||||||
|
* bounded by how far the contact can have travelled from it.
|
||||||
|
*
|
||||||
|
* A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks
|
||||||
|
* fresh evidence that contradicts it. Distance travelled is only a proxy for
|
||||||
|
* "does this still describe the ground here", and it is a loose one — a contact
|
||||||
|
* can taxi 200 m onto a different surface well inside the bound, and because a
|
||||||
|
* miss preserves the hold, nothing in the retry path would ever correct that.
|
||||||
|
* So where the mesh-floor cells have actually MEASURED the surface under the
|
||||||
|
* contact's current position, and that surface is ABOVE the held value by more
|
||||||
|
* than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and
|
||||||
|
* the contact is COLD again. The fallback is the floored 2D billboard, which is
|
||||||
|
* the honest picture — a model buried in the apron is not a better one.
|
||||||
|
*
|
||||||
|
* Shared by flights.js and militaryFlights.js (one instance each — the caches
|
||||||
|
* are per-layer, keyed by that layer's icao space).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* resample runs. */
|
||||||
|
const MOVE_INVALIDATE_M = 50;
|
||||||
|
/** @constant {number} How far a contact may travel from the spot its snap was
|
||||||
|
* measured at before that measurement stops describing the ground under it, and
|
||||||
|
* the hold is dropped rather than stretched.
|
||||||
|
*
|
||||||
|
* Sized to two things at once. It has to outlast a resample outage, or it buys
|
||||||
|
* nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a
|
||||||
|
* hold that expires inside ~25 s of rolling would hand back the pop it exists to
|
||||||
|
* prevent. And it has to stay inside the grade error an airfield can hide: the
|
||||||
|
* KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so
|
||||||
|
* 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets
|
||||||
|
* already being added, and two orders under the ~100+ m burial the snap exists
|
||||||
|
* to prevent.
|
||||||
|
*
|
||||||
|
* Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That
|
||||||
|
* hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an
|
||||||
|
* under-read there is inert; here the held value IS the placement, so the error
|
||||||
|
* shows in both directions and the bound has to be tighter. */
|
||||||
|
const HELD_SNAP_MAX_DRIFT_M = 250;
|
||||||
|
/** @constant {number} How far a held measurement may disagree with FRESH
|
||||||
|
* measured evidence at the contact's current position before it is dropped
|
||||||
|
* rather than trusted.
|
||||||
|
*
|
||||||
|
* The drift bound above asks "how far has the contact travelled?", which is a
|
||||||
|
* proxy for "does this value still describe the ground?" — and a proxy is all
|
||||||
|
* it is. 202 m of taxi onto a different surface is inside the bound and still
|
||||||
|
* wrong, and because a miss preserves the hold, repeated misses preserve the
|
||||||
|
* error for as long as they last. So the bound is not the only guard: where
|
||||||
|
* something has actually MEASURED the surface the contact is standing on now,
|
||||||
|
* that measurement outranks the memory, and a memory that contradicts it is
|
||||||
|
* discarded rather than stretched to cover the gap.
|
||||||
|
*
|
||||||
|
* 5 m sits between the two scales this has to separate:
|
||||||
|
* - ABOVE the honest disagreement between two measurements of the SAME
|
||||||
|
* surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the
|
||||||
|
* ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep
|
||||||
|
* 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of
|
||||||
|
* those should cost a taxiing aircraft its model.
|
||||||
|
* - BELOW the scale at which a placement reads as wrong. The belly offsets
|
||||||
|
* this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a
|
||||||
|
* model displaced more than ~5 m is visibly off its gear rather than
|
||||||
|
* imprecisely on it — and it is two orders under the ~100+ m burial the
|
||||||
|
* snap exists to prevent.
|
||||||
|
*
|
||||||
|
* Applied in ONE direction: the hold is released only when fresh evidence sits
|
||||||
|
* ABOVE it, which is the BURIAL case — the model sunk into the surface, which
|
||||||
|
* is the entire reason this gate exists. The other sign is deliberately left
|
||||||
|
* alone, and the asymmetry is not tidiness:
|
||||||
|
*
|
||||||
|
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
|
||||||
|
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
|
||||||
|
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
|
||||||
|
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
|
||||||
|
* built to under-read rather than to invent. Releasing on that sign would
|
||||||
|
* discard good measurements wherever the two disagree downward. Measured on
|
||||||
|
* the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at
|
||||||
|
* the SAME spot, and a two-sided rule dropped a hold that was correct.
|
||||||
|
* - The residual is stated rather than hidden, and stated WITH its condition.
|
||||||
|
* A contact taxiing DOWNHILL keeps a held value above its new surface and
|
||||||
|
* its model floats. The correction is a SUCCESSFUL resample, and nothing on
|
||||||
|
* this path guarantees one: on the OSM fallback sampleHeight misses forever,
|
||||||
|
* so the retries never land and the float persists for as long as the
|
||||||
|
* contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it
|
||||||
|
* and no vertical cap of its own — only whatever the ground drops within
|
||||||
|
* that radius. The other releases (a ground flip via forget(), travelling
|
||||||
|
* past the bound, a mesh cell measuring ABOVE the hold) are not ones a
|
||||||
|
* downhill taxi is guaranteed to hit either. It is accepted over the
|
||||||
|
* alternative because it errs UPWARD and stays visible, where a buried model
|
||||||
|
* errs into the terrain and cannot be seen at all. */
|
||||||
|
const HELD_SNAP_CONTRADICTION_M = 5;
|
||||||
|
/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */
|
||||||
|
const RETRY_BASE_MS = 2000;
|
||||||
|
/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */
|
||||||
|
const RETRY_MAX_MS = 30000;
|
||||||
|
/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */
|
||||||
|
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;
|
||||||
|
for (let i = 0; i < prims.length; i++) {
|
||||||
|
let p = null;
|
||||||
|
try {
|
||||||
|
p = prims.get(i);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') {
|
||||||
|
return p.tilesLoaded === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a per-layer ground-snap cache.
|
||||||
|
* @returns {{
|
||||||
|
* heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3,
|
||||||
|
* getExclusions?: () => Array<unknown>) => number|null,
|
||||||
|
* forget: (icao: string) => void,
|
||||||
|
* clear: () => void,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function createGroundSnap({ groundFloor }) {
|
||||||
|
const { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } =
|
||||||
|
groundFloor;
|
||||||
|
/** @type {Map<string, {h: number|null, samplePos: Cesium.Cartesian3|null,
|
||||||
|
* held: boolean, nextRetryMs: number, misses: number}>} Per-icao snap state:
|
||||||
|
* the sampled height and the surface point it was measured at, whether that
|
||||||
|
* pair is still the fresh answer or has been demoted to a bounded last-known
|
||||||
|
* (`held`), and the retry backoff a miss earned. */
|
||||||
|
const entries = new Map();
|
||||||
|
let windowStartMs = 0;
|
||||||
|
let windowCount = 0;
|
||||||
|
const scratchCarto = new Cesium.Cartographic();
|
||||||
|
const scratchSurfacePos = new Cesium.Cartesian3();
|
||||||
|
/** Separate from `scratchCarto`: the held-evidence check runs on paths that
|
||||||
|
* return BEFORE the sample writes `scratchCarto`, and on the sampling path it
|
||||||
|
* must not disturb the cartographic that `reportValidatedMeshFloorCell` reads
|
||||||
|
* back after the sample lands. */
|
||||||
|
const scratchHeldCarto = new Cesium.Cartographic();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last measurement for a contact whose fresh snap has been demoted — it
|
||||||
|
* taxied past MOVE_INVALIDATE_M and the resample has not landed yet.
|
||||||
|
*
|
||||||
|
* Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the
|
||||||
|
* spot the value was measured at; past that the memory is DROPPED rather than
|
||||||
|
* stretched, and the contact is back to no evidence (null, model hidden). The
|
||||||
|
* bound is purely spatial on purpose: the ground under a contact that has not
|
||||||
|
* moved does not change with time, so what invalidates the value is the
|
||||||
|
* contact MOVING, which is exactly what this measures.
|
||||||
|
*
|
||||||
|
* @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry
|
||||||
|
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function heldSnapM(entry, surfacePos) {
|
||||||
|
if (!entry || !entry.held || entry.h == null || !entry.samplePos)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos) >
|
||||||
|
HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M
|
||||||
|
) {
|
||||||
|
return dropHold(entry);
|
||||||
|
}
|
||||||
|
// Held memory never outranks fresh contradicting evidence. Inside the drift
|
||||||
|
// bound the contact can still have taxied onto a different surface — an
|
||||||
|
// apron edge, a ramp, the far side of a rise — and since a miss PRESERVES
|
||||||
|
// the hold, nothing else would ever correct it: the model would stay buried
|
||||||
|
// (or floating) for as long as the resample kept missing.
|
||||||
|
const fresh = freshMeasuredFloorAt(surfacePos);
|
||||||
|
if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) {
|
||||||
|
return dropHold(entry);
|
||||||
|
}
|
||||||
|
return entry.h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget a demoted measurement: it no longer describes anywhere this contact
|
||||||
|
* is, so the contact is COLD again (model hidden, floored billboard). */
|
||||||
|
function dropHold(entry) {
|
||||||
|
entry.h = null;
|
||||||
|
entry.samplePos = null;
|
||||||
|
entry.held = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independently measured surface height at the contact's CURRENT position, or
|
||||||
|
* null where nothing has measured there yet.
|
||||||
|
*
|
||||||
|
* MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a
|
||||||
|
* `sampleHeight` of the RENDERED skin, and the mesh cells are that same
|
||||||
|
* measurement through the same validation gate (written by this module's own
|
||||||
|
* successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls
|
||||||
|
* back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between
|
||||||
|
* skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for
|
||||||
|
* (15 m / 80 m), so testing a snap against a DEM cell would throw away good
|
||||||
|
* holds over a disagreement that says nothing about where the model sits.
|
||||||
|
*
|
||||||
|
* The contact's OWN cell only. A neighbour is ~111 m away and may be a
|
||||||
|
* terminal roof — that is why `neighborFloorM` leans to the lowest and refuses
|
||||||
|
* to answer from a single sample — and borrowing one to DISCARD a real
|
||||||
|
* measurement would let a roof cell hide a correctly placed model.
|
||||||
|
*
|
||||||
|
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function freshMeasuredFloorAt(surfacePos) {
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(
|
||||||
|
surfacePos,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
scratchHeldCarto,
|
||||||
|
);
|
||||||
|
if (!carto) return null;
|
||||||
|
return cachedMeshFloor(
|
||||||
|
Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null
|
||||||
|
* when this contact has NO ground evidence at all (caller keeps the model
|
||||||
|
* hidden and calls again on a later tick — cheap map hit until a retry is due).
|
||||||
|
* A contact that HAS resolved once keeps that answer through a resample outage;
|
||||||
|
* see `heldSnapM`.
|
||||||
|
*
|
||||||
|
* @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check).
|
||||||
|
* @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.
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function heightFor(viewer, icao, pos, getExclusions) {
|
||||||
|
const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
|
||||||
|
pos,
|
||||||
|
scratchSurfacePos,
|
||||||
|
);
|
||||||
|
if (!surfacePos) return null;
|
||||||
|
const cached = entries.get(icao);
|
||||||
|
if (cached && cached.h != null && cached.samplePos && !cached.held) {
|
||||||
|
if (
|
||||||
|
Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <=
|
||||||
|
MOVE_INVALIDATE_M * MOVE_INVALIDATE_M
|
||||||
|
) {
|
||||||
|
return cached.h;
|
||||||
|
}
|
||||||
|
// Taxied away from the sampled spot. The measurement is DEMOTED to a
|
||||||
|
// bounded last-known rather than deleted, and the resample runs below: a
|
||||||
|
// miss now holds the aircraft on the ground it just left instead of
|
||||||
|
// hiding a model that was already up.
|
||||||
|
cached.held = true;
|
||||||
|
cached.nextRetryMs = 0;
|
||||||
|
cached.misses = 0;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = entries.get(icao);
|
||||||
|
if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force
|
||||||
|
// Per-window budget: deny WITHOUT a retry stamp so the overflow simply
|
||||||
|
// tries again next tick instead of waiting out a backoff it didn't earn.
|
||||||
|
if (now - windowStartMs > SAMPLE_WINDOW_MS) {
|
||||||
|
windowStartMs = now;
|
||||||
|
windowCount = 0;
|
||||||
|
}
|
||||||
|
if (windowCount >= SAMPLE_BUDGET_PER_WINDOW)
|
||||||
|
return heldSnapM(entry, surfacePos);
|
||||||
|
const misses = entry ? entry.misses : 0;
|
||||||
|
const miss = () => {
|
||||||
|
// A held measurement survives the miss — only the retry schedule moves.
|
||||||
|
const held = !!(
|
||||||
|
entry &&
|
||||||
|
entry.held &&
|
||||||
|
entry.h != null &&
|
||||||
|
entry.samplePos
|
||||||
|
);
|
||||||
|
const next = {
|
||||||
|
h: held ? entry.h : null,
|
||||||
|
samplePos: held ? entry.samplePos : null,
|
||||||
|
held,
|
||||||
|
nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses),
|
||||||
|
misses: misses + 1,
|
||||||
|
};
|
||||||
|
entries.set(icao, next);
|
||||||
|
return heldSnapM(next, surfacePos);
|
||||||
|
};
|
||||||
|
if (!_tilesReady(viewer)) return miss();
|
||||||
|
windowCount += 1;
|
||||||
|
let sampled;
|
||||||
|
try {
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(
|
||||||
|
pos,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
scratchCarto,
|
||||||
|
);
|
||||||
|
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
|
||||||
|
sampled = viewer.scene.sampleHeight(
|
||||||
|
carto,
|
||||||
|
getExclusions ? getExclusions() : undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
sampled = undefined;
|
||||||
|
}
|
||||||
|
// Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the
|
||||||
|
// ellipsoid is pick garbage, not ground.
|
||||||
|
if (!Number.isFinite(sampled) || sampled < -150) return miss();
|
||||||
|
if (meshFloorPreferred()) {
|
||||||
|
reportValidatedMeshFloorCell(
|
||||||
|
Cesium.Math.toDegrees(scratchCarto.latitude),
|
||||||
|
Cesium.Math.toDegrees(scratchCarto.longitude),
|
||||||
|
sampled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A fresh sample RELEASES any hold: this is a measurement of where the
|
||||||
|
// contact is now, and it outranks a memory of where it was.
|
||||||
|
entries.set(icao, {
|
||||||
|
h: sampled,
|
||||||
|
samplePos: Cesium.Cartesian3.clone(surfacePos),
|
||||||
|
held: false,
|
||||||
|
nextRetryMs: 0,
|
||||||
|
misses: 0,
|
||||||
|
});
|
||||||
|
return sampled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */
|
||||||
|
function forget(icao) {
|
||||||
|
entries.delete(icao);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop everything (layer destroy). */
|
||||||
|
function clear() {
|
||||||
|
entries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { heightFor, forget, clear };
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
|
||||||
|
export function createMeshFloorSampler({
|
||||||
|
groundFloor,
|
||||||
|
signal,
|
||||||
|
eventTarget = null,
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
coarseFloorCoord,
|
||||||
|
cachedMeshFloor,
|
||||||
|
reportValidatedMeshFloorCell,
|
||||||
|
setMeshFloorPreferred,
|
||||||
|
meshFloorPreferred,
|
||||||
|
cachedGroundFloor,
|
||||||
|
} = groundFloor;
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
// src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling
|
||||||
|
// (field-test round 4, 2026-07-06).
|
||||||
|
//
|
||||||
|
// The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH,
|
||||||
|
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
|
||||||
|
// airport apron) — so DEM-floored sprites/trails still buried themselves in
|
||||||
|
// the mesh while 3D models (which groundSnap against the mesh) looked right.
|
||||||
|
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
|
||||||
|
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
|
||||||
|
//
|
||||||
|
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
|
||||||
|
// tile-load prioritization at the probe point, so sampling is (a) gated to
|
||||||
|
// cells NEAR the viewer — where tiles are streamed and where an error is
|
||||||
|
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
|
||||||
|
// (d) skipped entirely outside the google-3d regime. A returned-undefined
|
||||||
|
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
|
||||||
|
//
|
||||||
|
// Sample acceptance is sanity-gated against the DEM prior when warm (a
|
||||||
|
// vertical probe can hit a rooftop or another aircraft instead of pavement):
|
||||||
|
// the mesh legitimately sits ABOVE bare earth, so the window is asymmetric.
|
||||||
|
|
||||||
|
/** @constant {number} Max scene samples per call (one call per layer poll). */
|
||||||
|
const MAX_SAMPLES_PER_CALL = 40;
|
||||||
|
/** @constant {number} Only cells within this range of the viewer subpoint are
|
||||||
|
* sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed
|
||||||
|
* fine-LOD area a probe returns COARSE-tile heights (not undefined), and the
|
||||||
|
* one-shot latch made those permanent (owner round 5: grounded planes stuck
|
||||||
|
* at a uniform wrong height). */
|
||||||
|
const MAX_SAMPLE_DIST_KM = 15;
|
||||||
|
/** @constant {number} No sampling when the camera is above this height — the
|
||||||
|
* 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();
|
||||||
|
|
||||||
|
// Regime tracking: mesh cells only apply while the photoreal (google-3d)
|
||||||
|
// stack renders. main.js re-dispatches MapStackController.onChange as this
|
||||||
|
// CustomEvent; the boot default is photoreal, matching groundFloor's initial
|
||||||
|
// 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.
|
||||||
|
*/
|
||||||
|
function _approxKm(lat1, lon1, lat2, lon2) {
|
||||||
|
const dLat = (lat2 - lat1) * 111.32;
|
||||||
|
const dLon =
|
||||||
|
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||||
|
return Math.hypot(dLat, dLon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples the rendered mesh height for the coarse cells containing `points`,
|
||||||
|
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
|
||||||
|
* layer poll (never per contact, never per frame). Synchronous — Cesium's
|
||||||
|
* sampleHeight is a CPU-side query against loaded tiles.
|
||||||
|
*
|
||||||
|
* @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down).
|
||||||
|
* @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 {number} [options.viewerLat] @param {number} [options.viewerLon]
|
||||||
|
* Viewer subpoint (computed once by the caller's poll).
|
||||||
|
*/
|
||||||
|
function sampleMeshFloorCells(
|
||||||
|
scene,
|
||||||
|
points,
|
||||||
|
{ excludeObjects = [], viewerLat, viewerLon } = {},
|
||||||
|
) {
|
||||||
|
if (!meshFloorPreferred()) return;
|
||||||
|
if (!scene || typeof scene.sampleHeight !== 'function') 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 = scene.camera?.positionCartographic?.height;
|
||||||
|
if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return;
|
||||||
|
if (!_visibleTilesetLoaded(scene)) return;
|
||||||
|
let sampled = 0;
|
||||||
|
const attempted = new Set();
|
||||||
|
for (const p of points) {
|
||||||
|
if (sampled >= MAX_SAMPLES_PER_CALL) break;
|
||||||
|
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||||
|
const cell = coarseFloorCoord(p.lat, p.lon);
|
||||||
|
const key = `${cell.lat},${cell.lon}`;
|
||||||
|
if (attempted.has(key)) continue;
|
||||||
|
attempted.add(key);
|
||||||
|
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
|
||||||
|
if (
|
||||||
|
Number.isFinite(viewerLat) &&
|
||||||
|
Number.isFinite(viewerLon) &&
|
||||||
|
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
|
||||||
|
) {
|
||||||
|
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
|
||||||
|
}
|
||||||
|
let height;
|
||||||
|
try {
|
||||||
|
const carto = Cesium.Cartographic.fromDegrees(
|
||||||
|
cell.lon,
|
||||||
|
cell.lat,
|
||||||
|
0,
|
||||||
|
_scratchProbe,
|
||||||
|
);
|
||||||
|
height = scene.sampleHeight(carto, excludeObjects);
|
||||||
|
sampled += 1;
|
||||||
|
} catch {
|
||||||
|
continue; // scene mid-teardown — try again next poll
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch
|
||||||
|
// Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches.
|
||||||
|
// - A geoid-fallback prior (cached while the proxy was failing) is a
|
||||||
|
// sea-level poison that rejected legitimate samples at elevated
|
||||||
|
// fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever).
|
||||||
|
// - No prior at all means no way to tell a real surface from a
|
||||||
|
// coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch
|
||||||
|
// latched a 37 m "surface" at Austin permanently. The DEM lands
|
||||||
|
// within a poll (the same warm batch that queued this cell), so
|
||||||
|
// waiting costs one poll, not correctness.
|
||||||
|
// 2026-08-21: a "provisional" tier that KEPT the reading when no prior
|
||||||
|
// existed was built here and then removed, because it was measured failing.
|
||||||
|
// Run twice against the real GPU at the same Austin apron with the proxy
|
||||||
|
// down, it recorded 122.1 m once and 20.6 m the next time — the second is a
|
||||||
|
// coarse-LOD read of ground that is really ~122 m, and it left the contact
|
||||||
|
// ~100 m under the mesh. The DEM requirement above is not a formality.
|
||||||
|
//
|
||||||
|
// Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is
|
||||||
|
// NOT a resolution signal. It reports that the streaming queue has drained
|
||||||
|
// for the CURRENT camera's screen-space-error target, and a distant or high
|
||||||
|
// camera is entirely satisfied by coarse tiles — which is exactly the state
|
||||||
|
// that reported "loaded" during the measurement above. Gating harder on it
|
||||||
|
// buys nothing. A correct gate has to ask about the specific tile covering
|
||||||
|
// the sample point: walk the tileset tree to the tile containing the
|
||||||
|
// coordinate and require its `geometricError` to be below a threshold tied
|
||||||
|
// to the accuracy the caller needs, before trusting the sample. That is
|
||||||
|
// feasible and deliberately deferred — see the P2 in
|
||||||
|
// the project roadmap, which bundles it with the fly_route probe so
|
||||||
|
// one post-launch pass settles rendered-mesh sampling everywhere.
|
||||||
|
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-export for callers that want one import site
|
||||||
|
|
||||||
|
const onStack = (event) => {
|
||||||
|
const id = event?.detail?.activeId;
|
||||||
|
if (id) setMeshFloorPreferred(id === 'photoreal');
|
||||||
|
};
|
||||||
|
eventTarget?.addEventListener('gev:map-stack-changed', onStack);
|
||||||
|
signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return { sampleMeshFloorCells, cachedGroundFloor };
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { ensureGeoidReady, geoidHeight } from '../data/geoid.js';
|
||||||
|
|
||||||
|
/** Construct an instance-owned terrainHeights service with explicit dependencies. */
|
||||||
|
export function createTerrainHeights({ source, signal }) {
|
||||||
|
if (typeof source?.getHeights !== 'function')
|
||||||
|
throw new TypeError('Terrain heights require a source');
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
// src/data/terrainHeights.js — batched, cached client terrain-height resolver
|
||||||
|
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
|
||||||
|
//
|
||||||
|
// Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side
|
||||||
|
// `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached,
|
||||||
|
// serve-stale). When the proxy is unreachable (or errors) AND the point isn't
|
||||||
|
// already warm in this module's in-memory cache, falls back to bundled-geoid
|
||||||
|
// math (Task 1, `src/data/geoid.js`):
|
||||||
|
//
|
||||||
|
// sourceOrthometricM (if finite) + geoidHeight(lat, lon) → source:'geoid-fallback'
|
||||||
|
// else geoidHeight(lat, lon) alone (H≈0 "at the geoid/coast" prior) → source:'geoid-fallback'
|
||||||
|
//
|
||||||
|
// Two things learned building the Task 2 proxy (see its report, and the
|
||||||
|
// ledger's T2 entry) that this module builds in:
|
||||||
|
//
|
||||||
|
// 1. The proxy's GET has a Node http header-size ceiling — empirically a
|
||||||
|
// single request much past ~700-1500 points (depending on coordinate
|
||||||
|
// precision) risks a raw socket-level 431 *before* the proxy's own
|
||||||
|
// request handler runs. So every network request this module issues is
|
||||||
|
// chunked at CHUNK_SIZE (64 points), well under that ceiling, and
|
||||||
|
// chunks are sent SEQUENTIALLY (not in parallel) to keep this client
|
||||||
|
// well-behaved against a single dev-server proxy.
|
||||||
|
// 2. The proxy's disk cache is keyed by the raw `points` query string, with
|
||||||
|
// no eviction — so a client that varies coordinate precision or point
|
||||||
|
// order defeats the warm cache and grows the cache file unbounded. This
|
||||||
|
// module rounds every coordinate to 5 decimal places (~1.1 m of
|
||||||
|
// precision at the equator — comfortably tighter than any of this app's
|
||||||
|
// placement needs) BOTH for its own in-memory cache key AND for the
|
||||||
|
// points string sent to the proxy, so repeated calls (including calls
|
||||||
|
// from a fresh page load, a different camera batch, etc.) consistently
|
||||||
|
// hit the same warm proxy cache entry.
|
||||||
|
|
||||||
|
/** Max points per outgoing request to `/api/terrain/heights` (see file header, point 1). */
|
||||||
|
// Match the server's upstream batch so sequential upstream work also fits
|
||||||
|
// within this client's 30-second request deadline when Re:Earth slows.
|
||||||
|
const CHUNK_SIZE = 64;
|
||||||
|
|
||||||
|
/** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */
|
||||||
|
const GEOID_FALLBACK_COOLDOWN_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`.
|
||||||
|
* Module-scoped (not exported) — the only reads are through
|
||||||
|
* `cachedEllipsoidalGround` and the internal lookup in
|
||||||
|
* `resolveEllipsoidalGround`.
|
||||||
|
* @type {Map<string, {ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}>}
|
||||||
|
*/
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the rounded cache key shared between the in-memory cache and the
|
||||||
|
* `points` string sent to the proxy (see file header, point 2).
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function cacheKey(lat, lon) {
|
||||||
|
return `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous read of a previously resolved ellipsoidal ground height.
|
||||||
|
* Returns null if this exact (rounded) coordinate hasn't been resolved yet
|
||||||
|
* by either the proxy path or the geoid-fallback path.
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function cachedEllipsoidalGround(lat, lon) {
|
||||||
|
const entry = cache.get(cacheKey(lat, lon));
|
||||||
|
return entry ? entry.ellipsoid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field-test round 5 (2026-07-06, the "sea-level poison"): like
|
||||||
|
* cachedEllipsoidalGround but returns null for geoid-FALLBACK entries —
|
||||||
|
* only a real Re:Earth value counts. A fallback (cached when the proxy
|
||||||
|
* failed mid-burst) is the geoid surface, which at Austin sits ~165 m below
|
||||||
|
* the airport: floors built on it sank every sprite/trail, and the mesh
|
||||||
|
* sampler's sanity gate rejected REAL surface samples against it. Floor
|
||||||
|
* consumers read THIS; the plain read stays for display-only consumers.
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function cachedRealEllipsoidalGround(lat, lon) {
|
||||||
|
const entry = cache.get(cacheKey(lat, lon));
|
||||||
|
return entry && entry.source === 'reearth' ? entry.ellipsoid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches one chunk (<=CHUNK_SIZE points) from the `/api/terrain/heights`
|
||||||
|
* proxy. Returns a Map keyed by the same rounded cache key so callers can
|
||||||
|
* look results up positionally-independent of upstream response ordering.
|
||||||
|
* Throws on any failure (non-ok response, network error, malformed body) —
|
||||||
|
* callers decide the fallback behavior per point.
|
||||||
|
* @param {Array<{key: string, lat: number, lon: number}>} chunk
|
||||||
|
* @returns {Promise<Map<string, number>>} key -> ellipsoid height (m)
|
||||||
|
*/
|
||||||
|
async function fetchChunk(chunk) {
|
||||||
|
// lon,lat order (matches the proxy's documented `points=lon,lat;…` contract
|
||||||
|
// and Task 2's implementation).
|
||||||
|
const results = await source.getHeights(chunk, {
|
||||||
|
signal: signal
|
||||||
|
? AbortSignal.any([signal, AbortSignal.timeout(30000)])
|
||||||
|
: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
const body = { results };
|
||||||
|
if (!Array.isArray(body?.results))
|
||||||
|
throw new Error('malformed terrain heights response (no results array)');
|
||||||
|
if (body.results.length !== chunk.length) {
|
||||||
|
throw new Error(
|
||||||
|
`terrain heights response length mismatch (expected ${chunk.length}, got ${body.results.length})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const out = new Map();
|
||||||
|
// The proxy is documented to preserve input order (Task 2 report verifies
|
||||||
|
// this against the live upstream), so map positionally rather than trust
|
||||||
|
// the response's own lon/lat fields to re-match — those are also present
|
||||||
|
// for callers who want them, but this module doesn't need them.
|
||||||
|
for (let i = 0; i < chunk.length; i += 1) {
|
||||||
|
const ellipsoid = body.results[i]?.ellipsoid;
|
||||||
|
if (!Number.isFinite(ellipsoid))
|
||||||
|
throw new Error(`non-finite ellipsoid height at index ${i}`);
|
||||||
|
out.set(chunk[i].key, ellipsoid);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the geoid-fallback ellipsoidal height for one point per the
|
||||||
|
* brief's fallback chain: `sourceOrthometricM + geoidHeight` when a finite
|
||||||
|
* orthometric source height is available, else `geoidHeight` alone (treats
|
||||||
|
* the point as if it were at the geoid, i.e. H≈0 — a coast-level prior).
|
||||||
|
* Assumes `ensureGeoidReady()` has already resolved.
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @param {number} [sourceOrthometricM]
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function geoidFallback(lat, lon, sourceOrthometricM) {
|
||||||
|
const n = geoidHeight(lat, lon);
|
||||||
|
return Number.isFinite(sourceOrthometricM) ? sourceOrthometricM + n : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves ellipsoidal ground height for a batch of coordinates, in order.
|
||||||
|
*
|
||||||
|
* - Results already warm in the in-memory cache are returned without any
|
||||||
|
* network call.
|
||||||
|
* - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights`
|
||||||
|
* in sequential chunks of <=64 points.
|
||||||
|
* - If a chunk request fails (network error, non-ok HTTP, malformed body),
|
||||||
|
* every point in THAT chunk falls back to geoid math
|
||||||
|
* (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than
|
||||||
|
* failing the whole batch — a transient proxy outage in the middle of a
|
||||||
|
* large batch shouldn't blank out entities whose chunk happened to land
|
||||||
|
* earlier or later.
|
||||||
|
* - Every resolved value (proxy or fallback) is written back into the
|
||||||
|
* in-memory cache before this function returns, so a later call — even
|
||||||
|
* with a real proxy round-trip pending — sees a warm hit.
|
||||||
|
*
|
||||||
|
* @param {Array<{lat:number, lon:number, sourceOrthometricM?:number}>} coords
|
||||||
|
* @returns {Promise<Array<{ellipsoid:number, source:'reearth'|'geoid-fallback'}>>}
|
||||||
|
* Same length and order as `coords`.
|
||||||
|
*/
|
||||||
|
async function resolveEllipsoidalGround(coords) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (!Array.isArray(coords) || coords.length === 0) return [];
|
||||||
|
|
||||||
|
// Build the per-input work list (key + original index) up front so the
|
||||||
|
// final assembly can map back positionally regardless of how many inputs
|
||||||
|
// shared a cache key or a chunk.
|
||||||
|
const work = coords.map((c, index) => ({
|
||||||
|
index,
|
||||||
|
lat: c.lat,
|
||||||
|
lon: c.lon,
|
||||||
|
sourceOrthometricM: c.sourceOrthometricM,
|
||||||
|
key: cacheKey(c.lat, c.lon),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Points not yet warm in the cache, deduplicated by key (multiple inputs —
|
||||||
|
// even within the same call — naming the same rounded coordinate should
|
||||||
|
// only ever hit the network once). Round 5: a geoid-FALLBACK entry does
|
||||||
|
// NOT count as permanently warm — it was cached when the proxy failed
|
||||||
|
// mid-burst and (at elevated terrain) is a sea-level poison that sank every
|
||||||
|
// floor built on it. Give failed keys a short cooldown before re-requesting
|
||||||
|
// so warm consumers do not hammer a failing proxy; after it expires, a
|
||||||
|
// successful response replaces the fallback entry and clears the path.
|
||||||
|
const uncached = [];
|
||||||
|
const seenKeys = new Set();
|
||||||
|
const now = Date.now();
|
||||||
|
for (const item of work) {
|
||||||
|
const entry = cache.get(item.key);
|
||||||
|
const fallbackCooling =
|
||||||
|
entry?.source === 'geoid-fallback' &&
|
||||||
|
Number.isFinite(entry.retryAt) &&
|
||||||
|
now < entry.retryAt;
|
||||||
|
if (
|
||||||
|
(entry && entry.source === 'reearth') ||
|
||||||
|
fallbackCooling ||
|
||||||
|
seenKeys.has(item.key)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
seenKeys.add(item.key);
|
||||||
|
uncached.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
|
||||||
|
// failure is isolated to that chunk's points (geoid fallback), so a single
|
||||||
|
// bad chunk doesn't lose results for the rest of a large batch.
|
||||||
|
for (let i = 0; i < uncached.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = uncached.slice(i, i + CHUNK_SIZE);
|
||||||
|
try {
|
||||||
|
const resolved = await fetchChunk(chunk);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
for (const item of chunk) {
|
||||||
|
const ellipsoid = resolved.get(item.key);
|
||||||
|
// Round 6: only a FINITE value may be cached as 'reearth'. A point
|
||||||
|
// the upstream response omitted used to cache {ellipsoid: undefined,
|
||||||
|
// source: 'reearth'} — permanently "warm" yet empty, so its floor
|
||||||
|
// read null forever and every later warm skipped it (ATL verify:
|
||||||
|
// one contact frozen at the geoid while its neighbors resolved).
|
||||||
|
// An omitted point now caches nothing and retries on the next warm.
|
||||||
|
if (Number.isFinite(ellipsoid)) {
|
||||||
|
cache.set(item.key, { ellipsoid, source: 'reearth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
// Proxy down (or cold cache had nothing to serve-stale) — fall back to
|
||||||
|
// geoid math for every point in this chunk. `ensureGeoidReady()` is
|
||||||
|
// awaited lazily, only on the fallback path, so the common (proxy
|
||||||
|
// healthy) case never pays for the geoid grid's dynamic import.
|
||||||
|
await ensureGeoidReady();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
for (const item of chunk) {
|
||||||
|
const ellipsoid = geoidFallback(
|
||||||
|
item.lat,
|
||||||
|
item.lon,
|
||||||
|
item.sourceOrthometricM,
|
||||||
|
);
|
||||||
|
cache.set(item.key, {
|
||||||
|
ellipsoid,
|
||||||
|
source: 'geoid-fallback',
|
||||||
|
retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble the output in the original input order from the cache. A point
|
||||||
|
// the upstream omitted has NO entry (round 6 — deliberately uncached so it
|
||||||
|
// retries later): report it unresolved instead of throwing.
|
||||||
|
return work.map((item) => {
|
||||||
|
const entry = cache.get(item.key);
|
||||||
|
return entry
|
||||||
|
? { ellipsoid: entry.ellipsoid, source: entry.source }
|
||||||
|
: { ellipsoid: null, source: 'unresolved' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', () => cache.clear(), { once: true });
|
||||||
|
return {
|
||||||
|
cachedEllipsoidalGround,
|
||||||
|
cachedRealEllipsoidalGround,
|
||||||
|
resolveEllipsoidalGround,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,19 +25,23 @@ export function createStandaloneApplication({
|
|||||||
let placeSearch;
|
let placeSearch;
|
||||||
let catalog;
|
let catalog;
|
||||||
return createApplication({
|
return createApplication({
|
||||||
createScene: (context) => {
|
createScene: async (context) => {
|
||||||
catalog = createStandaloneCatalog({ signal: context.signal });
|
|
||||||
placeSearch = createStandalonePlaceSearch({
|
placeSearch = createStandalonePlaceSearch({
|
||||||
...geospatial,
|
...geospatial,
|
||||||
resolveApiKey: () => googleApiKey,
|
resolveApiKey: () => googleApiKey,
|
||||||
signal: context.signal,
|
signal: context.signal,
|
||||||
});
|
});
|
||||||
return createStandaloneScene({
|
const scene = await createStandaloneScene({
|
||||||
...context,
|
...context,
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
cesiumToken,
|
cesiumToken,
|
||||||
loaderStatus,
|
loaderStatus,
|
||||||
});
|
});
|
||||||
|
catalog = createStandaloneCatalog({
|
||||||
|
signal: context.signal,
|
||||||
|
surface: scene.operations.surface,
|
||||||
|
});
|
||||||
|
return scene;
|
||||||
},
|
},
|
||||||
createControls: (context) =>
|
createControls: (context) =>
|
||||||
createStandaloneControls({
|
createStandaloneControls({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createSurfaceServices } from '../app/surfaceServices.js';
|
||||||
|
import { createApplicationRequestServices } from '../services/requests.js';
|
||||||
import { createApplicationCatalog } from '../app/constructCatalog.js';
|
import { createApplicationCatalog } from '../app/constructCatalog.js';
|
||||||
import { createStandaloneLayerSources } from './layerSources.js';
|
import { createStandaloneLayerSources } from './layerSources.js';
|
||||||
export { createStandaloneReferenceSources } from './layerSources.js';
|
export { createStandaloneReferenceSources } from './layerSources.js';
|
||||||
@@ -5,8 +7,13 @@ export { createStandaloneReferenceSources } from './layerSources.js';
|
|||||||
/** Create fresh layer instances using the existing standalone source choices. */
|
/** Create fresh layer instances using the existing standalone source choices. */
|
||||||
export function createStandaloneCatalog({
|
export function createStandaloneCatalog({
|
||||||
signal = new AbortController().signal,
|
signal = new AbortController().signal,
|
||||||
|
surface = createSurfaceServices({
|
||||||
|
terrainSource: createApplicationRequestServices().terrain,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return createApplicationCatalog({
|
return createApplicationCatalog({
|
||||||
|
surface,
|
||||||
sources: createStandaloneLayerSources(),
|
sources: createStandaloneLayerSources(),
|
||||||
signal,
|
signal,
|
||||||
vesselOptions: {
|
vesselOptions: {
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
export { createApplicationScene as createStandaloneScene } from '../app/scene.js';
|
import { createApplicationScene } from '../app/scene.js';
|
||||||
|
import { createApplicationRequestServices } from '../services/requests.js';
|
||||||
|
/** Supply standalone request services when directly constructing a scene. */
|
||||||
|
export function createStandaloneScene(options) {
|
||||||
|
return createApplicationScene({
|
||||||
|
requestServices: createApplicationRequestServices(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export class StyleManager extends ApplicationControls {
|
|||||||
...options,
|
...options,
|
||||||
services: {
|
services: {
|
||||||
...catalogControlServices(getStandaloneCatalog()),
|
...catalogControlServices(getStandaloneCatalog()),
|
||||||
|
...getStandaloneCatalog().surface.controlServices,
|
||||||
...options.services,
|
...options.services,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export class StyleManager {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
viewer,
|
viewer,
|
||||||
{ mapStackController = null, placeSearch, services } = {},
|
{ mapStackController = null, placeSearch, services, requestServices } = {},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
IntelHUD,
|
IntelHUD,
|
||||||
@@ -293,7 +293,10 @@ export class StyleManager {
|
|||||||
this._orbitIndicator = null;
|
this._orbitIndicator = null;
|
||||||
|
|
||||||
// Intel HUD
|
// Intel HUD
|
||||||
this.hud = new IntelHUD(viewer, { placeSearch });
|
this.hud = new IntelHUD(viewer, {
|
||||||
|
placeSearch,
|
||||||
|
summaryService: requestServices?.summary,
|
||||||
|
});
|
||||||
this._recording.hud = this.hud;
|
this._recording.hud = this.hud;
|
||||||
this._cockpitVisionMode = 'optical';
|
this._cockpitVisionMode = 'optical';
|
||||||
this._cockpitVisionRestore = null;
|
this._cockpitVisionRestore = null;
|
||||||
|
|||||||
@@ -41,14 +41,6 @@ import {
|
|||||||
setDetectionTuning,
|
setDetectionTuning,
|
||||||
} from '../data/detection.js';
|
} from '../data/detection.js';
|
||||||
import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
|
import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
|
||||||
import {
|
|
||||||
cachedGroundFloor,
|
|
||||||
cachedMeshFloor,
|
|
||||||
GROUND_FLOOR_LIFT_M,
|
|
||||||
meshFloorPreferred,
|
|
||||||
warmGroundFloor,
|
|
||||||
} from '../data/groundFloor.js';
|
|
||||||
import { sampleMeshFloorCells } from '../data/meshFloorSampler.js';
|
|
||||||
import {
|
import {
|
||||||
holdContinuousRender,
|
holdContinuousRender,
|
||||||
releaseContinuousRender,
|
releaseContinuousRender,
|
||||||
@@ -105,12 +97,6 @@ export class StyleManager extends ApplicationShell {
|
|||||||
setDetectionTuning,
|
setDetectionTuning,
|
||||||
isTr3b,
|
isTr3b,
|
||||||
toggleTr3b,
|
toggleTr3b,
|
||||||
cachedGroundFloor,
|
|
||||||
cachedMeshFloor,
|
|
||||||
GROUND_FLOOR_LIFT_M,
|
|
||||||
meshFloorPreferred,
|
|
||||||
warmGroundFloor,
|
|
||||||
sampleMeshFloorCells,
|
|
||||||
holdContinuousRender,
|
holdContinuousRender,
|
||||||
releaseContinuousRender,
|
releaseContinuousRender,
|
||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
|
|||||||
+19
-18
@@ -13,10 +13,10 @@ import { contextModeWord } from '../contextModePolicy.js';
|
|||||||
import { createAnalystEngine } from '../data/analystEngine.js';
|
import { createAnalystEngine } from '../data/analystEngine.js';
|
||||||
import { layerFeedState } from '../data/manager.js';
|
import { layerFeedState } from '../data/manager.js';
|
||||||
import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js';
|
import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js';
|
||||||
import { cachedGroundFloor, warmGroundFloor } from '../data/groundFloor.js';
|
import * as defaultFloorServices from '../data/groundFloor.js';
|
||||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||||
import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js';
|
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
||||||
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
||||||
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
||||||
|
|
||||||
@@ -263,7 +263,11 @@ const BASEMAP_CONTEXT_WAIT_MS = 1500;
|
|||||||
const viewTargetCache = new WeakMap();
|
const viewTargetCache = new WeakMap();
|
||||||
|
|
||||||
/** Create application actions over the supplied scene and services. */
|
/** Create application actions over the supplied scene and services. */
|
||||||
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch }) {
|
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch, floorServices = defaultFloorServices, annotationResolver = defaultAnnotationResolver, searchNavigation = searchAndFlyTo }) {
|
||||||
|
// Voice enable times and analyst follow-up memory belong to this runner.
|
||||||
|
const _layerEnabledAt = new Map();
|
||||||
|
let analystEngine;
|
||||||
|
const resolveRegionRing = (name) => annotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch);
|
||||||
installViewTargetPrewarm(viewer);
|
installViewTargetPrewarm(viewer);
|
||||||
initCameraVerbs(viewer, getViewTargetCartesian);
|
initCameraVerbs(viewer, getViewTargetCartesian);
|
||||||
return async function runGevAction(name, rawArgs = {}, runOptions = {}) {
|
return async function runGevAction(name, rawArgs = {}, runOptions = {}) {
|
||||||
@@ -486,7 +490,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, {
|
const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, {
|
||||||
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch,
|
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch, resolveRegionRing,
|
||||||
})).query({
|
})).query({
|
||||||
layers: [layerId],
|
layers: [layerId],
|
||||||
scope: { kind: 'view' },
|
scope: { kind: 'view' },
|
||||||
@@ -745,7 +749,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||||||
|
|
||||||
if (name === 'fly_to_location') {
|
if (name === 'fly_to_location') {
|
||||||
return flyToRequestedLocation(viewer, args, {
|
return flyToRequestedLocation(viewer, args, {
|
||||||
placeSearch, signal: runOptions.signal,
|
placeSearch, searchNavigation, signal: runOptions.signal,
|
||||||
runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function'
|
runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function'
|
||||||
? (navigate) => styleManager.runImmediateLocationNavigation(navigate)
|
? (navigate) => styleManager.runImmediateLocationNavigation(navigate)
|
||||||
: null,
|
: null,
|
||||||
@@ -791,7 +795,8 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'analyst_query') {
|
if (name === 'analyst_query') {
|
||||||
return runAnalystQuery(viewer, dataManager, args, placeSearch);
|
analystEngine ||= createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch, resolveRegionRing }));
|
||||||
|
return runAnalystQuery(analystEngine, dataManager, args, _layerEnabledAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'move_camera') {
|
if (name === 'move_camera') {
|
||||||
@@ -804,9 +809,9 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||||||
return flyRoute(
|
return flyRoute(
|
||||||
annotations?.list?.() || [],
|
annotations?.list?.() || [],
|
||||||
args,
|
args,
|
||||||
(lat, lon) => cachedGroundFloor(lat, lon),
|
(lat, lon) => floorServices.cachedGroundFloor(lat, lon),
|
||||||
(navigate) => runManagedVoiceNavigation(styleManager, 'route', 'fly_route', navigate),
|
(navigate) => runManagedVoiceNavigation(styleManager, 'route', 'fly_route', navigate),
|
||||||
(cells) => warmGroundFloor(cells),
|
(cells) => floorServices.warmGroundFloor(cells),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2166,7 +2171,7 @@ function normalizeStyle(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function flyToRequestedLocation(viewer, args, {
|
async function flyToRequestedLocation(viewer, args, {
|
||||||
placeSearch = unavailablePlaceSearch, signal,
|
placeSearch = unavailablePlaceSearch, searchNavigation = searchAndFlyTo, signal,
|
||||||
onStart = null,
|
onStart = null,
|
||||||
runImmediate = null,
|
runImmediate = null,
|
||||||
beginDeferred = null,
|
beginDeferred = null,
|
||||||
@@ -2281,7 +2286,7 @@ async function flyToRequestedLocation(viewer, args, {
|
|||||||
const generation = typeof beginDeferred === 'function' ? beginDeferred() : null;
|
const generation = typeof beginDeferred === 'function' ? beginDeferred() : null;
|
||||||
if (generation === false) return cancelled(query);
|
if (generation === false) return cancelled(query);
|
||||||
const managedDeferred = typeof reassertDeferred === 'function';
|
const managedDeferred = typeof reassertDeferred === 'function';
|
||||||
const destination = await searchAndFlyTo(viewer, query, {
|
const destination = await searchNavigation(viewer, query, {
|
||||||
placeSearch, signal,
|
placeSearch, signal,
|
||||||
...(rangeM ? { range: rangeM } : {}),
|
...(rangeM ? { range: rangeM } : {}),
|
||||||
forceClose: args.viewMode === 'close',
|
forceClose: args.viewMode === 'close',
|
||||||
@@ -3218,9 +3223,6 @@ function clampNumber(value, min, max, fallback) {
|
|||||||
* the voice payload. One engine per runner keeps follow-up memory
|
* the voice payload. One engine per runner keeps follow-up memory
|
||||||
* ("which of those is closest?") scoped to the session.
|
* ("which of those is closest?") scoped to the session.
|
||||||
*/
|
*/
|
||||||
/** layerId → epoch ms of last voice-driven enable (analyst warm-up honesty). */
|
|
||||||
const _layerEnabledAt = new Map();
|
|
||||||
let _analystEngine = null;
|
|
||||||
/** Layers whose loaded set follows the camera, so a loaded count is not a world count. */
|
/** Layers whose loaded set follows the camera, so a loaded count is not a world count. */
|
||||||
const VIEWPORT_LOADED_LAYERS = new Set(['flights']);
|
const VIEWPORT_LOADED_LAYERS = new Set(['flights']);
|
||||||
|
|
||||||
@@ -3239,7 +3241,7 @@ function activeContactsWindow(dataManager) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch } = {}) {
|
function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch, resolveRegionRing = (name) => defaultAnnotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch) } = {}) {
|
||||||
return {
|
return {
|
||||||
getRecords(layerKey) {
|
getRecords(layerKey) {
|
||||||
const layer = dataManager.layers.get(layerKey);
|
const layer = dataManager.layers.get(layerKey);
|
||||||
@@ -3251,7 +3253,7 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac
|
|||||||
? (mod.getAnalystRecords(requestedLimit) || [])
|
? (mod.getAnalystRecords(requestedLimit) || [])
|
||||||
: (mod.getAnalystRecords() || []);
|
: (mod.getAnalystRecords() || []);
|
||||||
},
|
},
|
||||||
resolveRegionRing: (name) => resolveRegionRingForQuery(name, undefined, placeSearch),
|
resolveRegionRing,
|
||||||
/**
|
/**
|
||||||
* The active Contacts subject, when there is one — the centre the operator
|
* The active Contacts subject, when there is one — the centre the operator
|
||||||
* is reasoning about while Contacts is up. Null whenever Contacts is off,
|
* is reasoning about while Contacts is up. Null whenever Contacts is off,
|
||||||
@@ -3285,9 +3287,8 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAnalystQuery(viewer, dataManager, args = {}, placeSearch = unavailablePlaceSearch) {
|
async function runAnalystQuery(analystEngine, dataManager, args = {}, _layerEnabledAt) {
|
||||||
if (!_analystEngine) _analystEngine = createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch }));
|
const result = await analystEngine.query({
|
||||||
const result = await _analystEngine.query({
|
|
||||||
layers: Array.isArray(args.layers) ? args.layers : undefined,
|
layers: Array.isArray(args.layers) ? args.layers : undefined,
|
||||||
scope: args.scope,
|
scope: args.scope,
|
||||||
filters: Array.isArray(args.filters) ? args.filters : [],
|
filters: Array.isArray(args.filters) ? args.filters : [],
|
||||||
|
|||||||
Reference in new issue
Block a user