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