mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Place every surface-bound layer through the shared placement
Import pins, infrastructure, ALPR, directions markers, bikeshare and the Bhote Koshi story use createSurfacePoints instead of their own sampling, budgets and partial triggers; ground snap, the mesh floor sampler, traffic, transit, Street Level, the camera ground guard, wildlife and the cockpit host read heights through readSurface or surfaceTriggers. Per-layer offsets and floor policies stay as options. The import no longer re-places pins on every camera move from the UI. Calls to engine.surface outside src/globe go from 41 sampling calls to 10, each a raycast, a one-shot fly-to height or an asynchronous detailed sample.
This commit is contained in:
1 parent
ef104aaac3
commit
82ed68f443
31 files changed
+530
-568
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"metrics": {
|
||||
"attachRendererWithViewer": 33,
|
||||
"restoreFlagCopies": 9,
|
||||
"surfaceCallsOutsideGlobe": 41,
|
||||
"surfaceCallsOutsideGlobe": 10,
|
||||
"engineSwitchLayerIds": 3,
|
||||
"cesiumLayerRendererLines": 0,
|
||||
"navaraLayerRendererLines": 0,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
||||
* held, so the correction reads as the shot settling.
|
||||
*/
|
||||
import { readSurface } from './globe/surfacePlacement.js';
|
||||
import * as geo from './geo/index.js';
|
||||
import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
|
||||
@@ -30,17 +31,19 @@ const probeScratch = geo.cartographic.create();
|
||||
/**
|
||||
* Highest rendered surface across a few cells, with how many cells answered.
|
||||
* A cell whose tiles are not streamed yet contributes nothing.
|
||||
* @param {import('./globe/contract.js').GlobeSurface} surface Live surface.
|
||||
* @param {import('./globe/contract.js').GlobeEngine} engine Live engine.
|
||||
* @param {Array<{lat: number, lon: number}>} cells Cells to probe.
|
||||
* @returns {{heightM: number, sampled: number}} Reading.
|
||||
*/
|
||||
function sampleSurfaceM(surface, cells) {
|
||||
function sampleSurfaceM(engine, cells) {
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
for (const cell of cells) {
|
||||
try {
|
||||
const height = surface.sampleHeight(
|
||||
const height = readSurface(
|
||||
engine,
|
||||
geo.cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
{ prefer: 'scene' },
|
||||
);
|
||||
if (!Number.isFinite(height)) continue;
|
||||
sampled += 1;
|
||||
@@ -135,7 +138,7 @@ export function guardCameraAboveGround(engine, at, options = {}) {
|
||||
lat: geo.math.toDegrees(carto.latitude),
|
||||
lon: geo.math.toDegrees(carto.longitude),
|
||||
};
|
||||
const { heightM, sampled } = sampleSurfaceM(engine.surface, [
|
||||
const { heightM, sampled } = sampleSurfaceM(engine, [
|
||||
{ lat: at.lat, lon: at.lon },
|
||||
below,
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSurface } from '../globe/surfacePlacement.js';
|
||||
import { bindOverlayHost } from '../overlays/bindOverlayHost.js';
|
||||
import { worldOverlayFor } from '../overlays/worldOverlay.js';
|
||||
import { waitForSignal } from '../sources/requestDeadline.js';
|
||||
@@ -1250,7 +1251,9 @@ export function createBhoteKoshiEventLayer({
|
||||
return anchor;
|
||||
const { lon, lat } = _evidenceTimeline[index]?.observation ?? {};
|
||||
const height = Number.isFinite(lon)
|
||||
? _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon, lat))
|
||||
? readSurface(_engine, Cesium.Cartographic.fromDegrees(lon, lat), {
|
||||
prefer: 'terrain',
|
||||
})
|
||||
: undefined;
|
||||
if (Number.isFinite(height))
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, height + 18, undefined, anchor);
|
||||
@@ -1626,7 +1629,7 @@ export function createBhoteKoshiEventLayer({
|
||||
// Sample once while preparing the shot, never from an animation frame.
|
||||
try {
|
||||
height =
|
||||
_engine?.surface?.sampleHeight?.(position) ??
|
||||
readSurface(_engine, position, { prefer: 'scene' }) ??
|
||||
_viewer?.scene?.sampleHeight?.(
|
||||
position,
|
||||
[_floodCore, _floodHalo, _surgePoint].filter(Boolean),
|
||||
@@ -1634,7 +1637,7 @@ export function createBhoteKoshiEventLayer({
|
||||
} catch {}
|
||||
if (!Number.isFinite(height))
|
||||
height =
|
||||
_engine?.surface?.terrainHeight(position) ??
|
||||
readSurface(_engine, position, { prefer: 'terrain' }) ??
|
||||
_viewer?.scene?.globe?.getHeight?.(position);
|
||||
}
|
||||
if (!Number.isFinite(height)) height = point.fallbackElevationM;
|
||||
|
||||
@@ -12,18 +12,14 @@
|
||||
// - ground overlays: image layers, or textured meshes at absolute altitudes,
|
||||
// tinted and turned on a canvas when the KML says so; a plain colour is
|
||||
// a ground fill or a mesh at an absolute altitude.
|
||||
// Nothing is pickable: an imported file has no card. Ground heights under
|
||||
// the points come from engine.surface, sampled again by refreshGround():
|
||||
// the terrain where the globe is drawn, else (3D tiles) the scene drawn
|
||||
// under a low camera, never an imported point (as Cesium's import kept its
|
||||
// pins out of height queries).
|
||||
// Nothing is pickable: an imported file has no card. Ground pins share
|
||||
// the surface placer and exclude every imported pin set from its samples.
|
||||
|
||||
import { createSurfacePoints } from '../globe/surfacePlacement.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { planImport } from './geoImportPlan.js';
|
||||
import {
|
||||
BATCH,
|
||||
HORIZON_LIFT_M,
|
||||
CLAMP_TILES_BELOW_M,
|
||||
MAX_ICON_PIXEL_RATIO,
|
||||
POLYGON_YIELD_MS,
|
||||
} from './geoImportLimits.js';
|
||||
@@ -112,6 +108,7 @@ export async function drawImportedFile(
|
||||
let shown = visible;
|
||||
let destroyed = false;
|
||||
let removeCull = null;
|
||||
let surface = null;
|
||||
const groundSets = [];
|
||||
const lines = [];
|
||||
const meshes = [];
|
||||
@@ -129,6 +126,7 @@ export async function drawImportedFile(
|
||||
|
||||
function release() {
|
||||
destroyed = true;
|
||||
surface?.destroy();
|
||||
removeCull?.();
|
||||
removeCull = null;
|
||||
for (const set of groundSets.splice(0)) set.destroy();
|
||||
@@ -276,14 +274,61 @@ export async function drawImportedFile(
|
||||
pointSetsOf(engine).add(look.set);
|
||||
}
|
||||
checkActive();
|
||||
for (let from = 0; from < pins.length; from += BATCH) {
|
||||
settle(heightContext(), from, from + BATCH);
|
||||
if (from + BATCH < pins.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
checkActive();
|
||||
}
|
||||
for (const pin of pins) {
|
||||
pin.position = geo.cartesian3.fromDegrees(
|
||||
pin.lon,
|
||||
pin.lat,
|
||||
pin.height ?? pin.offset,
|
||||
);
|
||||
pin.horizon = geo.cartesian3.fromDegrees(
|
||||
pin.lon,
|
||||
pin.lat,
|
||||
(pin.height ?? pin.offset) + HORIZON_LIFT_M,
|
||||
);
|
||||
pin.surface = {
|
||||
cartographic: geo.cartographic.fromDegrees(pin.lon, pin.lat),
|
||||
position: pin.position,
|
||||
offset: pin.offset,
|
||||
get show() {
|
||||
return shown && pin.height === null;
|
||||
},
|
||||
};
|
||||
}
|
||||
for (const look of looks.values()) writePins(look);
|
||||
surface = createSurfacePoints(engine, {
|
||||
groups: () =>
|
||||
[...looks.values()]
|
||||
.filter((look) => look.set)
|
||||
.map((look) => ({
|
||||
points: look.pins.map((pin) => pin.surface),
|
||||
exclude: [...pointSetsOf(engine)],
|
||||
set: {
|
||||
set(index, { position }) {
|
||||
const pin = look.pins[index];
|
||||
pin.position = position;
|
||||
pin.ground = pin.surface.surfaceHeight;
|
||||
pin.horizon = geo.cartesian3.fromDegrees(
|
||||
pin.lon,
|
||||
pin.lat,
|
||||
pin.ground + pin.offset + HORIZON_LIFT_M,
|
||||
);
|
||||
look.set.set(index, { position });
|
||||
},
|
||||
commit() {
|
||||
writePins(look);
|
||||
},
|
||||
},
|
||||
})),
|
||||
prefer: (point) =>
|
||||
engine.maps.photorealActive() && point.surfaceHeight !== undefined
|
||||
? 'tiles'
|
||||
: undefined,
|
||||
heightOffset: (point) => point.offset,
|
||||
estimateGround: true,
|
||||
screenMargin: 0,
|
||||
request,
|
||||
});
|
||||
surface.place();
|
||||
syncCull();
|
||||
} catch (error) {
|
||||
release();
|
||||
@@ -291,79 +336,6 @@ export async function drawImportedFile(
|
||||
}
|
||||
request();
|
||||
|
||||
// Where heights come from now: the terrain, or the scene drawn under a
|
||||
// low camera when the globe is not (3D tiles).
|
||||
function heightContext() {
|
||||
const surface = engine.surface;
|
||||
const tiles =
|
||||
(engine.imagery?.surface?.() ?? 'globe') !== 'globe' &&
|
||||
surface.clampToHeightSupported &&
|
||||
engine.camera.height() < CLAMP_TILES_BELOW_M;
|
||||
return {
|
||||
surface,
|
||||
tiles,
|
||||
exclude: [...pointSetsOf(engine)],
|
||||
size: tiles ? engine.host.size() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Whether a position is in the window now: clamping to 3D tiles reads
|
||||
// the tiles drawn, and a point elsewhere would only cost the query.
|
||||
function inWindow(position, { width, height }) {
|
||||
if (!engine.projector.horizonVisible(position)) return false;
|
||||
const screen = engine.projector.worldToWindow(position);
|
||||
return (
|
||||
Boolean(screen) &&
|
||||
screen.x >= 0 &&
|
||||
screen.y >= 0 &&
|
||||
screen.x <= width &&
|
||||
screen.y <= height
|
||||
);
|
||||
}
|
||||
|
||||
// The ground under a pin, falling back to the last known one.
|
||||
function groundUnder(pin, { surface, tiles, exclude, size }) {
|
||||
const at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0);
|
||||
const terrain = surface.terrainHeightSupported
|
||||
? surface.terrainHeight(at)
|
||||
: undefined;
|
||||
// A coarse ellipsoid mesh under 3D tiles can answer far below zero.
|
||||
let ground = Number.isFinite(terrain)
|
||||
? tiles
|
||||
? Math.max(0, terrain)
|
||||
: terrain
|
||||
: (pin.ground ?? 0);
|
||||
// On tiles the last height found stays until the tiles answer again:
|
||||
// the terrain under them is not where the pin stands.
|
||||
if (tiles) ground = pin.ground ?? ground;
|
||||
if (
|
||||
tiles &&
|
||||
inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size)
|
||||
) {
|
||||
const top = surface.clampToHeight(
|
||||
geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)),
|
||||
exclude,
|
||||
);
|
||||
const height = top && geo.cartographic.fromCartesian(top)?.height;
|
||||
ground = Number.isFinite(height) ? height : (pin.ground ?? ground);
|
||||
}
|
||||
return ground;
|
||||
}
|
||||
|
||||
function settle(context, from = 0, to = pins.length) {
|
||||
for (let i = from; i < Math.min(to, pins.length); i++) {
|
||||
const pin = pins[i];
|
||||
if (pin.height === null) pin.ground = groundUnder(pin, context);
|
||||
const height = pin.height ?? pin.ground + pin.offset;
|
||||
pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
|
||||
pin.horizon = geo.cartesian3.fromDegrees(
|
||||
pin.lon,
|
||||
pin.lat,
|
||||
height + HORIZON_LIFT_M,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writePins(look) {
|
||||
const { set, pins } = look;
|
||||
if (!set) return;
|
||||
@@ -443,6 +415,7 @@ export async function drawImportedFile(
|
||||
look.set?.commit();
|
||||
}
|
||||
syncCull();
|
||||
if (shown) surface.place({ all: true });
|
||||
request();
|
||||
},
|
||||
/**
|
||||
@@ -460,20 +433,9 @@ export async function drawImportedFile(
|
||||
]);
|
||||
return out;
|
||||
},
|
||||
/** Sample the ground under the points again (a map or the terrain changed). */
|
||||
async refreshGround(signal) {
|
||||
const total = pins.length;
|
||||
for (let from = 0; from < total; from += BATCH) {
|
||||
signal?.throwIfAborted();
|
||||
if (destroyed) return;
|
||||
settle(heightContext(), from, from + BATCH);
|
||||
if (from + BATCH < total)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (destroyed) return;
|
||||
for (const look of looks.values()) writePins(look);
|
||||
request();
|
||||
surface.place({ all: true });
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MAX_RAISED_RINGS,
|
||||
MAX_RAISED_VERTICES,
|
||||
OVERLAY_PIXEL_BUDGET,
|
||||
BATCH,
|
||||
} from './geoImportLimits.js';
|
||||
import { drawImportedFile } from './geoImportDisplay.js';
|
||||
import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
|
||||
@@ -34,6 +33,7 @@ for (const make of GROUND_HARNESSES) {
|
||||
},
|
||||
});
|
||||
const { engine, viewer } = harness;
|
||||
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
|
||||
if (viewer) {
|
||||
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
|
||||
viewer.scene.globe.show = true;
|
||||
@@ -184,7 +184,8 @@ for (const make of GROUND_HARNESSES) {
|
||||
);
|
||||
assert.ok(pins.every(({ options }) => options.pickable === false));
|
||||
ground = 70;
|
||||
await drawing.refreshGround();
|
||||
await engine.maps.setTerrain({ kind: 'ellipsoid' });
|
||||
engine.fake.frame();
|
||||
assert.deepEqual(heights(), [70, 80, 500]);
|
||||
ground = undefined;
|
||||
await drawing.refreshGround();
|
||||
@@ -200,15 +201,14 @@ for (const make of GROUND_HARNESSES) {
|
||||
if (viewer) {
|
||||
viewer.scene.globe.show = false;
|
||||
viewer.scene.clampToHeightSupported = true;
|
||||
} else engine.imagery.surface = () => 'tiles';
|
||||
} else await engine.maps.setPhotorealTiles({});
|
||||
engine.camera.height = () => 1000;
|
||||
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
|
||||
engine.projector.horizonVisible = () => true;
|
||||
const excluded = [];
|
||||
engine.surface.clampToHeight = (position, exclude) => {
|
||||
engine.surface.sampleHeight = (_position, exclude) => {
|
||||
excluded.push(exclude);
|
||||
const at = geo.cartographic.fromCartesian(position);
|
||||
return geo.cartesian3.fromRadians(at.longitude, at.latitude, 2000);
|
||||
return 2000;
|
||||
};
|
||||
const ground = await drawImportedFile(
|
||||
engine,
|
||||
@@ -248,15 +248,12 @@ for (const make of GROUND_HARNESSES) {
|
||||
displayTest(
|
||||
'a pin on the tiles keeps its height while out of the window',
|
||||
async ({ engine }) => {
|
||||
engine.imagery.surface = () => 'tiles';
|
||||
await engine.maps.setPhotorealTiles({});
|
||||
engine.camera.height = () => 1000;
|
||||
engine.projector.horizonVisible = () => true;
|
||||
let visible = true;
|
||||
engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
|
||||
engine.surface.clampToHeight = (position) => {
|
||||
const at = geo.cartographic.fromCartesian(position);
|
||||
return geo.cartesian3.fromRadians(at.longitude, at.latitude, 500);
|
||||
};
|
||||
engine.surface.sampleHeight = () => 500;
|
||||
const drawing = await drawImportedFile(
|
||||
engine,
|
||||
geoJsonRecords({ type: 'Point', coordinates: [1, 2] }, '#39d0ff'),
|
||||
@@ -272,12 +269,11 @@ for (const make of GROUND_HARNESSES) {
|
||||
projected = Math.round(geo.cartographic.fromCartesian(position).height);
|
||||
return projected >= 500 ? { x: 1, y: 1 } : null;
|
||||
};
|
||||
engine.surface.clampToHeight = () =>
|
||||
geo.cartesian3.fromDegrees(1, 2, 700);
|
||||
engine.surface.sampleHeight = () => 700;
|
||||
await drawing.refreshGround();
|
||||
assert.equal(projected, 500, 'visibility uses the last measured height');
|
||||
assert.equal(height(), 700);
|
||||
engine.surface.clampToHeight = () => undefined;
|
||||
engine.surface.sampleHeight = () => undefined;
|
||||
await drawing.refreshGround();
|
||||
assert.equal(
|
||||
height(),
|
||||
@@ -1258,17 +1254,17 @@ for (const make of GROUND_HARNESSES) {
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'tile clamps only sample visible pins and yield between batches',
|
||||
'tile samples only measure visible pins and yield between passes',
|
||||
async ({ engine }) => {
|
||||
engine.imagery.surface = () => 'tiles';
|
||||
await engine.maps.setPhotorealTiles({});
|
||||
engine.camera.height = () => 1000;
|
||||
engine.projector.horizonVisible = () => true;
|
||||
let visible = false;
|
||||
engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
|
||||
let clamps = 0;
|
||||
engine.surface.clampToHeight = (position) => {
|
||||
engine.surface.sampleHeight = () => {
|
||||
clamps++;
|
||||
return position;
|
||||
return 0;
|
||||
};
|
||||
const records = geoJsonRecords(
|
||||
{
|
||||
@@ -1285,8 +1281,11 @@ for (const make of GROUND_HARNESSES) {
|
||||
beforeYield = clamps;
|
||||
}, 0);
|
||||
await drawing.refreshGround();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
clearTimeout(heartbeat);
|
||||
assert.equal(beforeYield, BATCH);
|
||||
assert.ok(beforeYield > 0 && beforeYield <= 16);
|
||||
while (clamps < 600)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(clamps, 600);
|
||||
drawing.destroy();
|
||||
},
|
||||
|
||||
@@ -41,12 +41,8 @@ export const MAX_RAISED_VERTICES = 32_768;
|
||||
export const MAX_RAISED_RINGS = 128;
|
||||
// Yield between polygons after half a 60 Hz frame, preserving the 8 ms work slice.
|
||||
export const POLYGON_YIELD_MS = 8;
|
||||
// Bound synchronous surface queries between yields; the existing batch is 256 pins.
|
||||
export const BATCH = 256;
|
||||
// Keep below-ellipsoid ground visible at the horizon, matching GhostMaps' 2 km lift.
|
||||
export const HORIZON_LIFT_M = 2000;
|
||||
// Limit tile clamping to a low camera, matching Cesium's 250 km threshold.
|
||||
export const CLAMP_TILES_BELOW_M = 250_000;
|
||||
|
||||
// Cesium draws ground lines over fills and ground rectangles over layer rasters.
|
||||
export const POLYGON_ORDER = 0;
|
||||
|
||||
@@ -195,6 +195,8 @@ test('consumer build includes only infrastructure code and engine-free geometry,
|
||||
'scaleToGeodeticSurface',
|
||||
'simon1994',
|
||||
].map((name) => `src/geo/${name}.js`),
|
||||
'src/globe/surfaceDetail.js',
|
||||
'src/globe/surfacePlacement.js',
|
||||
'src/layers/infrastructure/index.js',
|
||||
'src/layers/infrastructure/render/display.js',
|
||||
'src/layers/shared/color.js',
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
// floor cell, else on the terrain, and waits unseen until one of them is
|
||||
// known; floors still resolving are asked for once per render.
|
||||
|
||||
import {
|
||||
createSurfacePoints,
|
||||
readSurface,
|
||||
} from '../../../globe/surfacePlacement.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { distanceScale } from '../../../globe/cpuPicks.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
@@ -96,20 +100,23 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
geo.cartographic.fromDegrees(record.longitude, record.latitude);
|
||||
const ownSets = () => [normal, selected].filter(Boolean);
|
||||
|
||||
// Each sample is a depth render on Cesium, a raycast on Navara.
|
||||
function sampleHeight(record) {
|
||||
if (!engine.surface.supported) return undefined;
|
||||
try {
|
||||
return engine.surface.sampleHeight(location(record), ownSets());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function terrainHeight(record) {
|
||||
return engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(location(record))
|
||||
: undefined;
|
||||
}
|
||||
const sampleHeight = (record) =>
|
||||
readSurface(engine, location(record), {
|
||||
exclude: ownSets(),
|
||||
prefer: 'tiles',
|
||||
});
|
||||
const terrainHeight = (record) =>
|
||||
readSurface(engine, location(record), { prefer: 'terrain' });
|
||||
let surface = null;
|
||||
const surfaceSet = {
|
||||
set(index, { position }) {
|
||||
const marker = [...markers.values()][index];
|
||||
marker.position = position;
|
||||
marker.resolved = validAlprGroundHeight(marker.surface.surfaceHeight);
|
||||
syncMarker(marker);
|
||||
},
|
||||
commit: () => flushSprites(),
|
||||
};
|
||||
/** The rendered surface under a camera, else the terrain, else its floor. */
|
||||
function surfaceHeight(record) {
|
||||
let height = sampleHeight(record);
|
||||
@@ -131,6 +138,15 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
return {
|
||||
clamped,
|
||||
resolved,
|
||||
surface: {
|
||||
cartographic: location(record),
|
||||
surfaceHeight: resolved ? height : undefined,
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
resolved ? height + MARKER_FLOOR_LIFT_M : 0,
|
||||
),
|
||||
},
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
@@ -250,6 +266,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
marker.drawn = null;
|
||||
syncMarker(marker);
|
||||
}
|
||||
surface?.place({ all: true });
|
||||
resolveFloors();
|
||||
flushSprites();
|
||||
governorRequestRender('alpr-surface', engine);
|
||||
@@ -461,6 +478,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
}
|
||||
flushSprites();
|
||||
if (changed) state.renderRevision += 1;
|
||||
surface?.place();
|
||||
state.onScreen = countOnScreen();
|
||||
resolveFloors();
|
||||
const chosen = state.selectedId ? markers.get(state.selectedId) : null;
|
||||
@@ -674,6 +692,20 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
initialized = true;
|
||||
surface = createSurfacePoints(engine, {
|
||||
groups: () => [
|
||||
{
|
||||
set: surfaceSet,
|
||||
points: [...markers.values()].map((marker) => {
|
||||
marker.surface.show = visible && marker.clamped;
|
||||
return marker.surface;
|
||||
}),
|
||||
exclude: ownSets(),
|
||||
},
|
||||
],
|
||||
heightOffset: MARKER_FLOOR_LIFT_M,
|
||||
request: () => governorRequestRender('alpr-surface', engine),
|
||||
});
|
||||
removeMove = engine.camera.moveEnd.addEventListener(onMove);
|
||||
removeFrame = engine.frames.preRender.addEventListener(frame);
|
||||
overlay.init();
|
||||
@@ -688,11 +720,14 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
: null;
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
if (visible) surface?.place({ all: true });
|
||||
for (const marker of markers.values()) syncMarker(marker);
|
||||
flushSprites();
|
||||
},
|
||||
destroy(options) {
|
||||
if (!initialized) return;
|
||||
surface?.destroy();
|
||||
surface = null;
|
||||
clearRendered(options);
|
||||
overlay.destroy();
|
||||
removeClick?.();
|
||||
|
||||
@@ -281,6 +281,12 @@ for (const makeHarness of HARNESSES) {
|
||||
assert.ok(
|
||||
Math.abs(heightOf(h.badge('alpr-cameras', 0).position) - 321.5) < 0.01,
|
||||
);
|
||||
h.terrain(410);
|
||||
await h.engine.maps.setTerrain({ kind: 'ellipsoid' });
|
||||
h.frame();
|
||||
assert.ok(
|
||||
Math.abs(heightOf(h.badge('alpr-cameras', 0).position) - 411.5) < 0.01,
|
||||
);
|
||||
h.terrain(undefined);
|
||||
h.load(records(1).map((r) => ({ ...r, latitude: 30.0001 })));
|
||||
await settle();
|
||||
|
||||
@@ -54,7 +54,7 @@ test('an image that will not load rejects; story displays release their frame li
|
||||
const before = engine.frames.preRender.numberOfListeners;
|
||||
const story = display.draw(data);
|
||||
const locator = createBhoteKoshiLocatorDisplay(engine, { data });
|
||||
assert.equal(engine.frames.preRender.numberOfListeners, before + 2);
|
||||
assert.equal(engine.frames.preRender.numberOfListeners, before + 4);
|
||||
story.destroy();
|
||||
locator.destroy();
|
||||
assert.equal(engine.frames.preRender.numberOfListeners, before);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Samples the authored story graphics into ground sets and point movers.
|
||||
// The story owns reveal time; this display owns only its attached drawing.
|
||||
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
|
||||
const value = (property) =>
|
||||
@@ -25,6 +26,13 @@ export function drawStorySource(engine, data) {
|
||||
const entries = new Map();
|
||||
let order = 0;
|
||||
let destroyed = false;
|
||||
const groups = new Set();
|
||||
const surface = createSurfacePoints(engine, {
|
||||
groups: () => groups,
|
||||
prefer: 'scene',
|
||||
estimateGround: true,
|
||||
request: () => engine.render.request(data.name),
|
||||
});
|
||||
|
||||
function point(entity) {
|
||||
const graphics = entity.point;
|
||||
@@ -49,22 +57,27 @@ export function drawStorySource(engine, data) {
|
||||
: null,
|
||||
},
|
||||
});
|
||||
const row = { show: false };
|
||||
const group = { set, points: [row] };
|
||||
groups.add(group);
|
||||
let authored;
|
||||
let previous;
|
||||
return {
|
||||
paint(show) {
|
||||
let position = value(entity.position);
|
||||
show = Boolean(show && value(graphics.show) !== false && position);
|
||||
if (show && value(graphics.heightReference) === 1) {
|
||||
const place = geo.cartographic.fromCartesian(position);
|
||||
const height =
|
||||
engine.surface.sampleHeight(place, [set]) ??
|
||||
engine.surface.terrainHeight(place);
|
||||
if (Number.isFinite(height))
|
||||
position = geo.cartesian3.fromRadians(
|
||||
place.longitude,
|
||||
place.latitude,
|
||||
height,
|
||||
);
|
||||
const clamped = show && value(graphics.heightReference) === 1;
|
||||
if (clamped && !row.show) delete row.surfaceRevision;
|
||||
row.show = clamped;
|
||||
if (row.show) {
|
||||
if (!authored || !geo.cartesian3.equals(position, authored)) {
|
||||
authored = geo.cartesian3.clone(position);
|
||||
row.cartographic = geo.cartographic.fromCartesian(position);
|
||||
row.position = position;
|
||||
delete row.surfaceRevision;
|
||||
delete row.surfaceHeight;
|
||||
}
|
||||
position = row.position;
|
||||
}
|
||||
const fields = show
|
||||
? {
|
||||
@@ -92,7 +105,10 @@ export function drawStorySource(engine, data) {
|
||||
set.commit();
|
||||
previous = snapshot;
|
||||
},
|
||||
destroy: () => set.destroy(),
|
||||
destroy() {
|
||||
groups.delete(group);
|
||||
set.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +201,7 @@ export function drawStorySource(engine, data) {
|
||||
}
|
||||
item.paint(data.show !== false && value(entity.show) !== false);
|
||||
}
|
||||
surface.place();
|
||||
}
|
||||
const off = engine.frames.preRender.addEventListener(frame);
|
||||
engine.render.request(data.name);
|
||||
@@ -193,6 +210,7 @@ export function drawStorySource(engine, data) {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
off();
|
||||
surface.destroy();
|
||||
for (const item of entries.values()) item.destroy();
|
||||
entries.clear();
|
||||
engine.render.request(data.name);
|
||||
|
||||
@@ -166,9 +166,11 @@ for (const make of GROUND_HARNESSES) {
|
||||
};
|
||||
return set;
|
||||
};
|
||||
h.engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
|
||||
let ground = 1200;
|
||||
h.engine.surface.sampleHeight = (_place, exclude) => {
|
||||
assert.deepEqual(exclude, [pointSet]);
|
||||
return 1200;
|
||||
return ground;
|
||||
};
|
||||
const display = drawStorySource(h.engine, source);
|
||||
const frame = () => h.engine.fake.frame();
|
||||
@@ -179,11 +181,21 @@ for (const make of GROUND_HARNESSES) {
|
||||
assert.equal(options.point.outlinePx, 3);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(writes[0].position).height - 1200,
|
||||
Cesium.Cartographic.fromCartesian(writes.at(-1).position).height - 1200,
|
||||
) < 1e-6,
|
||||
);
|
||||
frame();
|
||||
assert.equal(writes.length, 1);
|
||||
const settled = writes.length;
|
||||
frame();
|
||||
assert.equal(writes.length, settled);
|
||||
ground = 1250;
|
||||
h.engine.fake.maps.terrainRevision++;
|
||||
frame();
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(writes.at(-1).position).height - 1250,
|
||||
) < 0.01,
|
||||
);
|
||||
size = 38;
|
||||
outline = 0.12;
|
||||
frame();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { writeSelectedMarker } from '../../shared/selectionKeeper.js';
|
||||
// - lifecycle: what init, enable, show, disable, detach and destroy own.
|
||||
// The layer (index.js) merges these into its parts.
|
||||
|
||||
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import {
|
||||
@@ -143,6 +144,37 @@ export function createBikeshareDisplay(engine, context) {
|
||||
commitStations();
|
||||
}
|
||||
|
||||
const surface = createSurfacePoints(engine, {
|
||||
groups: () => [
|
||||
{
|
||||
points: [...state._stationRenderMap.values()].map(
|
||||
(record) => record.point.surface,
|
||||
),
|
||||
exclude: [stations, highlight].filter(Boolean),
|
||||
set: stations && {
|
||||
set(index, fields) {
|
||||
const record = [...state._stationRenderMap.values()][index];
|
||||
record.point.position = fields.position;
|
||||
stations.set(index, fields);
|
||||
},
|
||||
commit() {
|
||||
stations.commit();
|
||||
commitHighlight();
|
||||
selection.refreshSelectedCard();
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
prefer: 'scene',
|
||||
estimateGround: true,
|
||||
heightOffset: POINT_HEIGHT_OFFSET_M,
|
||||
request: () => engine.render.request(LAYER_ID),
|
||||
});
|
||||
function placeStations() {
|
||||
commitStations();
|
||||
surface.place();
|
||||
}
|
||||
|
||||
const rendering = createStationPoints(context, {
|
||||
// On the rendered surface where it answers, a small height otherwise.
|
||||
position(station) {
|
||||
@@ -150,21 +182,20 @@ export function createBikeshareDisplay(engine, context) {
|
||||
const lat = Number(station?.lat);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
ensureSets();
|
||||
const sampled = engine.surface.supported
|
||||
? engine.surface.sampleHeight(
|
||||
geo.cartographic.fromDegrees(lon, lat),
|
||||
[stations, highlight].filter(Boolean),
|
||||
)
|
||||
: undefined;
|
||||
const height =
|
||||
(Number.isFinite(sampled) ? sampled : 0) + POINT_HEIGHT_OFFSET_M;
|
||||
return geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
return geo.cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
|
||||
},
|
||||
create(record, { position, pixelSize }) {
|
||||
ensureSets();
|
||||
record.index = state._stationRenderMap.size;
|
||||
record.point = {
|
||||
position,
|
||||
surface: {
|
||||
position,
|
||||
cartographic: geo.cartographic.fromCartesian(position),
|
||||
get show() {
|
||||
return visible;
|
||||
},
|
||||
},
|
||||
pixelSize,
|
||||
color: COLOR_NEUTRAL,
|
||||
show: true,
|
||||
@@ -172,13 +203,13 @@ export function createBikeshareDisplay(engine, context) {
|
||||
writePoint(record);
|
||||
return record.point;
|
||||
},
|
||||
afterAdd: commitStations,
|
||||
afterAdd: placeStations,
|
||||
afterRemove() {
|
||||
reindex();
|
||||
commitStations();
|
||||
},
|
||||
update: writePoint,
|
||||
afterUpdate: commitStations,
|
||||
afterUpdate: placeStations,
|
||||
});
|
||||
|
||||
const selection = {
|
||||
@@ -335,6 +366,7 @@ export function createBikeshareDisplay(engine, context) {
|
||||
/** Shows the points and card, takes clicks and follows the camera. */
|
||||
show() {
|
||||
setVisible(true);
|
||||
surface.place({ all: true });
|
||||
state._overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true);
|
||||
parts.display._installClickHandler();
|
||||
// Pick ownership: station point ids are render-map keys.
|
||||
@@ -374,6 +406,7 @@ export function createBikeshareDisplay(engine, context) {
|
||||
parts.display._clearSelection();
|
||||
releaseInput();
|
||||
detachCamera();
|
||||
surface.destroy();
|
||||
destroySets();
|
||||
destroyed = true;
|
||||
},
|
||||
@@ -393,6 +426,7 @@ export function createBikeshareDisplay(engine, context) {
|
||||
}
|
||||
detachCamera();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
surface.destroy();
|
||||
destroySets();
|
||||
destroyed = true;
|
||||
state._engine = null;
|
||||
|
||||
@@ -272,6 +272,29 @@ for (const makeHarness of HARNESSES) {
|
||||
const point = f.layer.getDetectableObjects()[0].position;
|
||||
near(geo.cartographic.fromCartesian(point).height, 177, 'height');
|
||||
|
||||
f.display._selectStation(key);
|
||||
f.surface(210);
|
||||
await f.engine.maps.setTerrain({ kind: 'ellipsoid' });
|
||||
f.engine.fake.frame();
|
||||
near(
|
||||
geo.cartographic.fromCartesian(
|
||||
f.context.state._stationRenderMap.get(key).point.position,
|
||||
).height,
|
||||
212,
|
||||
'selected height',
|
||||
);
|
||||
const selected =
|
||||
f.engine.fake.moverSet('bikeshare-selected').store.positions;
|
||||
near(
|
||||
geo.cartographic.fromCartesian({
|
||||
x: selected[0],
|
||||
y: selected[1],
|
||||
z: selected[2],
|
||||
}).height,
|
||||
212,
|
||||
'highlight height',
|
||||
);
|
||||
|
||||
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
|
||||
assert.deepEqual(
|
||||
call.args[1].map((set) => set.id),
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// Placement, routing, anchoring, selection and the route flight stay in the
|
||||
// layer.
|
||||
|
||||
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import {
|
||||
MARKER_COLORS,
|
||||
@@ -121,31 +122,37 @@ export function createDirectionsDisplay(engine) {
|
||||
|
||||
const own = () => [markers, letters, steps].filter(Boolean);
|
||||
|
||||
// The rendered surface under a marker (tiles, else terrain), without the
|
||||
// display's own markers.
|
||||
function groundPosition({ lon, lat }) {
|
||||
const carto = geo.cartographic.fromDegrees(lon, lat);
|
||||
let height;
|
||||
try {
|
||||
height = engine.surface.sampleHeightDetailed(carto, {
|
||||
const markerPoints = LETTERS.map(() => ({ show: false }));
|
||||
const surface = createSurfacePoints(engine, {
|
||||
groups: () => [
|
||||
{
|
||||
points: markerPoints,
|
||||
exclude: own(),
|
||||
})?.height;
|
||||
} catch {
|
||||
height = undefined;
|
||||
}
|
||||
return geo.cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
Number.isFinite(height) ? height : 0,
|
||||
);
|
||||
}
|
||||
set: {
|
||||
set(index, fields) {
|
||||
markers.set(index, fields);
|
||||
letters?.set(index, fields);
|
||||
},
|
||||
commit() {
|
||||
markers.commit();
|
||||
letters?.commit();
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
prefer: 'scene',
|
||||
estimateGround: true,
|
||||
request: () => engine.render.request(ID),
|
||||
});
|
||||
|
||||
function placeMarkers() {
|
||||
if (destroyed) return;
|
||||
LETTERS.forEach((letter, index) => {
|
||||
const point = ends[letter];
|
||||
const show = visible && Boolean(point);
|
||||
const position = point ? groundPosition(point) : undefined;
|
||||
const row = markerPoints[index];
|
||||
row.show = show;
|
||||
const position = point ? row.position : undefined;
|
||||
markers.set(index, {
|
||||
...(position ? { position } : {}),
|
||||
color: MARKER_COLORS[letter],
|
||||
@@ -157,6 +164,7 @@ export function createDirectionsDisplay(engine) {
|
||||
show,
|
||||
});
|
||||
});
|
||||
surface.place();
|
||||
markers.setCount(LETTERS.length);
|
||||
markers.commit();
|
||||
if (letters) {
|
||||
@@ -203,15 +211,6 @@ export function createDirectionsDisplay(engine) {
|
||||
steps.commit();
|
||||
}
|
||||
|
||||
const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => {
|
||||
if (ends.a || ends.b) placeMarkers();
|
||||
});
|
||||
let tilesLoaded = engine.surface.tilesLoaded();
|
||||
const removeSurface = engine.frames.preRender.addEventListener(() => {
|
||||
const loaded = engine.surface.tilesLoaded();
|
||||
if (loaded && !tilesLoaded && visible && (ends.a || ends.b)) placeMarkers();
|
||||
tilesLoaded = loaded;
|
||||
});
|
||||
placeMarkers();
|
||||
|
||||
return {
|
||||
@@ -219,6 +218,12 @@ export function createDirectionsDisplay(engine) {
|
||||
setMarker(letter, point) {
|
||||
if (destroyed || !LETTERS.includes(letter)) return;
|
||||
ends[letter] = point ? { lon: point.lon, lat: point.lat } : null;
|
||||
markerPoints[LETTERS.indexOf(letter)] = point
|
||||
? {
|
||||
cartographic: geo.cartographic.fromDegrees(point.lon, point.lat),
|
||||
position: geo.cartesian3.fromDegrees(point.lon, point.lat),
|
||||
}
|
||||
: { show: false };
|
||||
placeMarkers();
|
||||
},
|
||||
/** Draws the route ([lon, lat] pairs), or removes it (null). */
|
||||
@@ -276,13 +281,13 @@ export function createDirectionsDisplay(engine) {
|
||||
visible = Boolean(next);
|
||||
route?.show(visible);
|
||||
placeMarkers();
|
||||
if (visible) surface.place({ all: true });
|
||||
writeSteps();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
removeMoveEnd();
|
||||
removeSurface();
|
||||
surface.destroy();
|
||||
route?.destroy();
|
||||
steps?.destroy();
|
||||
markers.destroy();
|
||||
|
||||
@@ -351,3 +351,30 @@ test('route endpoints refine when the surface finishes loading', (t) => {
|
||||
) < 0.01,
|
||||
);
|
||||
});
|
||||
|
||||
test('A/B heights follow a terrain replacement with a parked camera', async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
t.after(() => engine.destroy());
|
||||
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
|
||||
engine.fake.script('terrainHeight', 40);
|
||||
const display = createDirectionsDisplay(engine);
|
||||
t.after(() => display.destroy());
|
||||
display.setMarker('a', { lon: 0, lat: 0 });
|
||||
display.setMarker('b', { lon: 1, lat: 0 });
|
||||
const heights = () => {
|
||||
const { positions } = engine.fake.moverSet('directions-markers').store;
|
||||
return [0, 3].map(
|
||||
(i) =>
|
||||
geo.cartographic.fromCartesian({
|
||||
x: positions[i],
|
||||
y: positions[i + 1],
|
||||
z: positions[i + 2],
|
||||
}).height,
|
||||
);
|
||||
};
|
||||
for (const height of heights()) near(height, 40, 'original terrain');
|
||||
engine.fake.script('terrainHeight', 70);
|
||||
await engine.maps.setTerrain({ kind: 'ellipsoid' });
|
||||
engine.fake.frames(1);
|
||||
for (const height of heights()) near(height, 70, 'replacement terrain');
|
||||
});
|
||||
@@ -17,6 +17,11 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
|
||||
// surface. The references' cards go to the shared world-overlay host. A
|
||||
// click on any of them selects the feature and flies there.
|
||||
|
||||
import {
|
||||
createSurfacePoints,
|
||||
readSurface,
|
||||
surfaceTriggers,
|
||||
} from '../../../globe/surfacePlacement.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import {
|
||||
@@ -58,16 +63,7 @@ const FLY_SECONDS = 1.5;
|
||||
* sampled once and the base moves onto it.
|
||||
*/
|
||||
export const GROUND_SAMPLE_MAX_DISTANCE_M = 75000;
|
||||
export const GROUND_SAMPLE_RETRY_MS = 2000;
|
||||
const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
|
||||
/**
|
||||
* A sampleable scene can still never answer under a feature; each retry
|
||||
* asks for the frame the next one needs, so after this many armed retries
|
||||
* without a newly grounded base the layer stops asking. A camera move, a
|
||||
* grounded base or sampling becoming supported again re-opens the budget.
|
||||
* 30 × 2 s is far longer than a tile stream-in.
|
||||
*/
|
||||
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
||||
/** Tip moves below this are camera noise and are not drawn. */
|
||||
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
||||
const TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
||||
@@ -151,10 +147,7 @@ export function createInfrastructureDisplay(
|
||||
let dirty = true,
|
||||
lastWalk = Number.NEGATIVE_INFINITY,
|
||||
walkCount = 0;
|
||||
// The ground-sample retry.
|
||||
let retryTimer = null,
|
||||
retryArms = 0,
|
||||
lastCapability = null;
|
||||
let surface = null;
|
||||
// Per walk: the view-projection matrix, computed on first use.
|
||||
const viewProjection = geo.matrix4.create();
|
||||
let viewProjectionWalk = -1;
|
||||
@@ -165,23 +158,6 @@ export function createInfrastructureDisplay(
|
||||
|
||||
const photoreal = () => engine.maps?.photorealActive?.() === true;
|
||||
|
||||
function scheduleGroundRetry() {
|
||||
if (retryTimer || !dataset || !engine.surface.supported) return;
|
||||
if (retryArms >= GROUND_SAMPLE_MAX_ARMED_RETRIES) return;
|
||||
retryArms += 1;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (dataset && !destroyed) wake(`local-ground-retry:${id}`);
|
||||
}, GROUND_SAMPLE_RETRY_MS);
|
||||
}
|
||||
|
||||
function clearGroundRetry() {
|
||||
retryArms = 0;
|
||||
lastCapability = null;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
/** The record's vertical at `height` metres above the ellipsoid. */
|
||||
function vertical(row, height, result) {
|
||||
scratchCarto.longitude = row.carto.longitude;
|
||||
@@ -227,7 +203,7 @@ export function createInfrastructureDisplay(
|
||||
function groundInView(row) {
|
||||
const terrain = photoreal()
|
||||
? undefined
|
||||
: engine.surface.terrainHeight(row.carto);
|
||||
: readSurface(engine, row.carto, { prefer: 'terrain' });
|
||||
const known =
|
||||
finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
const low = vertical(
|
||||
@@ -262,53 +238,36 @@ export function createInfrastructureDisplay(
|
||||
row.baseMoved = true;
|
||||
}
|
||||
|
||||
// The scene height under the record, once, while the visible surface has
|
||||
// loaded. A coarse sample can lie below the loaded terrain: the terrain is
|
||||
// the floor then (not under photoreal tiles), while roofs and valid
|
||||
// below-sea-level heights stay.
|
||||
function sampleGround(row, now) {
|
||||
if (row.groundSampled || !engine.surface.supported) return false;
|
||||
if (now - row.lastSample < GROUND_SAMPLE_RETRY_MS) return false;
|
||||
if (!groundInView(row)) return false;
|
||||
row.lastSample = now;
|
||||
const globe = !photoreal();
|
||||
if (globe && !engine.surface.tilesLoaded()) return false;
|
||||
// The stem stands on the sample point: out of the sample.
|
||||
stemBatch?.show(false);
|
||||
let sampled;
|
||||
try {
|
||||
sampled = engine.surface.sampleHeight(row.carto, exclude);
|
||||
} finally {
|
||||
stemBatch?.show(true);
|
||||
}
|
||||
if (!finite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M)
|
||||
return false;
|
||||
const terrain = globe ? engine.surface.terrainHeight(row.carto) : undefined;
|
||||
if (finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M)
|
||||
sampled = Math.max(sampled, terrain);
|
||||
row.groundSampled = true;
|
||||
setGroundHeight(row, sampled);
|
||||
return true;
|
||||
}
|
||||
|
||||
// A sampled base follows refined terrain upward on the bounded walks,
|
||||
// without another sample.
|
||||
function refreshTerrainFloor(row, camera) {
|
||||
if (!row.groundSampled || photoreal() || !engine.surface.tilesLoaded())
|
||||
return false;
|
||||
if (
|
||||
geo.cartesian3.distance(camera, row.base) >= GROUND_SAMPLE_MAX_DISTANCE_M
|
||||
)
|
||||
return false;
|
||||
const height = engine.surface.terrainHeight(row.carto);
|
||||
if (
|
||||
!finite(height) ||
|
||||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
||||
height <= row.groundHeight
|
||||
)
|
||||
return false;
|
||||
setGroundHeight(row, height);
|
||||
return true;
|
||||
function placeGround() {
|
||||
const points = [...rows.values()];
|
||||
surface = createSurfacePoints(engine, {
|
||||
groups: () => {
|
||||
viewProjectionWalk = -1;
|
||||
return [
|
||||
{
|
||||
points: points.map((row) => row.surface),
|
||||
exclude: [...exclude, stemBatch].filter(Boolean),
|
||||
set: {
|
||||
set(index, { position }) {
|
||||
const row = points[index];
|
||||
setGroundHeight(
|
||||
row,
|
||||
geo.cartographic.fromCartesian(position).height,
|
||||
);
|
||||
},
|
||||
commit() {
|
||||
dirty = true;
|
||||
lastWalk = Number.NEGATIVE_INFINITY;
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
// The LOD checks the entire ground vertical before offering a point.
|
||||
screenMargin: Infinity,
|
||||
prefer: 'sceneFloor',
|
||||
request: wake,
|
||||
});
|
||||
}
|
||||
|
||||
// The tip STEM_TARGET_PX above the base at the camera's distance; a move
|
||||
@@ -317,7 +276,6 @@ export function createInfrastructureDisplay(
|
||||
const distance = finite(knownDistance)
|
||||
? knownDistance
|
||||
: geo.cartesian3.distance(pose.position, row.base);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleGround(row, now);
|
||||
const tipHeight =
|
||||
row.groundHeight +
|
||||
Math.max(distance, 1) *
|
||||
@@ -395,6 +353,9 @@ export function createInfrastructureDisplay(
|
||||
nowMs: now,
|
||||
activeLimit: selection.budget.activeLimit,
|
||||
});
|
||||
for (const id of kept.keepIds)
|
||||
if (!active.has(id) || rows.get(id).surface.surfaceHeight === undefined)
|
||||
delete rows.get(id).surface.surfaceRevision;
|
||||
active = new Set(kept.keepIds);
|
||||
grace = kept.graceState;
|
||||
budget = selection.budget.activeLimit;
|
||||
@@ -433,20 +394,18 @@ export function createInfrastructureDisplay(
|
||||
if (probe.recompute) dirty = true;
|
||||
}
|
||||
const refresh = dirty;
|
||||
// Without sampling no retry is armed and no per-record work is spent.
|
||||
const canSample = engine.surface.supported === true;
|
||||
// Sampling that comes back re-opens a spent budget on a parked camera.
|
||||
if (canSample && lastCapability === false) retryArms = 0;
|
||||
lastCapability = canSample;
|
||||
let retryPending = false;
|
||||
let grounded = false;
|
||||
if (refresh && rows.size) selectLod(pose, now);
|
||||
surface.place();
|
||||
let tipsChanged = false;
|
||||
let pinsChanged = false;
|
||||
const visible = [];
|
||||
for (const row of rows.values()) {
|
||||
const isActive = !computed || active.has(row.id);
|
||||
const isVisible = isActive && engine.projector.horizonVisible(row.base);
|
||||
const isVisible =
|
||||
isActive &&
|
||||
engine.projector.horizonVisible(
|
||||
vertical(row, Math.max(0, row.groundHeight), scratchLow),
|
||||
);
|
||||
if (row.show !== isVisible) {
|
||||
row.show = isVisible;
|
||||
tips.set(row.index, { show: isVisible });
|
||||
@@ -458,30 +417,7 @@ export function createInfrastructureDisplay(
|
||||
}
|
||||
if (!isVisible) dropStem(row.id);
|
||||
if (!isActive) continue;
|
||||
const wasSampled = row.groundSampled;
|
||||
const floorChanged = refreshTerrainFloor(row, camera);
|
||||
if (refresh || floorChanged) sizeStem(row, now, pose, view);
|
||||
else if (
|
||||
canSample &&
|
||||
!row.groundSampled &&
|
||||
now - row.lastSample >= GROUND_SAMPLE_RETRY_MS
|
||||
) {
|
||||
const distance = geo.cartesian3.distance(camera, row.base);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M && sampleGround(row, now))
|
||||
sizeStem(row, now, pose, view, distance);
|
||||
}
|
||||
if (!wasSampled && row.groundSampled) grounded = true;
|
||||
// An ungrounded base in range and in view: the retry needs a frame
|
||||
// the idle governor would never produce.
|
||||
if (
|
||||
canSample &&
|
||||
!row.groundSampled &&
|
||||
!retryPending &&
|
||||
geo.cartesian3.distance(camera, row.base) <
|
||||
GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
groundInView(row)
|
||||
)
|
||||
retryPending = true;
|
||||
if (refresh || row.baseMoved) sizeStem(row, now, pose, view);
|
||||
if (row.tipMoved) {
|
||||
row.tipMoved = false;
|
||||
tips.set(row.index, { position: row.tip });
|
||||
@@ -521,9 +457,6 @@ export function createInfrastructureDisplay(
|
||||
dirty = false;
|
||||
if (tipsChanged) tips.commit();
|
||||
if (pinsChanged) pins.commit();
|
||||
// Real progress re-opens the budget for the records still waiting.
|
||||
if (grounded) retryArms = 0;
|
||||
if (retryPending) scheduleGroundRetry();
|
||||
publisher.publish(
|
||||
selectLocalInfrastructureOverlayCohort(visible, {
|
||||
maxEntries: labelMax,
|
||||
@@ -575,7 +508,8 @@ export function createInfrastructureDisplay(
|
||||
function hide() {
|
||||
const shown = Boolean(dataset);
|
||||
services.hideOsmCredit?.(engine, id);
|
||||
clearGroundRetry();
|
||||
surface?.destroy();
|
||||
surface = null;
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
stems.clear();
|
||||
stemBatch?.destroy();
|
||||
@@ -640,8 +574,6 @@ export function createInfrastructureDisplay(
|
||||
nextTip: geo.cartesian3.clone(tip),
|
||||
priority,
|
||||
groundHeight: 0,
|
||||
groundSampled: false,
|
||||
lastSample: Number.NEGATIVE_INFINITY,
|
||||
// As a Cesium entity's: shown until the first walk decides; the
|
||||
// context store reads it (isContextRecordActive).
|
||||
show: true,
|
||||
@@ -659,6 +591,19 @@ export function createInfrastructureDisplay(
|
||||
: null,
|
||||
};
|
||||
row.__localBaseCartesian = row.base;
|
||||
row.surface = {
|
||||
cartographic: carto,
|
||||
position: base,
|
||||
get show() {
|
||||
return (
|
||||
(photoreal() || engine.surface.tilesLoaded()) &&
|
||||
(!computed || active.has(row.id)) &&
|
||||
geo.cartesian3.distance(engine.camera.livePosition, row.base) <
|
||||
GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
groundInView(row)
|
||||
);
|
||||
},
|
||||
};
|
||||
rows.set(row.id, row);
|
||||
services.registerEntityContext?.(row, {
|
||||
id: row.pickId,
|
||||
@@ -741,15 +686,14 @@ export function createInfrastructureDisplay(
|
||||
try {
|
||||
dataset = next;
|
||||
build(next);
|
||||
placeGround();
|
||||
removers.push(
|
||||
engine.frames.preRender.addEventListener(walk),
|
||||
engine.camera.moveEnd.addEventListener(() => {
|
||||
surfaceTriggers(engine, () => {
|
||||
dirty = true;
|
||||
lastWalk = Number.NEGATIVE_INFINITY;
|
||||
// A camera move is a fresh situation with free frames.
|
||||
retryArms = 0;
|
||||
wake();
|
||||
}),
|
||||
engine.frames.preRender.addEventListener(walk),
|
||||
engine.input.on('click', onClick, { pick: true }),
|
||||
);
|
||||
publisher.show();
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { LOCAL_OVERLAY_COHORT_LIMIT } from '../../../data/localGeojsonCore.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import {
|
||||
GROUND_SAMPLE_MAX_ARMED_RETRIES,
|
||||
LOCAL_STEM_TIP_EPSILON_M,
|
||||
createInfrastructureDisplay,
|
||||
} from './display.js';
|
||||
@@ -380,15 +379,6 @@ function fixture(t, makeHarness, { config = DAMS, surface = {} } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Each walk arms a timer, the timer asks for a frame, the frame walks. */
|
||||
function runArmedRetryChain(f, t, clock, cycles) {
|
||||
for (let i = 0; i < cycles; i++) {
|
||||
f.preRender();
|
||||
clock.advance(2_100);
|
||||
t.mock.timers.tick(2_100);
|
||||
}
|
||||
}
|
||||
|
||||
/** The camera `southM` south of the dam at `cameraM`, aimed at `groundM`. */
|
||||
function aim(f, { groundM, cameraM, southM, away = false }) {
|
||||
const lat = DAM_LAT - geo.math.toDegrees(southM / 6_371_000);
|
||||
@@ -677,7 +667,8 @@ for (const makeHarness of HARNESSES) {
|
||||
// Looking away from the dam.
|
||||
aim(f, { groundM: 117, cameraM: 20_000, southM: 5_000, away: true });
|
||||
f.display.show(dataset([DAM]));
|
||||
runArmedRetryChain(f, t, clock, 5);
|
||||
clock.advance(10_500);
|
||||
f.preRender();
|
||||
assert.equal(f.sampleCalls(), 0, 'an off-screen base is not sampled');
|
||||
assert.equal(f.retries(), 0, 'nor keeps a parked camera rendering');
|
||||
// In view but over the horizon: 50 m up, about 67 km away.
|
||||
@@ -687,7 +678,8 @@ for (const makeHarness of HARNESSES) {
|
||||
roll: 0,
|
||||
});
|
||||
f.moveEnd();
|
||||
runArmedRetryChain(f, t, clock, 5);
|
||||
clock.advance(10_500);
|
||||
f.preRender();
|
||||
assert.equal(f.sampleCalls(), 0, 'a base over the horizon is not sampled');
|
||||
assert.equal(f.retries(), 0);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
@@ -814,10 +806,10 @@ for (const makeHarness of HARNESSES) {
|
||||
clock.advance(500);
|
||||
f.preRender();
|
||||
assert.ok(
|
||||
Math.abs(f.baseHeight() - 120) < 0.01,
|
||||
'terrain under photoreal tiles is not a floor',
|
||||
Math.abs(f.baseHeight() - 117) < 0.01,
|
||||
'switching surfaces places the base on the mesh',
|
||||
);
|
||||
assert.equal(f.sampleCalls(), 1, 'one sample in all');
|
||||
assert.equal(f.sampleCalls(), 3, 'one sample per changed surface');
|
||||
});
|
||||
|
||||
for (const [terrainHeight, sampledHeight, expectedHeight, photoreal] of [
|
||||
@@ -845,153 +837,65 @@ for (const makeHarness of HARNESSES) {
|
||||
);
|
||||
});
|
||||
|
||||
test(`the stem is out of the scene while its ground is sampled (${name})`, (t) => {
|
||||
let fails = true;
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: {
|
||||
supported: true,
|
||||
sample: () => (fails ? undefined : 50),
|
||||
terrain: () => 0,
|
||||
},
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
f.preRender();
|
||||
fails = false;
|
||||
clock.advance(2_100);
|
||||
f.preRender();
|
||||
assert.deepEqual(f.line(f.stemId('real-dam')).shows, [false, true]);
|
||||
assert.ok(Math.abs(f.baseHeight() - 50) < 0.01);
|
||||
});
|
||||
|
||||
test(`a failed ground sample schedules the retry frame the idle governor would not (${name})`, (t) => {
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: { supported: true, terrain: () => 0 },
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
f.preRender();
|
||||
assert.equal(f.retries(), 0, 'deferred by the retry window');
|
||||
t.mock.timers.tick(2_100);
|
||||
assert.ok(f.requests.includes('local-ground-retry:local-dams'));
|
||||
});
|
||||
|
||||
test(`a far camera arms no retry (${name})`, (t) => {
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: { supported: true, terrain: () => 0 },
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
f.look(DAM_LON, DAM_LAT, 400_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
f.preRender();
|
||||
t.mock.timers.tick(5_000);
|
||||
assert.equal(f.retries(), 0);
|
||||
});
|
||||
|
||||
test(`hide cancels a pending retry (${name})`, (t) => {
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: { supported: true, terrain: () => 0 },
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
f.preRender();
|
||||
f.display.hide();
|
||||
t.mock.timers.tick(5_000);
|
||||
assert.equal(f.retries(), 0);
|
||||
});
|
||||
|
||||
test(`a scene that cannot sample never samples and never arms (${name})`, (t) => {
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: { supported: false, sample: () => 117, terrain: () => 0 },
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const clock = installFakeClock(t);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
f.preRender();
|
||||
clock.advance(2_100);
|
||||
runArmedRetryChain(f, t, clock, 5);
|
||||
assert.equal(f.retries(), 0);
|
||||
assert.equal(f.sampleCalls(), 0);
|
||||
assert.ok(Math.abs(f.baseHeight()) < 1e-6, 'bases stay on the ellipsoid');
|
||||
});
|
||||
|
||||
test(`a sampleable scene keeps trying (${name})`, (t) => {
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: { supported: true, terrain: () => 0 },
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const clock = installFakeClock(t);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
runArmedRetryChain(f, t, clock, 5);
|
||||
assert.equal(f.sampleCalls(), 5);
|
||||
});
|
||||
|
||||
test(`sampling that comes back re-opens a spent budget without camera motion (${name})`, (t) => {
|
||||
let supported = true;
|
||||
const f = fixture(t, makeHarness);
|
||||
const setSurface = () =>
|
||||
f.surface({ supported, terrain: () => 0, tilesLoaded: () => true });
|
||||
setSurface();
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const clock = installFakeClock(t);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
runArmedRetryChain(f, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES + 2);
|
||||
assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES, 'budget spent');
|
||||
supported = false;
|
||||
setSurface();
|
||||
runArmedRetryChain(f, t, clock, 2);
|
||||
assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES);
|
||||
supported = true;
|
||||
setSurface();
|
||||
runArmedRetryChain(f, t, clock, 3);
|
||||
assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES + 3);
|
||||
});
|
||||
|
||||
test(`a scene that keeps failing gives up after a bounded run of arms (${name})`, (t) => {
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: { supported: true, terrain: () => 0 },
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const clock = installFakeClock(t);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
runArmedRetryChain(f, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES);
|
||||
assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES);
|
||||
runArmedRetryChain(f, t, clock, 10);
|
||||
assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES, 'stops arming');
|
||||
});
|
||||
|
||||
test(`after the cap a camera move still samples and re-opens the budget (${name})`, (t) => {
|
||||
let height;
|
||||
test(`the stem is excluded when a changed surface is sampled (${name})`, (t) => {
|
||||
let height = 50;
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: { supported: true, sample: () => height, terrain: () => 0 },
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const clock = installFakeClock(t);
|
||||
installFakeClock(t);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
runArmedRetryChain(f, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES + 3);
|
||||
assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES);
|
||||
assert.ok(Math.abs(f.baseHeight()) < 1e-6);
|
||||
height = 210;
|
||||
f.look(DAM_LON, DAM_LAT, 19_000);
|
||||
f.moveEnd();
|
||||
clock.advance(2_100);
|
||||
f.preRender();
|
||||
assert.ok(Math.abs(f.baseHeight() - 210) < 1e-6, 'the move grounds it');
|
||||
runArmedRetryChain(f, t, clock, 5);
|
||||
height = 70;
|
||||
f.moveEnd();
|
||||
f.preRender();
|
||||
const call = f.engine.fake.callsOf('surface.sampleHeight').at(-1);
|
||||
assert.equal(
|
||||
f.retries(),
|
||||
GROUND_SAMPLE_MAX_ARMED_RETRIES,
|
||||
'a grounded record asks for nothing',
|
||||
call.args[1].length,
|
||||
2,
|
||||
'both the tips and the stem batch are excluded',
|
||||
);
|
||||
assert.ok(Math.abs(f.baseHeight() - 70) < 0.01);
|
||||
});
|
||||
|
||||
for (const trigger of ['moveEnd', 'tilesLoaded', 'terrainRevision']) {
|
||||
test(`infrastructure follows ${trigger} and stops requesting idle frames (${name})`, async (t) => {
|
||||
let height;
|
||||
let loaded = true;
|
||||
const f = fixture(t, makeHarness, {
|
||||
surface: {
|
||||
supported: true,
|
||||
sample: () => height,
|
||||
terrain: () => undefined,
|
||||
tilesLoaded: () => loaded,
|
||||
},
|
||||
});
|
||||
installFakeClock(t);
|
||||
f.look(DAM_LON, DAM_LAT, 20_000);
|
||||
f.display.show(dataset([DAM]));
|
||||
f.preRender();
|
||||
assert.ok(Math.abs(f.baseHeight()) < 0.01);
|
||||
height = 210;
|
||||
if (trigger === 'moveEnd') f.moveEnd();
|
||||
if (trigger === 'tilesLoaded') {
|
||||
loaded = false;
|
||||
f.preRender();
|
||||
loaded = true;
|
||||
}
|
||||
if (trigger === 'terrainRevision')
|
||||
await f.engine.maps.setTerrain({ kind: 'ellipsoid' });
|
||||
f.preRender();
|
||||
assert.ok(Math.abs(f.baseHeight() - 210) < 0.01);
|
||||
const requests = f.requests.length;
|
||||
f.preRender();
|
||||
f.preRender();
|
||||
assert.equal(f.requests.length, requests);
|
||||
f.display.hide();
|
||||
f.moveEnd();
|
||||
f.preRender();
|
||||
assert.equal(f.engine.movers.list().length, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 150 polygons near Austin; the even ones are named (high label priority).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSurface } from '../../globe/surfacePlacement.js';
|
||||
// What Street Level reads of the camera and the ground, on the globe
|
||||
// contract: `view` is the layer's display (render/display.js), whose
|
||||
// `engine` it asks. Screen rays meet an ellipsoid raised to the ground
|
||||
@@ -43,25 +44,12 @@ export function plausibleSurfaceHeight(height) {
|
||||
* underground before tiles load).
|
||||
*/
|
||||
export function sampledSurfaceHeight(engine, carto) {
|
||||
let height = null;
|
||||
try {
|
||||
if (engine?.surface?.supported) height = engine.surface.sampleHeight(carto);
|
||||
} catch {
|
||||
/* not sampleable yet */
|
||||
}
|
||||
return plausibleSurfaceHeight(height) ? height : null;
|
||||
return readSurface(engine, carto, { prefer: 'tiles' }) ?? null;
|
||||
}
|
||||
|
||||
/** Height (m) of the terrain at `carto`, or null. */
|
||||
export function terrainSurfaceHeight(engine, carto) {
|
||||
let height;
|
||||
try {
|
||||
if (engine?.surface?.terrainHeightSupported)
|
||||
height = engine.surface.terrainHeight(carto);
|
||||
} catch {
|
||||
height = undefined;
|
||||
}
|
||||
return Number.isFinite(height) ? height : null;
|
||||
return readSurface(engine, carto, { prefer: 'terrain' }) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSurface } from '../../globe/surfacePlacement.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { requestWithDeadline } from '../../sources/requestDeadline.js';
|
||||
import {
|
||||
@@ -280,14 +281,11 @@ export function createIngestion({
|
||||
let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon);
|
||||
if (!Number.isFinite(estimated) && engine) {
|
||||
try {
|
||||
estimated = !engine.maps.photorealActive()
|
||||
? engine.surface.terrainHeight(location)
|
||||
: engine.surface.supported
|
||||
? engine.surface.sampleHeight(
|
||||
location,
|
||||
layerState._display?.excluded() ?? [],
|
||||
)
|
||||
: undefined;
|
||||
estimated = readSurface(engine, location, {
|
||||
exclude: layerState._display?.excluded() ?? [],
|
||||
prefer: engine.maps.photorealActive() ? 'tiles' : 'terrain',
|
||||
minHeight: -9000,
|
||||
});
|
||||
} catch {
|
||||
/* the local mesh can still be loading */
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSurface } from '../../globe/surfacePlacement.js';
|
||||
import { phaseTiming } from '../../sources/phaseTiming.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { DOT_HEIGHT_OFFSET, MAX_WAYPOINTS_PER_ROAD } from './policy.js';
|
||||
@@ -303,7 +304,11 @@ export async function prepareRoadSurfaces(
|
||||
// locally rendered mesh, not unresolved/offscreen tiles: global
|
||||
// tilesLoaded can stay false while this street is usable.
|
||||
const photoreal = engine.maps.photorealActive();
|
||||
if (!photoreal) sampled = engine.surface.terrainHeight(carto);
|
||||
if (!photoreal)
|
||||
sampled = readSurface(engine, carto, {
|
||||
prefer: 'terrain',
|
||||
minHeight: -9000,
|
||||
});
|
||||
else {
|
||||
const estimate = geo.cartographic.fromCartesian(
|
||||
road.waypoints[i],
|
||||
@@ -311,11 +316,15 @@ export async function prepareRoadSurfaces(
|
||||
sampled = renderedRoadHeight(engine, lon, lat, estimate);
|
||||
if (validHeight(sampled)) metrics.depthHits++;
|
||||
}
|
||||
if (photoreal && !validHeight(sampled) && engine.surface.supported) {
|
||||
if (photoreal && !validHeight(sampled)) {
|
||||
const sampleStart = performance.now();
|
||||
metrics.sampleCount++;
|
||||
try {
|
||||
sampled = engine.surface.sampleHeight(carto, excluded);
|
||||
sampled = readSurface(engine, carto, {
|
||||
exclude: excluded,
|
||||
prefer: 'tiles',
|
||||
minHeight: -9000,
|
||||
});
|
||||
} catch {
|
||||
/* local mesh missing */
|
||||
}
|
||||
|
||||
@@ -3715,32 +3715,30 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
|
||||
},
|
||||
}).methods;
|
||||
// The bikeshare display claims through its engine's camera: the viewer's.
|
||||
const bike = createBikeshareDisplay(
|
||||
{ camera: app.viewer.camera },
|
||||
{
|
||||
state: {
|
||||
_overlayHost: { setVisible: noop },
|
||||
_cityRuntime: new Map(),
|
||||
_failedCityIds: new Set(),
|
||||
},
|
||||
services: {
|
||||
render: { governorRequestRender: noop },
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
},
|
||||
source: {},
|
||||
parts: {
|
||||
display: {
|
||||
_installClickHandler: noop,
|
||||
_uninstallClickHandler: noop,
|
||||
_clearSelection: noop,
|
||||
onCameraChanged: noop,
|
||||
runProximityCheck: noop,
|
||||
deactivateAllCities: noop,
|
||||
},
|
||||
ingestion: { abortAllInFlight: noop },
|
||||
},
|
||||
const bike = createBikeshareDisplay(app.engine, {
|
||||
state: {
|
||||
_stationRenderMap: new Map(),
|
||||
_overlayHost: { setVisible: noop },
|
||||
_cityRuntime: new Map(),
|
||||
_failedCityIds: new Set(),
|
||||
},
|
||||
);
|
||||
services: {
|
||||
render: { governorRequestRender: noop },
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
},
|
||||
source: {},
|
||||
parts: {
|
||||
display: {
|
||||
_installClickHandler: noop,
|
||||
_uninstallClickHandler: noop,
|
||||
_clearSelection: noop,
|
||||
onCameraChanged: noop,
|
||||
runProximityCheck: noop,
|
||||
deactivateAllCities: noop,
|
||||
},
|
||||
ingestion: { abortAllInFlight: noop },
|
||||
},
|
||||
});
|
||||
const layers = { traffic, bikeshare: bike, transit: app.layer };
|
||||
const orders = [
|
||||
['traffic', 'bikeshare', 'transit'],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSurface } from '../../../globe/surfacePlacement.js';
|
||||
// The transit vehicles on the globe contract, the same on every engine:
|
||||
// - markers: two engine.movers sprite sets (the stationary fleet and the
|
||||
// vehicles mid-glide, so a glide never rewrites the fleet), drawn over the
|
||||
@@ -708,9 +709,10 @@ export function createTransitDisplay(
|
||||
// Photoreal tiles hide the globe: the floor comes from the mesh
|
||||
// samples and the coarse floors instead.
|
||||
if (engine.maps.photorealActive()) return undefined;
|
||||
if (!engine.surface.terrainHeightSupported) return undefined;
|
||||
return engine.surface.terrainHeight(
|
||||
return readSurface(
|
||||
engine,
|
||||
geo.cartographic.fromDegrees(lon, lat, 0, scratchCartographic),
|
||||
{ prefer: 'terrain' },
|
||||
);
|
||||
},
|
||||
// The engine's surface leaves out mover sets, not single sprites: a
|
||||
|
||||
@@ -296,7 +296,7 @@ function harness(
|
||||
/** Listeners that run on every drawn frame. */
|
||||
frameWork: () => engine.frames.preRender.numberOfListeners,
|
||||
/** The surface-loading watch (one per enabled scene). */
|
||||
surfaceWatch: () => engine.frames.postRender.numberOfListeners,
|
||||
surfaceWatch: () => engine.frames.preRender.numberOfListeners,
|
||||
/** Surface loading as the engine reports it, then one frame. */
|
||||
tiles(loaded) {
|
||||
tilesLoaded = loaded;
|
||||
@@ -370,16 +370,16 @@ test('terrain warming resamples unchanged point, card and context anchors withou
|
||||
h.moveEnd.emit();
|
||||
assert.equal(
|
||||
samples,
|
||||
before,
|
||||
'unchanged snapshots and camera movement do not resample per point',
|
||||
before + 1,
|
||||
'camera rest resamples the land anchor once',
|
||||
);
|
||||
h.tiles(false);
|
||||
assert.equal(samples, before, 'wait for terrain loading to settle');
|
||||
assert.equal(samples, before + 1, 'wait for terrain loading to settle');
|
||||
h.tiles(true);
|
||||
assert.ok(Math.abs(cartographicOf(point.position).height - 600) < 0.01);
|
||||
assert.equal(
|
||||
samples,
|
||||
before + 1,
|
||||
before + 2,
|
||||
'Acartia sea anchors are never terrain-sampled',
|
||||
);
|
||||
assert.ok(Math.abs(cartographicOf(sea.position).height) < 0.01);
|
||||
@@ -403,7 +403,7 @@ test('terrain warming resamples unchanged point, card and context anchors withou
|
||||
point.position,
|
||||
);
|
||||
assert.ok(h.renders.includes('wildlife-sightings-terrain'));
|
||||
assert.equal(h.frameWork(), 0);
|
||||
assert.equal(h.frameWork(), 1);
|
||||
h.layer.disable();
|
||||
assert.equal(h.surfaceWatch(), 0);
|
||||
const disabledSamples = samples;
|
||||
@@ -864,7 +864,7 @@ test('privacy cells draw on ground, replace changed batches and release them', a
|
||||
await h.update();
|
||||
const first = cells();
|
||||
assert.equal(h.engine.ground.list().length, 1);
|
||||
assert.equal(h.frameWork(), 0, 'static cells need no frame work');
|
||||
assert.equal(h.frameWork(), 1, 'only the shared surface observer');
|
||||
rows = [{ ...rows[0], iconic: 'Fungi' }];
|
||||
await h.update();
|
||||
const second = cells();
|
||||
@@ -1636,7 +1636,7 @@ test('ambient cards prefilter the horizon, spread over screen regions and retain
|
||||
assertCohort(entries);
|
||||
const farDot = h.badges().find((point) => point.id === 'wildlife:inat:far');
|
||||
assert.ok(!farDot || farDot.show === false);
|
||||
assert.equal(h.frameWork(), 0);
|
||||
assert.equal(h.frameWork(), 1);
|
||||
h.layer.selectSighting('inat:01');
|
||||
const selected = h.overlay.entries.get('wildlife-sightings-selected');
|
||||
h.moveEnd.emit();
|
||||
@@ -1680,7 +1680,7 @@ test('observations publish real badges while grid acquisition is still pending',
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(h.badges()[0]?.id, 'wildlife:inat:1');
|
||||
assert.equal(h.layer.getStats().count, 1);
|
||||
assert.equal(h.frameWork(), 0, 'acquisition adds no frame work');
|
||||
assert.equal(h.frameWork(), 1, 'only the shared surface observer');
|
||||
} finally {
|
||||
finishGrid({ cells: [], totalResults: 0, fetchedAt: NOW });
|
||||
await updating;
|
||||
@@ -1733,7 +1733,7 @@ test('each arriving tile extends the density glow before the remaining tiles fin
|
||||
assert.equal(h.badges().length, 0);
|
||||
assert.equal(h.glow().store.count, 2);
|
||||
assert.equal(h.layer.getStats().countLabel, '21 sightings in view');
|
||||
assert.equal(h.frameWork(), 1, 'only the glow follows the camera');
|
||||
assert.equal(h.frameWork(), 2, 'glow and the shared surface observer');
|
||||
} finally {
|
||||
finishGrid({ cells, totalResults: 21, fetchedAt: NOW });
|
||||
await updating;
|
||||
@@ -1767,7 +1767,7 @@ test('grid-only views draw nonpickable glow without observation badges, labels o
|
||||
assert.deepEqual(h.overlay.entries.get('wildlife-sightings-labels'), []);
|
||||
assert.deepEqual(h.overlay.entries.get('wildlife-sightings-selected'), []);
|
||||
assert.equal(h.focus.length, 0);
|
||||
assert.equal(h.frameWork(), 1, 'only the glow follows the camera');
|
||||
assert.equal(h.frameWork(), 2, 'glow and the shared surface observer');
|
||||
h.layer.disable();
|
||||
assert.equal(h.glow(), undefined);
|
||||
assert.equal(h.frameWork(), 0);
|
||||
@@ -2008,7 +2008,7 @@ test('label candidates are bounded after on-screen and horizon filtering, with n
|
||||
(entry) => cartographicOf(entry.position).latitude < toRadians(47.59),
|
||||
),
|
||||
);
|
||||
assert.equal(h.frameWork(), 0);
|
||||
assert.equal(h.frameWork(), 1);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { surfaceTriggers } from '../../globe/surfacePlacement.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { rgbaFromCss } from '../shared/color.js';
|
||||
import { SPRITE_LAYER_ORDER } from '../../data/spriteOrder.js';
|
||||
@@ -260,14 +261,7 @@ export function createRendering({ state, services, parts }) {
|
||||
refreshBadgeImages();
|
||||
// The ground floor cache warms as tiles arrive: resample once the
|
||||
// surface has settled, on the frame after loading ends.
|
||||
let loaded = engine.surface.tilesLoaded();
|
||||
surfaceRemovers.push(
|
||||
engine.frames.postRender.addEventListener(() => {
|
||||
const now = engine.surface.tilesLoaded();
|
||||
if (now && !loaded) resampleAnchors();
|
||||
loaded = now;
|
||||
}),
|
||||
);
|
||||
surfaceRemovers.push(surfaceTriggers(engine, resampleAnchors));
|
||||
// Terrain, tileset and imagery changes all go through the map stack.
|
||||
services.eventTarget?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
|
||||
@@ -187,12 +187,12 @@ test('badge sprites update in place, and unchanged refreshes write nothing', ()
|
||||
assert.equal(h.counts.created, 3);
|
||||
assert.equal(h.counts.removed, 1);
|
||||
assert.equal(h.counts.cleared, 0);
|
||||
assert.equal(h.engine.frames.postRender.numberOfListeners, 1);
|
||||
assert.equal(h.engine.frames.preRender.numberOfListeners, 1);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.badges(), undefined);
|
||||
assert.deepEqual(h.engine.movers.list(), []);
|
||||
assert.equal(h.state.pointById.size, 0);
|
||||
assert.equal(h.engine.frames.postRender.numberOfListeners, 0);
|
||||
assert.equal(h.engine.frames.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('horizon changes write only changed visibility, with one governor request per batch', () => {
|
||||
@@ -349,8 +349,8 @@ test('sampled fallback buckets allocate only representatives at every height, wi
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
assert.equal(
|
||||
h.engine.frames.preRender.numberOfListeners,
|
||||
0,
|
||||
'badges need no frame work',
|
||||
1,
|
||||
'one shared surface observer',
|
||||
);
|
||||
h.rendering.destroy();
|
||||
}
|
||||
@@ -384,8 +384,8 @@ test('UTFGrid creates a dedicated unpickable glow and observations alone create
|
||||
);
|
||||
assert.equal(
|
||||
h.engine.frames.preRender.numberOfListeners,
|
||||
1,
|
||||
'metre-sized glow follows the camera',
|
||||
2,
|
||||
'glow and the shared surface observer follow the camera',
|
||||
);
|
||||
const glowSet = h.rendering.density.collection.set;
|
||||
h.state.gridCells = [...h.state.gridCells].reverse();
|
||||
@@ -396,7 +396,7 @@ test('UTFGrid creates a dedicated unpickable glow and observations alone create
|
||||
h.rendering.clear();
|
||||
assert.equal(glowSet.isDestroyed(), true);
|
||||
assert.equal(h.glow(), undefined);
|
||||
assert.equal(h.engine.frames.preRender.numberOfListeners, 0);
|
||||
assert.equal(h.engine.frames.preRender.numberOfListeners, 1);
|
||||
assert.equal(h.badges().store.count, 0);
|
||||
h.rendering.destroy();
|
||||
assert.deepEqual(h.engine.movers.list(), []);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSurface } from '../globe/surfacePlacement.js';
|
||||
/**
|
||||
* groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
|
||||
*
|
||||
@@ -47,7 +48,6 @@
|
||||
*/
|
||||
|
||||
import * as geo from '../geo/index.js';
|
||||
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
|
||||
import { MAX_FLOOR_TILE_SIZE_M } from './meshFloorSampler.js';
|
||||
|
||||
/** Taxi threshold: a cached snap answers directly for moves up to this far from
|
||||
@@ -352,10 +352,11 @@ export function createGroundSnap({ groundFloor }) {
|
||||
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
|
||||
const exclusions = getExclusions ? getExclusions() : undefined;
|
||||
// A coarse tile's answer, or only terrain, is a miss.
|
||||
sampled = acceptedSurfaceHeight(
|
||||
engine.surface.sampleHeightDetailed(carto, { exclude: exclusions }),
|
||||
{ maxTileSizeM: MAX_FLOOR_TILE_SIZE_M },
|
||||
);
|
||||
sampled = readSurface(engine, carto, {
|
||||
exclude: exclusions,
|
||||
prefer: 'tiles',
|
||||
maxTileSizeM: MAX_FLOOR_TILE_SIZE_M,
|
||||
});
|
||||
} catch {
|
||||
sampled = undefined;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readSurface } from '../globe/surfacePlacement.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
|
||||
|
||||
/** Answers from tiles larger than this are too coarse to latch (m).
|
||||
* Measured on Navara: from 800 m the answering tiles were 112-314 m and
|
||||
@@ -158,12 +158,11 @@ export function createMeshFloorSampler({
|
||||
_scratchEngineProbe,
|
||||
);
|
||||
// A coarse tile's answer, or only terrain, is a miss (no latch).
|
||||
height = acceptedSurfaceHeight(
|
||||
engine.surface.sampleHeightDetailed(carto, {
|
||||
exclude: excludeObjects,
|
||||
}),
|
||||
{ maxTileSizeM: MAX_FLOOR_TILE_SIZE_M },
|
||||
);
|
||||
height = readSurface(engine, carto, {
|
||||
exclude: excludeObjects,
|
||||
prefer: 'tiles',
|
||||
maxTileSizeM: MAX_FLOOR_TILE_SIZE_M,
|
||||
});
|
||||
sampled += 1;
|
||||
} catch {
|
||||
continue; // scene mid-teardown — try again next poll
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSurface } from '../globe/surfacePlacement.js';
|
||||
// Camera and aircraft ownership used by the shared first-person controller,
|
||||
// on the globe contract: the ridden aircraft is the engine follow's target,
|
||||
// the aircraft display's tracked glyph handle.
|
||||
@@ -26,7 +27,7 @@ export function cockpitHost(engine, { viewer = null } = {}) {
|
||||
restore: (target) => target.resumeAircraftFollow(),
|
||||
camera: engine.camera,
|
||||
cartographic: () => engine.camera.pose().cartographic,
|
||||
terrainHeight: (place) => engine.surface.terrainHeight(place),
|
||||
terrainHeight: (place) => readSurface(engine, place, { prefer: 'terrain' }),
|
||||
surface: engine,
|
||||
pick: (position) => engine.picker.pick(position),
|
||||
// A full mesh read is still Cesium's; the contract's detailed read is
|
||||
|
||||
@@ -469,7 +469,6 @@ export function createGeoFileImport({
|
||||
engine,
|
||||
controller: new AbortController(),
|
||||
});
|
||||
ownEvent(engine.camera.moveEnd, ground);
|
||||
const container = engine.host.container;
|
||||
const canvas = engine.host.canvas || container;
|
||||
listen(canvas, 'pointerdown', gesture, { passive: true });
|
||||
|
||||
@@ -755,7 +755,7 @@ test('what no engine draws of a file is reported, and the drawing follows the en
|
||||
assert.match(t.shown().hint, /leaves out of a\.kml: 3/);
|
||||
assert.ok(engine.host.canvas.listeners.length > 0, 'gestures on its canvas');
|
||||
assert.ok(engine.host.container.listeners.length > 0, 'drops on it');
|
||||
assert.equal(engine.moveEnd.size, 1);
|
||||
assert.equal(engine.moveEnd.size, 0);
|
||||
const [a] = rows(t);
|
||||
press(t, a, 'toggle');
|
||||
assert.equal(t.added.at(-1).show, false);
|
||||
|
||||
Reference in new issue
Block a user