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

+1 -1
View File
@@ -2,7 +2,7 @@
"metrics": { "metrics": {
"attachRendererWithViewer": 33, "attachRendererWithViewer": 33,
"restoreFlagCopies": 9, "restoreFlagCopies": 9,
"surfaceCallsOutsideGlobe": 41, "surfaceCallsOutsideGlobe": 10,
"engineSwitchLayerIds": 3, "engineSwitchLayerIds": 3,
"cesiumLayerRendererLines": 0, "cesiumLayerRendererLines": 0,
"navaraLayerRendererLines": 0, "navaraLayerRendererLines": 0,
+18 -8
View File
@@ -34,7 +34,9 @@
"src/layers/infrastructure/render/display.js", "src/layers/infrastructure/render/display.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/sources/requestDeadline.js", "src/sources/requestDeadline.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js",
"src/globe/surfaceDetail.js",
"src/globe/surfacePlacement.js"
], ],
"external": [] "external": []
}, },
@@ -851,7 +853,9 @@
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/geo/", "src/geo/",
"src/globe/cesium/doubleClick.js", "src/globe/cesium/doubleClick.js",
"src/globe/cesium/orbital.js" "src/globe/cesium/orbital.js",
"src/globe/surfaceDetail.js",
"src/globe/surfacePlacement.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1644,7 +1648,9 @@
"src/sources/phaseTiming.js", "src/sources/phaseTiming.js",
"src/layers/traffic/roadModes.js", "src/layers/traffic/roadModes.js",
"src/geo/", "src/geo/",
"src/layers/traffic/render/display.js" "src/layers/traffic/render/display.js",
"src/globe/surfaceDetail.js",
"src/globe/surfacePlacement.js"
], ],
"external": ["pbf", "@mapbox/vector-tile"] "external": ["pbf", "@mapbox/vector-tile"]
}, },
@@ -1775,7 +1781,6 @@
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/globe/", "src/globe/",
"src/layers/streetLevel/render/display.js", "src/layers/streetLevel/render/display.js",
"src/layers/shared/render/surfacePoints.js",
"src/time/worldClock.js", "src/time/worldClock.js",
"src/time/clock.js" "src/time/clock.js"
], ],
@@ -1854,8 +1859,9 @@
"src/sources/requestDeadline.js", "src/sources/requestDeadline.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/submarineCables/render/display.js", "src/layers/submarineCables/render/display.js",
"src/layers/shared/render/surfacePoints.js", "src/overlays/bindOverlayHost.js",
"src/overlays/bindOverlayHost.js" "src/globe/surfaceDetail.js",
"src/globe/surfacePlacement.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3546,7 +3552,9 @@
"src/geo/", "src/geo/",
"src/layers/directions/render/display.js", "src/layers/directions/render/display.js",
"src/layers/directions/render/style.js", "src/layers/directions/render/style.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js",
"src/globe/surfaceDetail.js",
"src/globe/surfacePlacement.js"
], ],
"external": [] "external": []
}, },
@@ -4353,7 +4361,9 @@
"src/layers/wildlifeSightings/display.js", "src/layers/wildlifeSightings/display.js",
"src/data/spriteOrder.js", "src/data/spriteOrder.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js",
"src/globe/surfaceDetail.js",
"src/globe/surfacePlacement.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
+7 -4
View File
@@ -14,6 +14,7 @@
* lifts the eye if it sits below a usable clearance. Heading and pitch are * lifts the eye if it sits below a usable clearance. Heading and pitch are
* held, so the correction reads as the shot settling. * held, so the correction reads as the shot settling.
*/ */
import { readSurface } from './globe/surfacePlacement.js';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { ownCameraArrival } from './data/cameraArrival.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. * Highest rendered surface across a few cells, with how many cells answered.
* A cell whose tiles are not streamed yet contributes nothing. * 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. * @param {Array<{lat: number, lon: number}>} cells Cells to probe.
* @returns {{heightM: number, sampled: number}} Reading. * @returns {{heightM: number, sampled: number}} Reading.
*/ */
function sampleSurfaceM(surface, cells) { function sampleSurfaceM(engine, cells) {
let heightM = Number.NaN; let heightM = Number.NaN;
let sampled = 0; let sampled = 0;
for (const cell of cells) { for (const cell of cells) {
try { try {
const height = surface.sampleHeight( const height = readSurface(
engine,
geo.cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch), geo.cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
{ prefer: 'scene' },
); );
if (!Number.isFinite(height)) continue; if (!Number.isFinite(height)) continue;
sampled += 1; sampled += 1;
@@ -135,7 +138,7 @@ export function guardCameraAboveGround(engine, at, options = {}) {
lat: geo.math.toDegrees(carto.latitude), lat: geo.math.toDegrees(carto.latitude),
lon: geo.math.toDegrees(carto.longitude), lon: geo.math.toDegrees(carto.longitude),
}; };
const { heightM, sampled } = sampleSurfaceM(engine.surface, [ const { heightM, sampled } = sampleSurfaceM(engine, [
{ lat: at.lat, lon: at.lon }, { lat: at.lat, lon: at.lon },
below, below,
]); ]);
+6 -3
View File
@@ -1,3 +1,4 @@
import { readSurface } from '../globe/surfacePlacement.js';
import { bindOverlayHost } from '../overlays/bindOverlayHost.js'; import { bindOverlayHost } from '../overlays/bindOverlayHost.js';
import { worldOverlayFor } from '../overlays/worldOverlay.js'; import { worldOverlayFor } from '../overlays/worldOverlay.js';
import { waitForSignal } from '../sources/requestDeadline.js'; import { waitForSignal } from '../sources/requestDeadline.js';
@@ -1250,7 +1251,9 @@ export function createBhoteKoshiEventLayer({
return anchor; return anchor;
const { lon, lat } = _evidenceTimeline[index]?.observation ?? {}; const { lon, lat } = _evidenceTimeline[index]?.observation ?? {};
const height = Number.isFinite(lon) const height = Number.isFinite(lon)
? _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon, lat)) ? readSurface(_engine, Cesium.Cartographic.fromDegrees(lon, lat), {
prefer: 'terrain',
})
: undefined; : undefined;
if (Number.isFinite(height)) if (Number.isFinite(height))
Cesium.Cartesian3.fromDegrees(lon, lat, height + 18, undefined, anchor); 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. // Sample once while preparing the shot, never from an animation frame.
try { try {
height = height =
_engine?.surface?.sampleHeight?.(position) ?? readSurface(_engine, position, { prefer: 'scene' }) ??
_viewer?.scene?.sampleHeight?.( _viewer?.scene?.sampleHeight?.(
position, position,
[_floodCore, _floodHalo, _surgePoint].filter(Boolean), [_floodCore, _floodHalo, _surgePoint].filter(Boolean),
@@ -1634,7 +1637,7 @@ export function createBhoteKoshiEventLayer({
} catch {} } catch {}
if (!Number.isFinite(height)) if (!Number.isFinite(height))
height = height =
_engine?.surface?.terrainHeight(position) ?? readSurface(_engine, position, { prefer: 'terrain' }) ??
_viewer?.scene?.globe?.getHeight?.(position); _viewer?.scene?.globe?.getHeight?.(position);
} }
if (!Number.isFinite(height)) height = point.fallbackElevationM; 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, // - 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 // 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. // a ground fill or a mesh at an absolute altitude.
// Nothing is pickable: an imported file has no card. Ground heights under // Nothing is pickable: an imported file has no card. Ground pins share
// the points come from engine.surface, sampled again by refreshGround(): // the surface placer and exclude every imported pin set from its samples.
// 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).
import { createSurfacePoints } from '../globe/surfacePlacement.js';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { planImport } from './geoImportPlan.js'; import { planImport } from './geoImportPlan.js';
import { import {
BATCH,
HORIZON_LIFT_M, HORIZON_LIFT_M,
CLAMP_TILES_BELOW_M,
MAX_ICON_PIXEL_RATIO, MAX_ICON_PIXEL_RATIO,
POLYGON_YIELD_MS, POLYGON_YIELD_MS,
} from './geoImportLimits.js'; } from './geoImportLimits.js';
@@ -112,6 +108,7 @@ export async function drawImportedFile(
let shown = visible; let shown = visible;
let destroyed = false; let destroyed = false;
let removeCull = null; let removeCull = null;
let surface = null;
const groundSets = []; const groundSets = [];
const lines = []; const lines = [];
const meshes = []; const meshes = [];
@@ -129,6 +126,7 @@ export async function drawImportedFile(
function release() { function release() {
destroyed = true; destroyed = true;
surface?.destroy();
removeCull?.(); removeCull?.();
removeCull = null; removeCull = null;
for (const set of groundSets.splice(0)) set.destroy(); for (const set of groundSets.splice(0)) set.destroy();
@@ -276,14 +274,61 @@ export async function drawImportedFile(
pointSetsOf(engine).add(look.set); pointSetsOf(engine).add(look.set);
} }
checkActive(); checkActive();
for (let from = 0; from < pins.length; from += BATCH) { for (const pin of pins) {
settle(heightContext(), from, from + BATCH); pin.position = geo.cartesian3.fromDegrees(
if (from + BATCH < pins.length) { pin.lon,
await new Promise((resolve) => setTimeout(resolve, 0)); pin.lat,
checkActive(); 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); 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(); syncCull();
} catch (error) { } catch (error) {
release(); release();
@@ -291,79 +336,6 @@ export async function drawImportedFile(
} }
request(); 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) { function writePins(look) {
const { set, pins } = look; const { set, pins } = look;
if (!set) return; if (!set) return;
@@ -443,6 +415,7 @@ export async function drawImportedFile(
look.set?.commit(); look.set?.commit();
} }
syncCull(); syncCull();
if (shown) surface.place({ all: true });
request(); request();
}, },
/** /**
@@ -460,20 +433,9 @@ export async function drawImportedFile(
]); ]);
return out; return out;
}, },
/** Sample the ground under the points again (a map or the terrain changed). */
async refreshGround(signal) { 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(); signal?.throwIfAborted();
if (destroyed) return; surface.place({ all: true });
for (const look of looks.values()) writePins(look);
request();
}, },
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
+18 -19
View File
@@ -7,7 +7,6 @@ import {
MAX_RAISED_RINGS, MAX_RAISED_RINGS,
MAX_RAISED_VERTICES, MAX_RAISED_VERTICES,
OVERLAY_PIXEL_BUDGET, OVERLAY_PIXEL_BUDGET,
BATCH,
} from './geoImportLimits.js'; } from './geoImportLimits.js';
import { drawImportedFile } from './geoImportDisplay.js'; import { drawImportedFile } from './geoImportDisplay.js';
import { emptyRecords, geoJsonRecords } from './geoImportRecords.js'; import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
@@ -34,6 +33,7 @@ for (const make of GROUND_HARNESSES) {
}, },
}); });
const { engine, viewer } = harness; const { engine, viewer } = harness;
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
if (viewer) { if (viewer) {
viewer.imageryLayers = new Cesium.ImageryLayerCollection(); viewer.imageryLayers = new Cesium.ImageryLayerCollection();
viewer.scene.globe.show = true; viewer.scene.globe.show = true;
@@ -184,7 +184,8 @@ for (const make of GROUND_HARNESSES) {
); );
assert.ok(pins.every(({ options }) => options.pickable === false)); assert.ok(pins.every(({ options }) => options.pickable === false));
ground = 70; ground = 70;
await drawing.refreshGround(); await engine.maps.setTerrain({ kind: 'ellipsoid' });
engine.fake.frame();
assert.deepEqual(heights(), [70, 80, 500]); assert.deepEqual(heights(), [70, 80, 500]);
ground = undefined; ground = undefined;
await drawing.refreshGround(); await drawing.refreshGround();
@@ -200,15 +201,14 @@ for (const make of GROUND_HARNESSES) {
if (viewer) { if (viewer) {
viewer.scene.globe.show = false; viewer.scene.globe.show = false;
viewer.scene.clampToHeightSupported = true; viewer.scene.clampToHeightSupported = true;
} else engine.imagery.surface = () => 'tiles'; } else await engine.maps.setPhotorealTiles({});
engine.camera.height = () => 1000; engine.camera.height = () => 1000;
engine.projector.worldToWindow = () => ({ x: 1, y: 1 }); engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
engine.projector.horizonVisible = () => true; engine.projector.horizonVisible = () => true;
const excluded = []; const excluded = [];
engine.surface.clampToHeight = (position, exclude) => { engine.surface.sampleHeight = (_position, exclude) => {
excluded.push(exclude); excluded.push(exclude);
const at = geo.cartographic.fromCartesian(position); return 2000;
return geo.cartesian3.fromRadians(at.longitude, at.latitude, 2000);
}; };
const ground = await drawImportedFile( const ground = await drawImportedFile(
engine, engine,
@@ -248,15 +248,12 @@ for (const make of GROUND_HARNESSES) {
displayTest( displayTest(
'a pin on the tiles keeps its height while out of the window', 'a pin on the tiles keeps its height while out of the window',
async ({ engine }) => { async ({ engine }) => {
engine.imagery.surface = () => 'tiles'; await engine.maps.setPhotorealTiles({});
engine.camera.height = () => 1000; engine.camera.height = () => 1000;
engine.projector.horizonVisible = () => true; engine.projector.horizonVisible = () => true;
let visible = true; let visible = true;
engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null); engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
engine.surface.clampToHeight = (position) => { engine.surface.sampleHeight = () => 500;
const at = geo.cartographic.fromCartesian(position);
return geo.cartesian3.fromRadians(at.longitude, at.latitude, 500);
};
const drawing = await drawImportedFile( const drawing = await drawImportedFile(
engine, engine,
geoJsonRecords({ type: 'Point', coordinates: [1, 2] }, '#39d0ff'), 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); projected = Math.round(geo.cartographic.fromCartesian(position).height);
return projected >= 500 ? { x: 1, y: 1 } : null; return projected >= 500 ? { x: 1, y: 1 } : null;
}; };
engine.surface.clampToHeight = () => engine.surface.sampleHeight = () => 700;
geo.cartesian3.fromDegrees(1, 2, 700);
await drawing.refreshGround(); await drawing.refreshGround();
assert.equal(projected, 500, 'visibility uses the last measured height'); assert.equal(projected, 500, 'visibility uses the last measured height');
assert.equal(height(), 700); assert.equal(height(), 700);
engine.surface.clampToHeight = () => undefined; engine.surface.sampleHeight = () => undefined;
await drawing.refreshGround(); await drawing.refreshGround();
assert.equal( assert.equal(
height(), height(),
@@ -1258,17 +1254,17 @@ for (const make of GROUND_HARNESSES) {
); );
displayTest( displayTest(
'tile clamps only sample visible pins and yield between batches', 'tile samples only measure visible pins and yield between passes',
async ({ engine }) => { async ({ engine }) => {
engine.imagery.surface = () => 'tiles'; await engine.maps.setPhotorealTiles({});
engine.camera.height = () => 1000; engine.camera.height = () => 1000;
engine.projector.horizonVisible = () => true; engine.projector.horizonVisible = () => true;
let visible = false; let visible = false;
engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null); engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
let clamps = 0; let clamps = 0;
engine.surface.clampToHeight = (position) => { engine.surface.sampleHeight = () => {
clamps++; clamps++;
return position; return 0;
}; };
const records = geoJsonRecords( const records = geoJsonRecords(
{ {
@@ -1285,8 +1281,11 @@ for (const make of GROUND_HARNESSES) {
beforeYield = clamps; beforeYield = clamps;
}, 0); }, 0);
await drawing.refreshGround(); await drawing.refreshGround();
await new Promise((resolve) => setTimeout(resolve, 0));
clearTimeout(heartbeat); 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); assert.equal(clamps, 600);
drawing.destroy(); drawing.destroy();
}, },
-4
View File
@@ -41,12 +41,8 @@ export const MAX_RAISED_VERTICES = 32_768;
export const MAX_RAISED_RINGS = 128; export const MAX_RAISED_RINGS = 128;
// Yield between polygons after half a 60 Hz frame, preserving the 8 ms work slice. // Yield between polygons after half a 60 Hz frame, preserving the 8 ms work slice.
export const POLYGON_YIELD_MS = 8; 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. // Keep below-ellipsoid ground visible at the horizon, matching GhostMaps' 2 km lift.
export const HORIZON_LIFT_M = 2000; 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. // Cesium draws ground lines over fills and ground rectangles over layer rasters.
export const POLYGON_ORDER = 0; export const POLYGON_ORDER = 0;
+2
View File
@@ -195,6 +195,8 @@ test('consumer build includes only infrastructure code and engine-free geometry,
'scaleToGeodeticSurface', 'scaleToGeodeticSurface',
'simon1994', 'simon1994',
].map((name) => `src/geo/${name}.js`), ].map((name) => `src/geo/${name}.js`),
'src/globe/surfaceDetail.js',
'src/globe/surfacePlacement.js',
'src/layers/infrastructure/index.js', 'src/layers/infrastructure/index.js',
'src/layers/infrastructure/render/display.js', 'src/layers/infrastructure/render/display.js',
'src/layers/shared/color.js', 'src/layers/shared/color.js',
@@ -7,7 +7,8 @@
// tiles have loaded; a point whose height holds is not written again, so a // tiles have loaded; a point whose height holds is not written again, so a
// parked camera asks for no frames. // 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 SAMPLES_PER_PASS = 16;
const SAMPLE_BUDGET_MS = 4; const SAMPLE_BUDGET_MS = 4;
@@ -21,8 +22,88 @@ const SCREEN_MARGIN = 0.25;
const plausible = (height) => const plausible = (height) =>
Number.isFinite(height) && height >= -500 && height <= 9000; 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 {object} options
* @param {() => Iterable<{ set: object | null, points: object[], exclude?: object[] }>} options.groups * @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 * 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 * placement replaces (keeping the height in `surfaceHeight`); one with
* `show === false` is skipped. * `show === false` is skipped.
* @param {() => void} options.request Asks for a frame after a placement. * @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 * @param {boolean} [options.estimateGround=false] Screen-test a point not
* measured yet on the loaded terrain (under 3D tiles without it, at the * measured yet on the loaded terrain (under 3D tiles without it, at the
* ground under the camera) rather than where it stands now. * ground under the camera) rather than where it stands now.
* @returns {{ place: () => void, destroy: () => void }} `place()` measures * @param {number} [options.screenMargin=0.25] Extra viewport fraction to measure.
* the points not yet placed on this surface (new points, for example). * @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( export function createSurfacePoints(
engine, engine,
{ groups, request, heightOffset = 0, estimateGround = false }, {
groups,
request,
heightOffset = 0,
estimateGround = false,
screenMargin = SCREEN_MARGIN,
prefer,
},
) { ) {
let revision = 0; let revision = 0;
let timer = null; let timer = null;
let destroyed = false; let destroyed = false;
let tilesLoaded = engine.surface.tilesLoaded();
let photoreal = engine.maps?.photorealActive?.() === true;
function onScreen(position, size) { function onScreen(position, size) {
if (screenMargin === Infinity) return true;
const screen = engine.projector.worldToWindow(position); const screen = engine.projector.worldToWindow(position);
const x = size.width * SCREEN_MARGIN; const x = size.width * screenMargin;
const y = size.height * SCREEN_MARGIN; const y = size.height * screenMargin;
return ( return (
screen && screen &&
screen.x >= -x && screen.x >= -x &&
@@ -72,7 +161,8 @@ export function createSurfacePoints(
} }
// Points off screen are passed over until the next move or tile load. // Points off screen are passed over until the next move or tile load.
function place() { function place({ all = false } = {}) {
if (all) revision++;
clearTimeout(timer); clearTimeout(timer);
timer = null; timer = null;
if (destroyed) return; if (destroyed) return;
@@ -80,6 +170,7 @@ export function createSurfacePoints(
const tiles = engine.maps?.photorealActive?.() === true; const tiles = engine.maps?.photorealActive?.() === true;
const started = performance.now(); const started = performance.now();
let samples = 0; let samples = 0;
let visited = 0;
let moved = false; let moved = false;
let cameraGround; let cameraGround;
for (const { set, points, exclude = [set] } of groups()) { for (const { set, points, exclude = [set] } of groups()) {
@@ -90,18 +181,23 @@ export function createSurfacePoints(
if (point.surfaceRevision === revision) continue; if (point.surfaceRevision === revision) continue;
if ( if (
samples >= SAMPLES_PER_PASS || samples >= SAMPLES_PER_PASS ||
performance.now() - started >= SAMPLE_BUDGET_MS (visited > 0 && performance.now() - started >= SAMPLE_BUDGET_MS)
) { ) {
if (changed) set.commit(); if (changed) set.commit();
if (changed || moved) request(); if (changed || moved) request();
timer = setTimeout(place, 0); timer = setTimeout(place, 0);
return; return;
} }
visited++;
point.surfaceRevision = revision; point.surfaceRevision = revision;
if (point.show === false) continue; if (point.show === false) continue;
const offset =
typeof heightOffset === 'function'
? heightOffset(point)
: heightOffset;
const estimate = estimateGround && point.surfaceHeight === undefined; const estimate = estimateGround && point.surfaceHeight === undefined;
const terrain = estimate const terrain = estimate
? engine.surface.terrainHeight(point.cartographic) ? readSurface(engine, point.cartographic, { prefer: 'terrain' })
: undefined; : undefined;
let ground = terrain; let ground = terrain;
if (estimate && tiles && !plausible(ground)) { if (estimate && tiles && !plausible(ground)) {
@@ -115,23 +211,22 @@ export function createSurfacePoints(
? geo.cartesian3.fromRadians( ? geo.cartesian3.fromRadians(
point.cartographic.longitude, point.cartographic.longitude,
point.cartographic.latitude, point.cartographic.latitude,
ground + heightOffset, ground + offset,
) )
: point.position; : point.position;
if (!onScreen(position, size)) continue; if (!onScreen(position, size)) continue;
samples++; samples++;
let height = tiles const height = readSurface(engine, point.cartographic, {
? engine.surface.sampleHeight(point.cartographic, exclude) exclude,
: undefined; prefer: typeof prefer === 'function' ? prefer(point) : prefer,
if (!plausible(height)) });
height = terrain ?? engine.surface.terrainHeight(point.cartographic);
if (!plausible(height)) continue; if (!plausible(height)) continue;
if (Math.abs(height - point.surfaceHeight) < HEIGHT_EPSILON_M) continue; if (Math.abs(height - point.surfaceHeight) < HEIGHT_EPSILON_M) continue;
point.surfaceHeight = height; point.surfaceHeight = height;
point.position = geo.cartesian3.fromRadians( point.position = geo.cartesian3.fromRadians(
point.cartographic.longitude, point.cartographic.longitude,
point.cartographic.latitude, point.cartographic.latitude,
height + heightOffset, height + offset,
); );
set.set(index, { position: point.position }); set.set(index, { position: point.position });
changed = true; changed = true;
@@ -142,18 +237,7 @@ export function createSurfacePoints(
if (moved) request(); if (moved) request();
} }
function refine() { const removeSurface = surfaceTriggers(engine, () => place({ all: true }));
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;
});
return { return {
place, place,
@@ -161,8 +245,7 @@ export function createSurfacePoints(
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
clearTimeout(timer); clearTimeout(timer);
removeMoveEnd(); removeSurface();
removeFrame();
}, },
}; };
} }
+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 // floor cell, else on the terrain, and waits unseen until one of them is
// known; floors still resolving are asked for once per render. // 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 * as geo from '../../../geo/index.js';
import { distanceScale } from '../../../globe/cpuPicks.js'; import { distanceScale } from '../../../globe/cpuPicks.js';
import { rgbaFromCss } from '../../shared/color.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); geo.cartographic.fromDegrees(record.longitude, record.latitude);
const ownSets = () => [normal, selected].filter(Boolean); const ownSets = () => [normal, selected].filter(Boolean);
// Each sample is a depth render on Cesium, a raycast on Navara. const sampleHeight = (record) =>
function sampleHeight(record) { readSurface(engine, location(record), {
if (!engine.surface.supported) return undefined; exclude: ownSets(),
try { prefer: 'tiles',
return engine.surface.sampleHeight(location(record), ownSets()); });
} catch { const terrainHeight = (record) =>
return undefined; readSurface(engine, location(record), { prefer: 'terrain' });
} let surface = null;
} const surfaceSet = {
function terrainHeight(record) { set(index, { position }) {
return engine.surface.terrainHeightSupported const marker = [...markers.values()][index];
? engine.surface.terrainHeight(location(record)) marker.position = position;
: undefined; marker.resolved = validAlprGroundHeight(marker.surface.surfaceHeight);
} syncMarker(marker);
},
commit: () => flushSprites(),
};
/** The rendered surface under a camera, else the terrain, else its floor. */ /** The rendered surface under a camera, else the terrain, else its floor. */
function surfaceHeight(record) { function surfaceHeight(record) {
let height = sampleHeight(record); let height = sampleHeight(record);
@@ -131,6 +138,15 @@ export function createAlprDisplay(engine, { state, services, source }) {
return { return {
clamped, clamped,
resolved, 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( position: geo.cartesian3.fromDegrees(
record.longitude, record.longitude,
record.latitude, record.latitude,
@@ -250,6 +266,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
marker.drawn = null; marker.drawn = null;
syncMarker(marker); syncMarker(marker);
} }
surface?.place({ all: true });
resolveFloors(); resolveFloors();
flushSprites(); flushSprites();
governorRequestRender('alpr-surface', engine); governorRequestRender('alpr-surface', engine);
@@ -461,6 +478,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
} }
flushSprites(); flushSprites();
if (changed) state.renderRevision += 1; if (changed) state.renderRevision += 1;
surface?.place();
state.onScreen = countOnScreen(); state.onScreen = countOnScreen();
resolveFloors(); resolveFloors();
const chosen = state.selectedId ? markers.get(state.selectedId) : null; const chosen = state.selectedId ? markers.get(state.selectedId) : null;
@@ -674,6 +692,20 @@ export function createAlprDisplay(engine, { state, services, source }) {
normal.commit(); normal.commit();
selected.commit(); selected.commit();
initialized = true; 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); removeMove = engine.camera.moveEnd.addEventListener(onMove);
removeFrame = engine.frames.preRender.addEventListener(frame); removeFrame = engine.frames.preRender.addEventListener(frame);
overlay.init(); overlay.init();
@@ -688,11 +720,14 @@ export function createAlprDisplay(engine, { state, services, source }) {
: null; : null;
if (visible === next) return; if (visible === next) return;
visible = next; visible = next;
if (visible) surface?.place({ all: true });
for (const marker of markers.values()) syncMarker(marker); for (const marker of markers.values()) syncMarker(marker);
flushSprites(); flushSprites();
}, },
destroy(options) { destroy(options) {
if (!initialized) return; if (!initialized) return;
surface?.destroy();
surface = null;
clearRendered(options); clearRendered(options);
overlay.destroy(); overlay.destroy();
removeClick?.(); removeClick?.();
+6
View File
@@ -281,6 +281,12 @@ for (const makeHarness of HARNESSES) {
assert.ok( assert.ok(
Math.abs(heightOf(h.badge('alpr-cameras', 0).position) - 321.5) < 0.01, 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.terrain(undefined);
h.load(records(1).map((r) => ({ ...r, latitude: 30.0001 }))); h.load(records(1).map((r) => ({ ...r, latitude: 30.0001 })));
await settle(); 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 before = engine.frames.preRender.numberOfListeners;
const story = display.draw(data); const story = display.draw(data);
const locator = createBhoteKoshiLocatorDisplay(engine, { data }); const locator = createBhoteKoshiLocatorDisplay(engine, { data });
assert.equal(engine.frames.preRender.numberOfListeners, before + 2); assert.equal(engine.frames.preRender.numberOfListeners, before + 4);
story.destroy(); story.destroy();
locator.destroy(); locator.destroy();
assert.equal(engine.frames.preRender.numberOfListeners, before); 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. // Samples the authored story graphics into ground sets and point movers.
// The story owns reveal time; this display owns only its attached drawing. // 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'; import * as geo from '../../../geo/index.js';
const value = (property) => const value = (property) =>
@@ -25,6 +26,13 @@ export function drawStorySource(engine, data) {
const entries = new Map(); const entries = new Map();
let order = 0; let order = 0;
let destroyed = false; 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) { function point(entity) {
const graphics = entity.point; const graphics = entity.point;
@@ -49,22 +57,27 @@ export function drawStorySource(engine, data) {
: null, : null,
}, },
}); });
const row = { show: false };
const group = { set, points: [row] };
groups.add(group);
let authored;
let previous; let previous;
return { return {
paint(show) { paint(show) {
let position = value(entity.position); let position = value(entity.position);
show = Boolean(show && value(graphics.show) !== false && position); show = Boolean(show && value(graphics.show) !== false && position);
if (show && value(graphics.heightReference) === 1) { const clamped = show && value(graphics.heightReference) === 1;
const place = geo.cartographic.fromCartesian(position); if (clamped && !row.show) delete row.surfaceRevision;
const height = row.show = clamped;
engine.surface.sampleHeight(place, [set]) ?? if (row.show) {
engine.surface.terrainHeight(place); if (!authored || !geo.cartesian3.equals(position, authored)) {
if (Number.isFinite(height)) authored = geo.cartesian3.clone(position);
position = geo.cartesian3.fromRadians( row.cartographic = geo.cartographic.fromCartesian(position);
place.longitude, row.position = position;
place.latitude, delete row.surfaceRevision;
height, delete row.surfaceHeight;
); }
position = row.position;
} }
const fields = show const fields = show
? { ? {
@@ -92,7 +105,10 @@ export function drawStorySource(engine, data) {
set.commit(); set.commit();
previous = snapshot; 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); item.paint(data.show !== false && value(entity.show) !== false);
} }
surface.place();
} }
const off = engine.frames.preRender.addEventListener(frame); const off = engine.frames.preRender.addEventListener(frame);
engine.render.request(data.name); engine.render.request(data.name);
@@ -193,6 +210,7 @@ export function drawStorySource(engine, data) {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
off(); off();
surface.destroy();
for (const item of entries.values()) item.destroy(); for (const item of entries.values()) item.destroy();
entries.clear(); entries.clear();
engine.render.request(data.name); engine.render.request(data.name);
+15 -3
View File
@@ -166,9 +166,11 @@ for (const make of GROUND_HARNESSES) {
}; };
return set; return set;
}; };
h.engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
let ground = 1200;
h.engine.surface.sampleHeight = (_place, exclude) => { h.engine.surface.sampleHeight = (_place, exclude) => {
assert.deepEqual(exclude, [pointSet]); assert.deepEqual(exclude, [pointSet]);
return 1200; return ground;
}; };
const display = drawStorySource(h.engine, source); const display = drawStorySource(h.engine, source);
const frame = () => h.engine.fake.frame(); const frame = () => h.engine.fake.frame();
@@ -179,11 +181,21 @@ for (const make of GROUND_HARNESSES) {
assert.equal(options.point.outlinePx, 3); assert.equal(options.point.outlinePx, 3);
assert.ok( assert.ok(
Math.abs( Math.abs(
Cesium.Cartographic.fromCartesian(writes[0].position).height - 1200, Cesium.Cartographic.fromCartesian(writes.at(-1).position).height - 1200,
) < 1e-6, ) < 1e-6,
); );
frame(); 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; size = 38;
outline = 0.12; outline = 0.12;
frame(); 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. // - lifecycle: what init, enable, show, disable, detach and destroy own.
// The layer (index.js) merges these into its parts. // The layer (index.js) merges these into its parts.
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js'; import { isPointerFree } from '../../../data/inputOwnership.js';
import { import {
@@ -143,6 +144,37 @@ export function createBikeshareDisplay(engine, context) {
commitStations(); 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, { const rendering = createStationPoints(context, {
// On the rendered surface where it answers, a small height otherwise. // On the rendered surface where it answers, a small height otherwise.
position(station) { position(station) {
@@ -150,21 +182,20 @@ export function createBikeshareDisplay(engine, context) {
const lat = Number(station?.lat); const lat = Number(station?.lat);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
ensureSets(); ensureSets();
const sampled = engine.surface.supported return geo.cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
? 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);
}, },
create(record, { position, pixelSize }) { create(record, { position, pixelSize }) {
ensureSets(); ensureSets();
record.index = state._stationRenderMap.size; record.index = state._stationRenderMap.size;
record.point = { record.point = {
position, position,
surface: {
position,
cartographic: geo.cartographic.fromCartesian(position),
get show() {
return visible;
},
},
pixelSize, pixelSize,
color: COLOR_NEUTRAL, color: COLOR_NEUTRAL,
show: true, show: true,
@@ -172,13 +203,13 @@ export function createBikeshareDisplay(engine, context) {
writePoint(record); writePoint(record);
return record.point; return record.point;
}, },
afterAdd: commitStations, afterAdd: placeStations,
afterRemove() { afterRemove() {
reindex(); reindex();
commitStations(); commitStations();
}, },
update: writePoint, update: writePoint,
afterUpdate: commitStations, afterUpdate: placeStations,
}); });
const selection = { const selection = {
@@ -335,6 +366,7 @@ export function createBikeshareDisplay(engine, context) {
/** Shows the points and card, takes clicks and follows the camera. */ /** Shows the points and card, takes clicks and follows the camera. */
show() { show() {
setVisible(true); setVisible(true);
surface.place({ all: true });
state._overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true); state._overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true);
parts.display._installClickHandler(); parts.display._installClickHandler();
// Pick ownership: station point ids are render-map keys. // Pick ownership: station point ids are render-map keys.
@@ -374,6 +406,7 @@ export function createBikeshareDisplay(engine, context) {
parts.display._clearSelection(); parts.display._clearSelection();
releaseInput(); releaseInput();
detachCamera(); detachCamera();
surface.destroy();
destroySets(); destroySets();
destroyed = true; destroyed = true;
}, },
@@ -393,6 +426,7 @@ export function createBikeshareDisplay(engine, context) {
} }
detachCamera(); detachCamera();
parts.ingestion.abortAllInFlight(); parts.ingestion.abortAllInFlight();
surface.destroy();
destroySets(); destroySets();
destroyed = true; destroyed = true;
state._engine = null; state._engine = null;
@@ -272,6 +272,29 @@ for (const makeHarness of HARNESSES) {
const point = f.layer.getDetectableObjects()[0].position; const point = f.layer.getDetectableObjects()[0].position;
near(geo.cartographic.fromCartesian(point).height, 177, 'height'); 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]; const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
assert.deepEqual( assert.deepEqual(
call.args[1].map((set) => set.id), 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 // Placement, routing, anchoring, selection and the route flight stay in the
// layer. // layer.
import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { import {
MARKER_COLORS, MARKER_COLORS,
@@ -121,31 +122,37 @@ export function createDirectionsDisplay(engine) {
const own = () => [markers, letters, steps].filter(Boolean); const own = () => [markers, letters, steps].filter(Boolean);
// The rendered surface under a marker (tiles, else terrain), without the const markerPoints = LETTERS.map(() => ({ show: false }));
// display's own markers. const surface = createSurfacePoints(engine, {
function groundPosition({ lon, lat }) { groups: () => [
const carto = geo.cartographic.fromDegrees(lon, lat); {
let height; points: markerPoints,
try {
height = engine.surface.sampleHeightDetailed(carto, {
exclude: own(), exclude: own(),
})?.height; set: {
} catch { set(index, fields) {
height = undefined; markers.set(index, fields);
} letters?.set(index, fields);
return geo.cartesian3.fromDegrees( },
lon, commit() {
lat, markers.commit();
Number.isFinite(height) ? height : 0, letters?.commit();
); },
} },
},
],
prefer: 'scene',
estimateGround: true,
request: () => engine.render.request(ID),
});
function placeMarkers() { function placeMarkers() {
if (destroyed) return; if (destroyed) return;
LETTERS.forEach((letter, index) => { LETTERS.forEach((letter, index) => {
const point = ends[letter]; const point = ends[letter];
const show = visible && Boolean(point); 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, { markers.set(index, {
...(position ? { position } : {}), ...(position ? { position } : {}),
color: MARKER_COLORS[letter], color: MARKER_COLORS[letter],
@@ -157,6 +164,7 @@ export function createDirectionsDisplay(engine) {
show, show,
}); });
}); });
surface.place();
markers.setCount(LETTERS.length); markers.setCount(LETTERS.length);
markers.commit(); markers.commit();
if (letters) { if (letters) {
@@ -203,15 +211,6 @@ export function createDirectionsDisplay(engine) {
steps.commit(); 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(); placeMarkers();
return { return {
@@ -219,6 +218,12 @@ export function createDirectionsDisplay(engine) {
setMarker(letter, point) { setMarker(letter, point) {
if (destroyed || !LETTERS.includes(letter)) return; if (destroyed || !LETTERS.includes(letter)) return;
ends[letter] = point ? { lon: point.lon, lat: point.lat } : null; 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(); placeMarkers();
}, },
/** Draws the route ([lon, lat] pairs), or removes it (null). */ /** Draws the route ([lon, lat] pairs), or removes it (null). */
@@ -276,13 +281,13 @@ export function createDirectionsDisplay(engine) {
visible = Boolean(next); visible = Boolean(next);
route?.show(visible); route?.show(visible);
placeMarkers(); placeMarkers();
if (visible) surface.place({ all: true });
writeSteps(); writeSteps();
}, },
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
removeMoveEnd(); surface.destroy();
removeSurface();
route?.destroy(); route?.destroy();
steps?.destroy(); steps?.destroy();
markers.destroy(); markers.destroy();
@@ -351,3 +351,30 @@ test('route endpoints refine when the surface finishes loading', (t) => {
) < 0.01, ) < 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 { normalizeGroundItem } from '../../../globe/ground.js';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { imageAtlasLayout } from '../../shared/render/imageAtlas.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 LAYER_ID = 's2-ghostmaps';
const TRANSPARENT = Object.freeze([0, 0, 0, 0]); 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 // surface. The references' cards go to the shared world-overlay host. A
// click on any of them selects the feature and flies there. // 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 * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js'; import { isPointerFree } from '../../../data/inputOwnership.js';
import { import {
@@ -58,16 +63,7 @@ const FLY_SECONDS = 1.5;
* sampled once and the base moves onto it. * sampled once and the base moves onto it.
*/ */
export const GROUND_SAMPLE_MAX_DISTANCE_M = 75000; export const GROUND_SAMPLE_MAX_DISTANCE_M = 75000;
export const GROUND_SAMPLE_RETRY_MS = 2000;
const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000; 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. */ /** Tip moves below this are camera noise and are not drawn. */
export const LOCAL_STEM_TIP_EPSILON_M = 0.5; export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
const TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; const TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
@@ -151,10 +147,7 @@ export function createInfrastructureDisplay(
let dirty = true, let dirty = true,
lastWalk = Number.NEGATIVE_INFINITY, lastWalk = Number.NEGATIVE_INFINITY,
walkCount = 0; walkCount = 0;
// The ground-sample retry. let surface = null;
let retryTimer = null,
retryArms = 0,
lastCapability = null;
// Per walk: the view-projection matrix, computed on first use. // Per walk: the view-projection matrix, computed on first use.
const viewProjection = geo.matrix4.create(); const viewProjection = geo.matrix4.create();
let viewProjectionWalk = -1; let viewProjectionWalk = -1;
@@ -165,23 +158,6 @@ export function createInfrastructureDisplay(
const photoreal = () => engine.maps?.photorealActive?.() === true; 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. */ /** The record's vertical at `height` metres above the ellipsoid. */
function vertical(row, height, result) { function vertical(row, height, result) {
scratchCarto.longitude = row.carto.longitude; scratchCarto.longitude = row.carto.longitude;
@@ -227,7 +203,7 @@ export function createInfrastructureDisplay(
function groundInView(row) { function groundInView(row) {
const terrain = photoreal() const terrain = photoreal()
? undefined ? undefined
: engine.surface.terrainHeight(row.carto); : readSurface(engine, row.carto, { prefer: 'terrain' });
const known = const known =
finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
const low = vertical( const low = vertical(
@@ -262,53 +238,36 @@ export function createInfrastructureDisplay(
row.baseMoved = true; row.baseMoved = true;
} }
// The scene height under the record, once, while the visible surface has function placeGround() {
// loaded. A coarse sample can lie below the loaded terrain: the terrain is const points = [...rows.values()];
// the floor then (not under photoreal tiles), while roofs and valid surface = createSurfacePoints(engine, {
// below-sea-level heights stay. groups: () => {
function sampleGround(row, now) { viewProjectionWalk = -1;
if (row.groundSampled || !engine.surface.supported) return false; return [
if (now - row.lastSample < GROUND_SAMPLE_RETRY_MS) return false; {
if (!groundInView(row)) return false; points: points.map((row) => row.surface),
row.lastSample = now; exclude: [...exclude, stemBatch].filter(Boolean),
const globe = !photoreal(); set: {
if (globe && !engine.surface.tilesLoaded()) return false; set(index, { position }) {
// The stem stands on the sample point: out of the sample. const row = points[index];
stemBatch?.show(false); setGroundHeight(
let sampled; row,
try { geo.cartographic.fromCartesian(position).height,
sampled = engine.surface.sampleHeight(row.carto, exclude); );
} finally { },
stemBatch?.show(true); commit() {
} dirty = true;
if (!finite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) lastWalk = Number.NEGATIVE_INFINITY;
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); // The LOD checks the entire ground vertical before offering a point.
return true; screenMargin: Infinity,
} prefer: 'sceneFloor',
request: wake,
// 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;
} }
// The tip STEM_TARGET_PX above the base at the camera's distance; a move // 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) const distance = finite(knownDistance)
? knownDistance ? knownDistance
: geo.cartesian3.distance(pose.position, row.base); : geo.cartesian3.distance(pose.position, row.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleGround(row, now);
const tipHeight = const tipHeight =
row.groundHeight + row.groundHeight +
Math.max(distance, 1) * Math.max(distance, 1) *
@@ -395,6 +353,9 @@ export function createInfrastructureDisplay(
nowMs: now, nowMs: now,
activeLimit: selection.budget.activeLimit, 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); active = new Set(kept.keepIds);
grace = kept.graceState; grace = kept.graceState;
budget = selection.budget.activeLimit; budget = selection.budget.activeLimit;
@@ -433,20 +394,18 @@ export function createInfrastructureDisplay(
if (probe.recompute) dirty = true; if (probe.recompute) dirty = true;
} }
const refresh = dirty; 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); if (refresh && rows.size) selectLod(pose, now);
surface.place();
let tipsChanged = false; let tipsChanged = false;
let pinsChanged = false; let pinsChanged = false;
const visible = []; const visible = [];
for (const row of rows.values()) { for (const row of rows.values()) {
const isActive = !computed || active.has(row.id); 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) { if (row.show !== isVisible) {
row.show = isVisible; row.show = isVisible;
tips.set(row.index, { show: isVisible }); tips.set(row.index, { show: isVisible });
@@ -458,30 +417,7 @@ export function createInfrastructureDisplay(
} }
if (!isVisible) dropStem(row.id); if (!isVisible) dropStem(row.id);
if (!isActive) continue; if (!isActive) continue;
const wasSampled = row.groundSampled; if (refresh || row.baseMoved) sizeStem(row, now, pose, view);
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 (row.tipMoved) { if (row.tipMoved) {
row.tipMoved = false; row.tipMoved = false;
tips.set(row.index, { position: row.tip }); tips.set(row.index, { position: row.tip });
@@ -521,9 +457,6 @@ export function createInfrastructureDisplay(
dirty = false; dirty = false;
if (tipsChanged) tips.commit(); if (tipsChanged) tips.commit();
if (pinsChanged) pins.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( publisher.publish(
selectLocalInfrastructureOverlayCohort(visible, { selectLocalInfrastructureOverlayCohort(visible, {
maxEntries: labelMax, maxEntries: labelMax,
@@ -575,7 +508,8 @@ export function createInfrastructureDisplay(
function hide() { function hide() {
const shown = Boolean(dataset); const shown = Boolean(dataset);
services.hideOsmCredit?.(engine, id); services.hideOsmCredit?.(engine, id);
clearGroundRetry(); surface?.destroy();
surface = null;
for (const remove of removers.splice(0)) remove(); for (const remove of removers.splice(0)) remove();
stems.clear(); stems.clear();
stemBatch?.destroy(); stemBatch?.destroy();
@@ -640,8 +574,6 @@ export function createInfrastructureDisplay(
nextTip: geo.cartesian3.clone(tip), nextTip: geo.cartesian3.clone(tip),
priority, priority,
groundHeight: 0, groundHeight: 0,
groundSampled: false,
lastSample: Number.NEGATIVE_INFINITY,
// As a Cesium entity's: shown until the first walk decides; the // As a Cesium entity's: shown until the first walk decides; the
// context store reads it (isContextRecordActive). // context store reads it (isContextRecordActive).
show: true, show: true,
@@ -659,6 +591,19 @@ export function createInfrastructureDisplay(
: null, : null,
}; };
row.__localBaseCartesian = row.base; 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); rows.set(row.id, row);
services.registerEntityContext?.(row, { services.registerEntityContext?.(row, {
id: row.pickId, id: row.pickId,
@@ -741,15 +686,14 @@ export function createInfrastructureDisplay(
try { try {
dataset = next; dataset = next;
build(next); build(next);
placeGround();
removers.push( removers.push(
engine.frames.preRender.addEventListener(walk), surfaceTriggers(engine, () => {
engine.camera.moveEnd.addEventListener(() => {
dirty = true; dirty = true;
lastWalk = Number.NEGATIVE_INFINITY; lastWalk = Number.NEGATIVE_INFINITY;
// A camera move is a fresh situation with free frames.
retryArms = 0;
wake(); wake();
}), }),
engine.frames.preRender.addEventListener(walk),
engine.input.on('click', onClick, { pick: true }), engine.input.on('click', onClick, { pick: true }),
); );
publisher.show(); publisher.show();
+57 -153
View File
@@ -21,7 +21,6 @@ import {
import { LOCAL_OVERLAY_COHORT_LIMIT } from '../../../data/localGeojsonCore.js'; import { LOCAL_OVERLAY_COHORT_LIMIT } from '../../../data/localGeojsonCore.js';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { import {
GROUND_SAMPLE_MAX_ARMED_RETRIES,
LOCAL_STEM_TIP_EPSILON_M, LOCAL_STEM_TIP_EPSILON_M,
createInfrastructureDisplay, createInfrastructureDisplay,
} from './display.js'; } 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`. */ /** The camera `southM` south of the dam at `cameraM`, aimed at `groundM`. */
function aim(f, { groundM, cameraM, southM, away = false }) { function aim(f, { groundM, cameraM, southM, away = false }) {
const lat = DAM_LAT - geo.math.toDegrees(southM / 6_371_000); 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. // Looking away from the dam.
aim(f, { groundM: 117, cameraM: 20_000, southM: 5_000, away: true }); aim(f, { groundM: 117, cameraM: 20_000, southM: 5_000, away: true });
f.display.show(dataset([DAM])); 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.sampleCalls(), 0, 'an off-screen base is not sampled');
assert.equal(f.retries(), 0, 'nor keeps a parked camera rendering'); assert.equal(f.retries(), 0, 'nor keeps a parked camera rendering');
// In view but over the horizon: 50 m up, about 67 km away. // In view but over the horizon: 50 m up, about 67 km away.
@@ -687,7 +678,8 @@ for (const makeHarness of HARNESSES) {
roll: 0, roll: 0,
}); });
f.moveEnd(); 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.sampleCalls(), 0, 'a base over the horizon is not sampled');
assert.equal(f.retries(), 0); assert.equal(f.retries(), 0);
f.look(DAM_LON, DAM_LAT, 20_000); f.look(DAM_LON, DAM_LAT, 20_000);
@@ -814,10 +806,10 @@ for (const makeHarness of HARNESSES) {
clock.advance(500); clock.advance(500);
f.preRender(); f.preRender();
assert.ok( assert.ok(
Math.abs(f.baseHeight() - 120) < 0.01, Math.abs(f.baseHeight() - 117) < 0.01,
'terrain under photoreal tiles is not a floor', '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 [ 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) => { test(`the stem is excluded when a changed surface is sampled (${name})`, (t) => {
let fails = true; let height = 50;
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;
const f = fixture(t, makeHarness, { const f = fixture(t, makeHarness, {
surface: { supported: true, sample: () => height, terrain: () => 0 }, surface: { supported: true, sample: () => height, terrain: () => 0 },
}); });
t.mock.timers.enable({ apis: ['setTimeout'] }); installFakeClock(t);
const clock = installFakeClock(t);
f.look(DAM_LON, DAM_LAT, 20_000); f.look(DAM_LON, DAM_LAT, 20_000);
f.display.show(dataset([DAM])); 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(); f.preRender();
assert.ok(Math.abs(f.baseHeight() - 210) < 1e-6, 'the move grounds it'); height = 70;
runArmedRetryChain(f, t, clock, 5); f.moveEnd();
f.preRender();
const call = f.engine.fake.callsOf('surface.sampleHeight').at(-1);
assert.equal( assert.equal(
f.retries(), call.args[1].length,
GROUND_SAMPLE_MAX_ARMED_RETRIES, 2,
'a grounded record asks for nothing', '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). // 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 * as geo from '../../../geo/index.js';
import { rgbaFromCss } from '../../shared/color.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'; import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from '../model.js';
// Above the sampled surface, so a point does not sink into it. // 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 // 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 // terrain, or under Google 3D on the rendered tiles, measured on screen a
// few per pass and again after moves and tile loads // 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 * as geo from '../../../geo/index.js';
import { rgbaFromCss } from '../../shared/color.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. */ /** Lines per ground set: smaller sets build, and show, sooner. */
export const SEQUENCE_LINE_BATCH = 120; 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. // its measure, and a camera move can bring it into view first.
function locate(row) { function locate(row) {
row.cartographic = geo.cartographic.fromDegrees(row.lon, row.lat); 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.position = geo.cartesian3.fromDegrees(
row.lon, row.lon,
row.lat, 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 // What Street Level reads of the camera and the ground, on the globe
// contract: `view` is the layer's display (render/display.js), whose // contract: `view` is the layer's display (render/display.js), whose
// `engine` it asks. Screen rays meet an ellipsoid raised to the ground // `engine` it asks. Screen rays meet an ellipsoid raised to the ground
@@ -43,25 +44,12 @@ export function plausibleSurfaceHeight(height) {
* underground before tiles load). * underground before tiles load).
*/ */
export function sampledSurfaceHeight(engine, carto) { export function sampledSurfaceHeight(engine, carto) {
let height = null; return readSurface(engine, carto, { prefer: 'tiles' }) ?? null;
try {
if (engine?.surface?.supported) height = engine.surface.sampleHeight(carto);
} catch {
/* not sampleable yet */
}
return plausibleSurfaceHeight(height) ? height : null;
} }
/** Height (m) of the terrain at `carto`, or null. */ /** Height (m) of the terrain at `carto`, or null. */
export function terrainSurfaceHeight(engine, carto) { export function terrainSurfaceHeight(engine, carto) {
let height; return readSurface(engine, carto, { prefer: 'terrain' }) ?? null;
try {
if (engine?.surface?.terrainHeightSupported)
height = engine.surface.terrainHeight(carto);
} catch {
height = undefined;
}
return Number.isFinite(height) ? height : null;
} }
/** /**
+1 -1
View File
@@ -14,7 +14,7 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js'; import { isPointerFree } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { createSurfacePoints } from '../../shared/render/surfacePoints.js'; import { createSurfacePoints } from '../../../globe/surfacePlacement.js';
import { import {
featureLabel, featureLabel,
featureReference, featureReference,
+6 -8
View File
@@ -1,3 +1,4 @@
import { readSurface } from '../../globe/surfacePlacement.js';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { requestWithDeadline } from '../../sources/requestDeadline.js'; import { requestWithDeadline } from '../../sources/requestDeadline.js';
import { import {
@@ -280,14 +281,11 @@ export function createIngestion({
let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon); let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon);
if (!Number.isFinite(estimated) && engine) { if (!Number.isFinite(estimated) && engine) {
try { try {
estimated = !engine.maps.photorealActive() estimated = readSurface(engine, location, {
? engine.surface.terrainHeight(location) exclude: layerState._display?.excluded() ?? [],
: engine.surface.supported prefer: engine.maps.photorealActive() ? 'tiles' : 'terrain',
? engine.surface.sampleHeight( minHeight: -9000,
location, });
layerState._display?.excluded() ?? [],
)
: undefined;
} catch { } catch {
/* the local mesh can still be loading */ /* 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 { phaseTiming } from '../../sources/phaseTiming.js';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { DOT_HEIGHT_OFFSET, MAX_WAYPOINTS_PER_ROAD } from './policy.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 // locally rendered mesh, not unresolved/offscreen tiles: global
// tilesLoaded can stay false while this street is usable. // tilesLoaded can stay false while this street is usable.
const photoreal = engine.maps.photorealActive(); const photoreal = engine.maps.photorealActive();
if (!photoreal) sampled = engine.surface.terrainHeight(carto); if (!photoreal)
sampled = readSurface(engine, carto, {
prefer: 'terrain',
minHeight: -9000,
});
else { else {
const estimate = geo.cartographic.fromCartesian( const estimate = geo.cartographic.fromCartesian(
road.waypoints[i], road.waypoints[i],
@@ -311,11 +316,15 @@ export async function prepareRoadSurfaces(
sampled = renderedRoadHeight(engine, lon, lat, estimate); sampled = renderedRoadHeight(engine, lon, lat, estimate);
if (validHeight(sampled)) metrics.depthHits++; if (validHeight(sampled)) metrics.depthHits++;
} }
if (photoreal && !validHeight(sampled) && engine.surface.supported) { if (photoreal && !validHeight(sampled)) {
const sampleStart = performance.now(); const sampleStart = performance.now();
metrics.sampleCount++; metrics.sampleCount++;
try { try {
sampled = engine.surface.sampleHeight(carto, excluded); sampled = readSurface(engine, carto, {
exclude: excluded,
prefer: 'tiles',
minHeight: -9000,
});
} catch { } catch {
/* local mesh missing */ /* local mesh missing */
} }
+23 -25
View File
@@ -3715,32 +3715,30 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
}, },
}).methods; }).methods;
// The bikeshare display claims through its engine's camera: the viewer's. // The bikeshare display claims through its engine's camera: the viewer's.
const bike = createBikeshareDisplay( const bike = createBikeshareDisplay(app.engine, {
{ camera: app.viewer.camera }, state: {
{ _stationRenderMap: new Map(),
state: { _overlayHost: { setVisible: noop },
_overlayHost: { setVisible: noop }, _cityRuntime: new Map(),
_cityRuntime: new Map(), _failedCityIds: new Set(),
_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 },
},
}, },
); 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 layers = { traffic, bikeshare: bike, transit: app.layer };
const orders = [ const orders = [
['traffic', 'bikeshare', 'transit'], ['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: // The transit vehicles on the globe contract, the same on every engine:
// - markers: two engine.movers sprite sets (the stationary fleet and the // - markers: two engine.movers sprite sets (the stationary fleet and the
// vehicles mid-glide, so a glide never rewrites the fleet), drawn over 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 // Photoreal tiles hide the globe: the floor comes from the mesh
// samples and the coarse floors instead. // samples and the coarse floors instead.
if (engine.maps.photorealActive()) return undefined; if (engine.maps.photorealActive()) return undefined;
if (!engine.surface.terrainHeightSupported) return undefined; return readSurface(
return engine.surface.terrainHeight( engine,
geo.cartographic.fromDegrees(lon, lat, 0, scratchCartographic), geo.cartographic.fromDegrees(lon, lat, 0, scratchCartographic),
{ prefer: 'terrain' },
); );
}, },
// The engine's surface leaves out mover sets, not single sprites: a // 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. */ /** Listeners that run on every drawn frame. */
frameWork: () => engine.frames.preRender.numberOfListeners, frameWork: () => engine.frames.preRender.numberOfListeners,
/** The surface-loading watch (one per enabled scene). */ /** 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. */ /** Surface loading as the engine reports it, then one frame. */
tiles(loaded) { tiles(loaded) {
tilesLoaded = loaded; tilesLoaded = loaded;
@@ -370,16 +370,16 @@ test('terrain warming resamples unchanged point, card and context anchors withou
h.moveEnd.emit(); h.moveEnd.emit();
assert.equal( assert.equal(
samples, samples,
before, before + 1,
'unchanged snapshots and camera movement do not resample per point', 'camera rest resamples the land anchor once',
); );
h.tiles(false); 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); h.tiles(true);
assert.ok(Math.abs(cartographicOf(point.position).height - 600) < 0.01); assert.ok(Math.abs(cartographicOf(point.position).height - 600) < 0.01);
assert.equal( assert.equal(
samples, samples,
before + 1, before + 2,
'Acartia sea anchors are never terrain-sampled', 'Acartia sea anchors are never terrain-sampled',
); );
assert.ok(Math.abs(cartographicOf(sea.position).height) < 0.01); 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, point.position,
); );
assert.ok(h.renders.includes('wildlife-sightings-terrain')); assert.ok(h.renders.includes('wildlife-sightings-terrain'));
assert.equal(h.frameWork(), 0); assert.equal(h.frameWork(), 1);
h.layer.disable(); h.layer.disable();
assert.equal(h.surfaceWatch(), 0); assert.equal(h.surfaceWatch(), 0);
const disabledSamples = samples; const disabledSamples = samples;
@@ -864,7 +864,7 @@ test('privacy cells draw on ground, replace changed batches and release them', a
await h.update(); await h.update();
const first = cells(); const first = cells();
assert.equal(h.engine.ground.list().length, 1); 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' }]; rows = [{ ...rows[0], iconic: 'Fungi' }];
await h.update(); await h.update();
const second = cells(); const second = cells();
@@ -1636,7 +1636,7 @@ test('ambient cards prefilter the horizon, spread over screen regions and retain
assertCohort(entries); assertCohort(entries);
const farDot = h.badges().find((point) => point.id === 'wildlife:inat:far'); const farDot = h.badges().find((point) => point.id === 'wildlife:inat:far');
assert.ok(!farDot || farDot.show === false); assert.ok(!farDot || farDot.show === false);
assert.equal(h.frameWork(), 0); assert.equal(h.frameWork(), 1);
h.layer.selectSighting('inat:01'); h.layer.selectSighting('inat:01');
const selected = h.overlay.entries.get('wildlife-sightings-selected'); const selected = h.overlay.entries.get('wildlife-sightings-selected');
h.moveEnd.emit(); h.moveEnd.emit();
@@ -1680,7 +1680,7 @@ test('observations publish real badges while grid acquisition is still pending',
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.equal(h.badges()[0]?.id, 'wildlife:inat:1'); assert.equal(h.badges()[0]?.id, 'wildlife:inat:1');
assert.equal(h.layer.getStats().count, 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 { } finally {
finishGrid({ cells: [], totalResults: 0, fetchedAt: NOW }); finishGrid({ cells: [], totalResults: 0, fetchedAt: NOW });
await updating; 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.badges().length, 0);
assert.equal(h.glow().store.count, 2); assert.equal(h.glow().store.count, 2);
assert.equal(h.layer.getStats().countLabel, '21 sightings in view'); 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 { } finally {
finishGrid({ cells, totalResults: 21, fetchedAt: NOW }); finishGrid({ cells, totalResults: 21, fetchedAt: NOW });
await updating; 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-labels'), []);
assert.deepEqual(h.overlay.entries.get('wildlife-sightings-selected'), []); assert.deepEqual(h.overlay.entries.get('wildlife-sightings-selected'), []);
assert.equal(h.focus.length, 0); 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(); h.layer.disable();
assert.equal(h.glow(), undefined); assert.equal(h.glow(), undefined);
assert.equal(h.frameWork(), 0); 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), (entry) => cartographicOf(entry.position).latitude < toRadians(47.59),
), ),
); );
assert.equal(h.frameWork(), 0); assert.equal(h.frameWork(), 1);
h.layer.destroy(); 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 * as geo from '../../geo/index.js';
import { rgbaFromCss } from '../shared/color.js'; import { rgbaFromCss } from '../shared/color.js';
import { SPRITE_LAYER_ORDER } from '../../data/spriteOrder.js'; import { SPRITE_LAYER_ORDER } from '../../data/spriteOrder.js';
@@ -260,14 +261,7 @@ export function createRendering({ state, services, parts }) {
refreshBadgeImages(); refreshBadgeImages();
// The ground floor cache warms as tiles arrive: resample once the // The ground floor cache warms as tiles arrive: resample once the
// surface has settled, on the frame after loading ends. // surface has settled, on the frame after loading ends.
let loaded = engine.surface.tilesLoaded(); surfaceRemovers.push(surfaceTriggers(engine, resampleAnchors));
surfaceRemovers.push(
engine.frames.postRender.addEventListener(() => {
const now = engine.surface.tilesLoaded();
if (now && !loaded) resampleAnchors();
loaded = now;
}),
);
// Terrain, tileset and imagery changes all go through the map stack. // Terrain, tileset and imagery changes all go through the map stack.
services.eventTarget?.addEventListener?.( services.eventTarget?.addEventListener?.(
'gev:map-stack-changed', '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.created, 3);
assert.equal(h.counts.removed, 1); assert.equal(h.counts.removed, 1);
assert.equal(h.counts.cleared, 0); 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(); h.rendering.destroy();
assert.equal(h.badges(), undefined); assert.equal(h.badges(), undefined);
assert.deepEqual(h.engine.movers.list(), []); assert.deepEqual(h.engine.movers.list(), []);
assert.equal(h.state.pointById.size, 0); 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', () => { 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.deepEqual(h.counts.writes, []);
assert.equal( assert.equal(
h.engine.frames.preRender.numberOfListeners, h.engine.frames.preRender.numberOfListeners,
0, 1,
'badges need no frame work', 'one shared surface observer',
); );
h.rendering.destroy(); h.rendering.destroy();
} }
@@ -384,8 +384,8 @@ test('UTFGrid creates a dedicated unpickable glow and observations alone create
); );
assert.equal( assert.equal(
h.engine.frames.preRender.numberOfListeners, h.engine.frames.preRender.numberOfListeners,
1, 2,
'metre-sized glow follows the camera', 'glow and the shared surface observer follow the camera',
); );
const glowSet = h.rendering.density.collection.set; const glowSet = h.rendering.density.collection.set;
h.state.gridCells = [...h.state.gridCells].reverse(); 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(); h.rendering.clear();
assert.equal(glowSet.isDestroyed(), true); assert.equal(glowSet.isDestroyed(), true);
assert.equal(h.glow(), undefined); 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); assert.equal(h.badges().store.count, 0);
h.rendering.destroy(); h.rendering.destroy();
assert.deepEqual(h.engine.movers.list(), []); 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. * groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
* *
@@ -47,7 +48,6 @@
*/ */
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
import { MAX_FLOOR_TILE_SIZE_M } from './meshFloorSampler.js'; import { MAX_FLOOR_TILE_SIZE_M } from './meshFloorSampler.js';
/** Taxi threshold: a cached snap answers directly for moves up to this far from /** 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. // sampleHeight throws when unsupported (no depth textures) — that's a miss.
const exclusions = getExclusions ? getExclusions() : undefined; const exclusions = getExclusions ? getExclusions() : undefined;
// A coarse tile's answer, or only terrain, is a miss. // A coarse tile's answer, or only terrain, is a miss.
sampled = acceptedSurfaceHeight( sampled = readSurface(engine, carto, {
engine.surface.sampleHeightDetailed(carto, { exclude: exclusions }), exclude: exclusions,
{ maxTileSizeM: MAX_FLOOR_TILE_SIZE_M }, prefer: 'tiles',
); maxTileSizeM: MAX_FLOOR_TILE_SIZE_M,
});
} catch { } catch {
sampled = undefined; sampled = undefined;
} }
+6 -7
View File
@@ -1,5 +1,5 @@
import { readSurface } from '../globe/surfacePlacement.js';
import * as geo from '../geo/index.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). /** 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 * Measured on Navara: from 800 m the answering tiles were 112-314 m and
@@ -158,12 +158,11 @@ export function createMeshFloorSampler({
_scratchEngineProbe, _scratchEngineProbe,
); );
// A coarse tile's answer, or only terrain, is a miss (no latch). // A coarse tile's answer, or only terrain, is a miss (no latch).
height = acceptedSurfaceHeight( height = readSurface(engine, carto, {
engine.surface.sampleHeightDetailed(carto, { exclude: excludeObjects,
exclude: excludeObjects, prefer: 'tiles',
}), maxTileSizeM: MAX_FLOOR_TILE_SIZE_M,
{ maxTileSizeM: MAX_FLOOR_TILE_SIZE_M }, });
);
sampled += 1; sampled += 1;
} catch { } catch {
continue; // scene mid-teardown — try again next poll 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, // Camera and aircraft ownership used by the shared first-person controller,
// on the globe contract: the ridden aircraft is the engine follow's target, // on the globe contract: the ridden aircraft is the engine follow's target,
// the aircraft display's tracked glyph handle. // the aircraft display's tracked glyph handle.
@@ -26,7 +27,7 @@ export function cockpitHost(engine, { viewer = null } = {}) {
restore: (target) => target.resumeAircraftFollow(), restore: (target) => target.resumeAircraftFollow(),
camera: engine.camera, camera: engine.camera,
cartographic: () => engine.camera.pose().cartographic, cartographic: () => engine.camera.pose().cartographic,
terrainHeight: (place) => engine.surface.terrainHeight(place), terrainHeight: (place) => readSurface(engine, place, { prefer: 'terrain' }),
surface: engine, surface: engine,
pick: (position) => engine.picker.pick(position), pick: (position) => engine.picker.pick(position),
// A full mesh read is still Cesium's; the contract's detailed read is // 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, engine,
controller: new AbortController(), controller: new AbortController(),
}); });
ownEvent(engine.camera.moveEnd, ground);
const container = engine.host.container; const container = engine.host.container;
const canvas = engine.host.canvas || container; const canvas = engine.host.canvas || container;
listen(canvas, 'pointerdown', gesture, { passive: true }); 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.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.canvas.listeners.length > 0, 'gestures on its canvas');
assert.ok(engine.host.container.listeners.length > 0, 'drops on it'); 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); const [a] = rows(t);
press(t, a, 'toggle'); press(t, a, 'toggle');
assert.equal(t.added.at(-1).show, false); assert.equal(t.added.at(-1).show, false);