Merge branch 'german/refactor-e7-surface-placement' into german/navara-refactor

# Conflicts:
#	scripts/package-boundaries.json
This commit is contained in:
German Khokhlov committed 2026-11-10 16:15:09 +04:00
commit bcff542b16
39 files changed
+925 -691

No files matched your search

+7 -4
View File
@@ -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,
]);
+6 -3
View File
@@ -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;
+60 -98
View File
@@ -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;
+18 -19
View File
@@ -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();
},
-4
View File
@@ -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;
+2
View File
@@ -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',
@@ -7,7 +7,8 @@
// tiles have loaded; a point whose height holds is not written again, so a
// parked camera asks for no frames.
import * as geo from '../../../geo/index.js';
import * as geo from '../geo/index.js';
import { acceptedSurfaceHeight } from './surfaceDetail.js';
const SAMPLES_PER_PASS = 16;
const SAMPLE_BUDGET_MS = 4;
@@ -21,8 +22,88 @@ const SCREEN_MARGIN = 0.25;
const plausible = (height) =>
Number.isFinite(height) && height >= -500 && height <= 9000;
/** Read the visible surface, or an explicitly requested terrain/mesh floor.
* `scene` retains callers that include roofs even on a terrain map; `tiles`
* requires a mesh hit. `sceneFloor` keeps the terrain below a scene hit.
* A finite tile-size limit also rejects coarse mesh LOD. `minHeight` preserves
* consumers that intentionally admit coarse below-sea-level terrain.
*/
export function readSurface(
engine,
cartographic,
{ exclude = [], prefer, maxTileSizeM = Infinity, minHeight = -500 } = {},
) {
const valid = (height) =>
Number.isFinite(height) && height >= minHeight && height <= 9000;
const surface = engine?.surface;
if (!surface || !cartographic) return undefined;
const tiles = engine.maps?.photorealActive?.() === true;
if (
prefer !== 'terrain' &&
(tiles ||
prefer === 'scene' ||
prefer === 'sceneFloor' ||
prefer === 'tiles')
) {
let height;
try {
if (Number.isFinite(maxTileSizeM))
height = acceptedSurfaceHeight(
surface.sampleHeightDetailed(cartographic, { exclude }),
{ maxTileSizeM },
);
else if (surface.supported !== false)
height = surface.sampleHeight(cartographic, exclude);
} catch {
// A streaming or retiring surface may not answer yet.
}
if (valid(height)) {
if (prefer === 'sceneFloor' && !tiles) {
const terrain = readSurface(engine, cartographic, {
prefer: 'terrain',
});
return terrain === undefined ? height : Math.max(height, terrain);
}
return height;
}
if (prefer === 'tiles') return undefined;
}
try {
const height =
surface.terrainHeightSupported !== false
? surface.terrainHeight(cartographic)
: undefined;
return valid(height) ? height : undefined;
} catch {
return undefined;
}
}
/** Subscribe once to camera rest and changes to the rendered ground. */
export function surfaceTriggers(engine, onChange) {
let loaded = engine.surface.tilesLoaded();
let tiles = engine.maps?.photorealActive?.() === true;
let terrain = engine.maps?.terrainRevision?.();
const removeMove = engine.camera.moveEnd.addEventListener(onChange);
const removeFrame = engine.frames.preRender.addEventListener(() => {
const nextLoaded = engine.surface.tilesLoaded();
const nextTiles = engine.maps?.photorealActive?.() === true;
const nextTerrain = engine.maps?.terrainRevision?.();
const changed =
(nextLoaded && !loaded) || nextTiles !== tiles || nextTerrain !== terrain;
loaded = nextLoaded;
tiles = nextTiles;
terrain = nextTerrain;
if (changed) onChange();
});
return () => {
removeMove();
removeFrame();
};
}
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {import('./contract.js').GlobeEngine} engine
* @param {object} options
* @param {() => Iterable<{ set: object | null, points: object[], exclude?: object[] }>} options.groups
* Mover sets with the points they draw, point i as instance i, and what
@@ -31,27 +112,35 @@ const plausible = (height) =>
* placement replaces (keeping the height in `surfaceHeight`); one with
* `show === false` is skipped.
* @param {() => void} options.request Asks for a frame after a placement.
* @param {number} [options.heightOffset=0] Metres above the surface.
* @param {number | ((point: object) => number)} [options.heightOffset=0] Metres above the surface.
* @param {boolean} [options.estimateGround=false] Screen-test a point not
* measured yet on the loaded terrain (under 3D tiles without it, at the
* ground under the camera) rather than where it stands now.
* @returns {{ place: () => void, destroy: () => void }} `place()` measures
* the points not yet placed on this surface (new points, for example).
* @param {number} [options.screenMargin=0.25] Extra viewport fraction to measure.
* @param {string | ((point: object) => string | undefined)} [options.prefer] Explicit height policy for readSurface.
* @returns {{ place: (options?: { all?: boolean }) => void, destroy: () => void }}
* `place()` measures new points; `place({ all: true })` revisits every point.
*/
export function createSurfacePoints(
engine,
{ groups, request, heightOffset = 0, estimateGround = false },
{
groups,
request,
heightOffset = 0,
estimateGround = false,
screenMargin = SCREEN_MARGIN,
prefer,
},
) {
let revision = 0;
let timer = null;
let destroyed = false;
let tilesLoaded = engine.surface.tilesLoaded();
let photoreal = engine.maps?.photorealActive?.() === true;
function onScreen(position, size) {
if (screenMargin === Infinity) return true;
const screen = engine.projector.worldToWindow(position);
const x = size.width * SCREEN_MARGIN;
const y = size.height * SCREEN_MARGIN;
const x = size.width * screenMargin;
const y = size.height * screenMargin;
return (
screen &&
screen.x >= -x &&
@@ -72,7 +161,8 @@ export function createSurfacePoints(
}
// Points off screen are passed over until the next move or tile load.
function place() {
function place({ all = false } = {}) {
if (all) revision++;
clearTimeout(timer);
timer = null;
if (destroyed) return;
@@ -80,6 +170,7 @@ export function createSurfacePoints(
const tiles = engine.maps?.photorealActive?.() === true;
const started = performance.now();
let samples = 0;
let visited = 0;
let moved = false;
let cameraGround;
for (const { set, points, exclude = [set] } of groups()) {
@@ -90,18 +181,23 @@ export function createSurfacePoints(
if (point.surfaceRevision === revision) continue;
if (
samples >= SAMPLES_PER_PASS ||
performance.now() - started >= SAMPLE_BUDGET_MS
(visited > 0 && performance.now() - started >= SAMPLE_BUDGET_MS)
) {
if (changed) set.commit();
if (changed || moved) request();
timer = setTimeout(place, 0);
return;
}
visited++;
point.surfaceRevision = revision;
if (point.show === false) continue;
const offset =
typeof heightOffset === 'function'
? heightOffset(point)
: heightOffset;
const estimate = estimateGround && point.surfaceHeight === undefined;
const terrain = estimate
? engine.surface.terrainHeight(point.cartographic)
? readSurface(engine, point.cartographic, { prefer: 'terrain' })
: undefined;
let ground = terrain;
if (estimate && tiles && !plausible(ground)) {
@@ -115,23 +211,22 @@ export function createSurfacePoints(
? geo.cartesian3.fromRadians(
point.cartographic.longitude,
point.cartographic.latitude,
ground + heightOffset,
ground + offset,
)
: point.position;
if (!onScreen(position, size)) continue;
samples++;
let height = tiles
? engine.surface.sampleHeight(point.cartographic, exclude)
: undefined;
if (!plausible(height))
height = terrain ?? engine.surface.terrainHeight(point.cartographic);
const height = readSurface(engine, point.cartographic, {
exclude,
prefer: typeof prefer === 'function' ? prefer(point) : prefer,
});
if (!plausible(height)) continue;
if (Math.abs(height - point.surfaceHeight) < HEIGHT_EPSILON_M) continue;
point.surfaceHeight = height;
point.position = geo.cartesian3.fromRadians(
point.cartographic.longitude,
point.cartographic.latitude,
height + heightOffset,
height + offset,
);
set.set(index, { position: point.position });
changed = true;
@@ -142,18 +237,7 @@ export function createSurfacePoints(
if (moved) request();
}
function refine() {
revision++;
place();
}
const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine);
const removeFrame = engine.frames.preRender.addEventListener(() => {
const loaded = engine.surface.tilesLoaded();
const tiles = engine.maps?.photorealActive?.() === true;
if ((loaded && !tilesLoaded) || tiles !== photoreal) refine();
tilesLoaded = loaded;
photoreal = tiles;
});
const removeSurface = surfaceTriggers(engine, () => place({ all: true }));
return {
place,
@@ -161,8 +245,7 @@ export function createSurfacePoints(
if (destroyed) return;
destroyed = true;
clearTimeout(timer);
removeMoveEnd();
removeFrame();
removeSurface();
},
};
}
+249
View File
@@ -0,0 +1,249 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../geo/index.js';
import { createFakeEngine } from './testing/fakeEngine.js';
import {
createSurfacePoints,
readSurface,
surfaceTriggers,
} from './surfacePlacement.js';
function setup(t, count = 3, options = {}) {
t.mock.method(performance, 'now', () => 0);
const engine = createFakeEngine();
t.after(() => engine.destroy());
engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
const set = engine.movers.createSet({
id: 'points',
kind: 'point',
capacity: count,
point: { sizePx: 6 },
});
const points = Array.from({ length: count }, (_, index) => ({
cartographic: geo.cartographic.fromDegrees(index, 0),
position: geo.cartesian3.fromDegrees(index, 0),
}));
points.forEach((point, index) =>
set.set(index, { position: point.position, show: true }),
);
set.setCount(count);
set.commit();
const surface = createSurfacePoints(engine, {
groups: () => [{ set, points }],
request: () => engine.render.request('points'),
...options,
});
t.after(() => surface.destroy());
const heights = () =>
points.map(
(point) => geo.cartographic.fromCartesian(point.position).height,
);
return { engine, surface, heights };
}
test('on the globe points stand on the terrain without a pick sample', (t) => {
const { engine, surface, heights } = setup(t);
let picks = 0;
engine.fake.script('sampleHeight', () => {
picks++;
return 900;
});
engine.fake.script('terrainHeight', () => 40);
surface.place();
assert.equal(picks, 0);
for (const height of heights()) assert.ok(Math.abs(height - 40) < 0.01);
});
test('under 3D tiles points stand on the sampled tiles', async (t) => {
const { engine, surface, heights } = setup(t);
await engine.maps.setPhotorealTiles({ id: 'photoreal' });
engine.fake.script('sampleHeight', () => 55);
engine.fake.script('terrainHeight', () => 40);
surface.place();
for (const height of heights()) assert.ok(Math.abs(height - 55) < 0.01);
});
test('a parked camera asks for no frames once the points stand', (t) => {
const { engine, heights } = setup(t);
let terrain = 40;
engine.fake.script('terrainHeight', () => terrain);
engine.fake.script('tilesLoaded', () => true);
engine.camera.moveEnd.raiseEvent();
assert.ok(Math.abs(heights()[0] - 40) < 0.01);
const before = engine.fake.renderRequests;
engine.camera.moveEnd.raiseEvent();
engine.fake.frames(3);
assert.equal(engine.fake.renderRequests, before, 'same heights, no frame');
terrain = 46;
engine.camera.moveEnd.raiseEvent();
assert.ok(engine.fake.renderRequests > before, 'a changed height draws');
assert.ok(Math.abs(heights()[0] - 46) < 0.01);
});
for (const trigger of [
'moveEnd',
'tilesLoaded',
'photorealActive',
'terrainRevision',
]) {
for (const source of ['terrain', 'tiles', 'missing', 'implausible']) {
test(`${trigger} refreshes points on ${source}`, async (t) => {
const { engine, surface, heights } = setup(t);
let terrain = 40;
let tiles = 50;
let loaded = true;
engine.fake.script('terrainHeight', () => terrain);
engine.fake.script('sampleHeight', () =>
source === 'missing'
? undefined
: source === 'implausible'
? 9001
: tiles,
);
engine.fake.script('tilesLoaded', () => loaded);
if (source !== 'terrain') await engine.maps.setPhotorealTiles({});
engine.fake.frames(1);
surface.place();
assert.ok(Math.abs(heights()[0] - (source === 'tiles' ? 50 : 40)) < 0.01);
if (trigger === 'photorealActive') {
await engine.maps.setPhotorealTiles(source === 'terrain' ? {} : null);
engine.fake.frames(1);
}
terrain = 70;
tiles = 80;
if (trigger === 'moveEnd') engine.camera.moveEnd.raiseEvent();
if (trigger === 'terrainRevision') {
await engine.maps.setTerrain({ kind: 'ellipsoid' });
engine.fake.frames(1);
}
if (trigger === 'photorealActive') {
await engine.maps.setPhotorealTiles(source === 'terrain' ? null : {});
engine.fake.frames(1);
}
if (trigger === 'tilesLoaded') {
loaded = false;
engine.fake.frames(1);
loaded = true;
engine.fake.frames(1);
}
assert.ok(Math.abs(heights()[0] - (source === 'tiles' ? 80 : 70)) < 0.01);
const requests = engine.fake.renderRequests;
engine.fake.frames(3);
assert.equal(engine.fake.renderRequests, requests);
surface.destroy();
terrain = 90;
engine.camera.moveEnd.raiseEvent();
engine.fake.frames(1);
assert.equal(engine.fake.renderRequests, requests);
});
}
}
test('forced placement revisits measured points and invalid heights retain the last position', (t) => {
const { engine, surface, heights } = setup(t);
engine.fake.script('terrainHeight', 80);
surface.place();
engine.fake.script('terrainHeight', 100);
surface.place();
assert.ok(Math.abs(heights()[0] - 80) < 0.01);
surface.place({ all: true });
assert.ok(Math.abs(heights()[0] - 100) < 0.01);
for (const height of [-501, 9001, NaN, Infinity, undefined]) {
engine.fake.script('terrainHeight', height);
surface.place({ all: true });
assert.ok(Math.abs(heights()[0] - 100) < 0.01);
}
});
test('surface readers retain explicit terrain and fine-mesh policies', async (t) => {
const engine = createFakeEngine();
t.after(() => engine.destroy());
const point = geo.cartographic.fromDegrees(0, 0);
engine.fake.script('terrainHeight', -500);
engine.fake.script('sampleHeight', 9000);
assert.equal(readSurface(engine, point), -500);
await engine.maps.setPhotorealTiles({});
assert.equal(readSurface(engine, point), 9000);
assert.equal(readSurface(engine, point, { prefer: 'terrain' }), -500);
engine.fake.script('sampleHeightDetailed', {
height: 50,
source: 'tiles',
tileSizeM: 200,
});
assert.equal(
readSurface(engine, point, { prefer: 'tiles', maxTileSizeM: 100 }),
undefined,
);
engine.fake.script('sampleHeightDetailed', { height: 50, source: 'terrain' });
assert.equal(
readSurface(engine, point, { prefer: 'tiles', maxTileSizeM: 100 }),
undefined,
);
});
test('surface subscriptions coalesce frame changes and release both listeners', async (t) => {
const engine = createFakeEngine();
t.after(() => engine.destroy());
let changes = 0;
const remove = surfaceTriggers(engine, () => changes++);
await engine.maps.setTerrain({ kind: 'ellipsoid' });
await engine.maps.setPhotorealTiles({});
engine.fake.frames(1);
assert.equal(changes, 1);
remove();
engine.camera.moveEnd.raiseEvent();
await engine.maps.setTerrain({ kind: 'ellipsoid' });
engine.fake.frames(1);
assert.equal(changes, 1);
});
test('initial placement projects high ground before testing the screen and retains its offset', (t) => {
const { engine, surface, heights } = setup(t, 1, {
estimateGround: true,
heightOffset: 2,
});
engine.fake.script('terrainHeight', 2500);
engine.projector.worldToWindow = (position) =>
geo.cartographic.fromCartesian(position).height > 2400
? { x: 100, y: 100 }
: undefined;
surface.place();
assert.ok(Math.abs(heights()[0] - 2502) < 0.01);
});
test('a consumer with its own visibility gate can place a ground vertical whose base does not project', (t) => {
const { engine, surface, heights } = setup(t, 1, { screenMargin: Infinity });
engine.fake.script('terrainHeight', 2500);
engine.projector.worldToWindow = () => undefined;
surface.place();
assert.ok(Math.abs(heights()[0] - 2500) < 0.01);
});
test('placement makes progress when preparing a group exhausts the time budget', (t) => {
let now = 0;
const engine = createFakeEngine();
t.after(() => engine.destroy());
t.mock.method(performance, 'now', () => now);
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
engine.fake.script('terrainHeight', 50);
const set = engine.movers.createSet({
id: 'slow-group',
kind: 'point',
capacity: 1,
point: { sizePx: 6 },
});
const point = {
cartographic: geo.cartographic.fromDegrees(0, 0),
position: geo.cartesian3.fromDegrees(0, 0),
};
const surface = createSurfacePoints(engine, {
groups() {
now += 5;
return [{ set, points: [point] }];
},
request() {},
});
t.after(() => surface.destroy());
surface.place();
assert.equal(point.surfaceHeight, 50);
});
+49 -14
View File
@@ -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?.();
+6
View File
@@ -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);
+30 -12
View File
@@ -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);
+15 -3
View File
@@ -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();
+45 -11
View File
@@ -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),
+34 -29
View File
@@ -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');
});
+1 -1
View File
@@ -14,7 +14,7 @@ import * as geo from '../../../geo/index.js';
import { normalizeGroundItem } from '../../../globe/ground.js';
import { rgbaFromCss } from '../../shared/color.js';
import { imageAtlasLayout } from '../../shared/render/imageAtlas.js';
import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
const LAYER_ID = 's2-ghostmaps';
const TRANSPARENT = Object.freeze([0, 0, 0, 0]);
+65 -121
View File
@@ -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();
+57 -153
View File
@@ -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 -1
View File
@@ -6,7 +6,7 @@
import * as geo from '../../../geo/index.js';
import { rgbaFromCss } from '../../shared/color.js';
import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from '../model.js';
// Above the sampled surface, so a point does not sink into it.
@@ -1,75 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createSurfacePoints } from './surfacePoints.js';
function setup(t, count = 3) {
const engine = createFakeEngine();
t.after(() => engine.destroy());
engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
const set = engine.movers.createSet({
id: 'points',
kind: 'point',
capacity: count,
point: { sizePx: 6 },
});
const points = Array.from({ length: count }, (_, index) => ({
cartographic: geo.cartographic.fromDegrees(index, 0),
position: geo.cartesian3.fromDegrees(index, 0),
}));
points.forEach((point, index) =>
set.set(index, { position: point.position, show: true }),
);
set.setCount(count);
set.commit();
const surface = createSurfacePoints(engine, {
groups: () => [{ set, points }],
request: () => engine.render.request('points'),
});
t.after(() => surface.destroy());
const heights = () =>
points.map(
(point) => geo.cartographic.fromCartesian(point.position).height,
);
return { engine, surface, heights };
}
test('on the globe points stand on the terrain without a pick sample', (t) => {
const { engine, surface, heights } = setup(t);
let picks = 0;
engine.fake.script('sampleHeight', () => {
picks++;
return 900;
});
engine.fake.script('terrainHeight', () => 40);
surface.place();
assert.equal(picks, 0);
for (const height of heights()) assert.ok(Math.abs(height - 40) < 0.01);
});
test('under 3D tiles points stand on the sampled tiles', async (t) => {
const { engine, surface, heights } = setup(t);
await engine.maps.setPhotorealTiles({ id: 'photoreal' });
engine.fake.script('sampleHeight', () => 55);
engine.fake.script('terrainHeight', () => 40);
surface.place();
for (const height of heights()) assert.ok(Math.abs(height - 55) < 0.01);
});
test('a parked camera asks for no frames once the points stand', (t) => {
const { engine, heights } = setup(t);
let terrain = 40;
engine.fake.script('terrainHeight', () => terrain);
engine.fake.script('tilesLoaded', () => true);
engine.camera.moveEnd.raiseEvent();
assert.ok(Math.abs(heights()[0] - 40) < 0.01);
const before = engine.fake.renderRequests;
engine.camera.moveEnd.raiseEvent();
engine.fake.frames(3);
assert.equal(engine.fake.renderRequests, before, 'same heights, no frame');
terrain = 46;
engine.camera.moveEnd.raiseEvent();
assert.ok(engine.fake.renderRequests > before, 'a changed height draws');
assert.ok(Math.abs(heights()[0] - 46) < 0.01);
});
+8 -3
View File
@@ -15,11 +15,14 @@
// The contract has no ground clamping for sprites: a sprite sits on the
// terrain, or under Google 3D on the rendered tiles, measured on screen a
// few per pass and again after moves and tile loads
// (../../shared/render/surfacePoints.js).
// (../../../globe/surfacePlacement.js).
import * as geo from '../../../geo/index.js';
import { rgbaFromCss } from '../../shared/color.js';
import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
import {
createSurfacePoints,
readSurface,
} from '../../../globe/surfacePlacement.js';
/** Lines per ground set: smaller sets build, and show, sooner. */
export const SEQUENCE_LINE_BATCH = 120;
@@ -127,7 +130,9 @@ export function createStreetLevelDisplay(engine) {
// its measure, and a camera move can bring it into view first.
function locate(row) {
row.cartographic = geo.cartographic.fromDegrees(row.lon, row.lat);
const terrain = engine.surface.terrainHeight(row.cartographic);
const terrain = readSurface(engine, row.cartographic, {
prefer: 'terrain',
});
row.position = geo.cartesian3.fromDegrees(
row.lon,
row.lat,
+3 -15
View File
@@ -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 -1
View File
@@ -14,7 +14,7 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
import * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js';
import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
import {
featureLabel,
featureReference,
+6 -8
View File
@@ -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 */
}
+12 -3
View File
@@ -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 */
}
+23 -25
View File
@@ -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'],
+4 -2
View File
@@ -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
+12 -12
View File
@@ -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();
});
+2 -8
View File
@@ -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(), []);
+6 -5
View File
@@ -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;
}
+6 -7
View File
@@ -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
+2 -1
View File
@@ -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
-1
View File
@@ -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 });
+1 -1
View File
@@ -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);