diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 065d67112..4815e0fd1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,11 +1959,12 @@ during continuous motion. Disable/re-enable resets selection cleanly. Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. Unchanged overlay entries do not request another frame; moved positions, changed membership and re-enabling still publish. Ground sampling waits for -visible globe tiles to settle, samples only markers whose ground is in the -camera's view and above the horizon (unseen ground reads through the Earth -and would retry every 2 s; until it is seen, a stem keeps its unsampled -height, which can show for a stem whose base is just off screen), and uses -valid loaded terrain as a floor while +visible globe tiles to settle, and skips markers whose ground the camera +cannot see (unseen ground reads through the Earth and would retry every 2 s). +Ground is tested at loaded globe terrain, or across 0-9,000 m when the globe +is hidden, so photoreal views skip only ground well out of view. Until it is +seen, a stem keeps its unsampled height, which can show for a stem whose base +is just off screen. Sampling uses valid loaded terrain as a floor while retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 1c47cb8c1..3e104592f 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -113,8 +113,13 @@ async function createRealLocalLayerHarness({ upWC: new Cesium.Cartesian3(), frustum: { fov: Math.PI / 3, + // No planes: everything is in view. One plane no point satisfies: + // nothing is. computeCullingVolume: () => ({ - computeVisibility: () => baseVisibility, + planes: + baseVisibility === Cesium.Intersect.OUTSIDE + ? [new Cesium.Cartesian4(0, 0, 0, -1)] + : [], }), }, moveEnd, @@ -787,64 +792,95 @@ test('an unseen base is not sampled and arms no retry until it is in view', asyn assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); -for (const [label, globe] of [ - ['loaded terrain', { show: true, tilesLoaded: true, getHeight: () => 2_500 }], - ['a hidden globe', { show: false, tilesLoaded: true, getHeight: () => undefined }], +/** + * Give the harness a real Cesium camera `southM` metres south of the base at + * `cameraM` metres above the ellipsoid, looking at the base's ground at + * `groundM` (or directly away from it). + */ +function aimRealCamera(env, { groundM, cameraM, southM, away = false }) { + const entity = env.dataSources[0].entities.values[0]; + const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); + const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM); + const position = Cesium.Cartesian3.fromRadians( + base.longitude, + base.latitude - southM / 6_371_000, + cameraM, + ); + const direction = Cesium.Cartesian3.normalize( + away + ? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3()) + : Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const right = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + direction, + Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); + const frustum = new Cesium.PerspectiveFrustum({ + fov: Math.PI / 3, + aspectRatio: 800 / 600, + near: 1, + far: 1e8, + }); + Object.assign(env.viewer.camera, { + positionWC: position, + directionWC: direction, + upWC: up, + frustum, + }); + return frustum + .computeCullingVolume(position, direction, up) + .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); +} + +const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height }); +const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined }; + +for (const { label, globe, groundM, cameraM, southM, baseOffScreen } of [ + // 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height + // base is far below the bottom of the screen. + { label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, + { label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, + // A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the + // ellipsoid: the ellipsoidal horizon would hide the ground itself. + { label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false }, ]) { - test(`high ground in view is sampled with ${label} although its unsampled base projects off screen`, async (t) => { - // A base on 2,500 m terrain, seen from 3 km away at a shallow angle: its - // ellipsoid-height point is far below the bottom of the screen. + test(`${label} in view is sampled`, async (t) => { const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => 2_500, + sampleHeightSupported: true, sampleHeight: () => groundM, }); const clock = installFakeClock(t); t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); env.viewer.scene.globe = globe; - const entity = env.dataSources[0].entities.values[0]; - const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); - const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, 2_500); - const position = Cesium.Cartesian3.fromRadians( - base.longitude, - base.latitude - 3_000 / 6_371_000, - 2_500 + 1_000, - ); - const direction = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const right = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross( - direction, - Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); - const frustum = new Cesium.PerspectiveFrustum({ - fov: Math.PI / 3, - aspectRatio: 800 / 600, - near: 1, - far: 1e8, - }); - Object.assign(env.viewer.camera, { - positionWC: position, - directionWC: direction, - upWC: up, - frustum, - }); - const offScreen = frustum - .computeCullingVolume(position, direction, up) - .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); - assert.equal(offScreen, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); + const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM }); + if (baseOffScreen) + assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); clock.advance(2_100); env.preRender.raise(); assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled'); - assert.ok(Math.abs(baseHeightM(env) - 2_500) < 0.01); + assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01); }); } +test('under a hidden globe, ground behind the camera is not sampled', async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 2_500, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + env.viewer.scene.globe = hiddenGlobe; + aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true }); + clock.advance(2_100); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 0); +}); + test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true, sampleHeight: () => 117, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index ec8fc824e..596b78ed9 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1131,17 +1131,25 @@ function refreshLocalTerrainFloor(viewer, record) { return true; } -const groundSampleSphere = new Cesium.BoundingSphere( - new Cesium.Cartesian3(), - 1, -); - const groundSampleOccluder = new Cesium.EllipsoidalOccluder( Cesium.Ellipsoid.WGS84, ); const groundSampleCarto = new Cesium.Cartographic(); -const groundSampleTop = new Cesium.Cartesian3(); +const groundSampleLow = new Cesium.Cartesian3(); +const groundSampleHigh = new Cesium.Cartesian3(); + +/** The record's vertical at `height` metres above the ellipsoid. */ +function groundSamplePoint(record, height, result) { + groundSampleCarto.longitude = record.carto.longitude; + groundSampleCarto.latitude = record.carto.latitude; + groundSampleCarto.height = height; + return Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + result, + ); +} /** * Whether the camera can see the ground under the record: inside the view @@ -1150,10 +1158,10 @@ const groundSampleTop = new Cesium.Cartesian3(); * the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s * retry, each one a full offscreen render; 110 unseen data centres there held * rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test - * uses loaded globe terrain when there is some, and otherwise every height a - * sample can accept (0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M): high ground in - * view is never mistaken for an unseen base. Unseen ground is sampled on the - * first walk that sees it. + * uses loaded globe terrain when there is some. Otherwise (a hidden globe) it + * tests the vertical from 0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a + * sample can accept, so high ground in view is never mistaken for an unseen + * base. Unseen ground is sampled on the first walk that sees it. */ function groundSampleInView(viewer, record) { const camera = viewer.camera; @@ -1162,37 +1170,36 @@ function groundSampleInView(viewer, record) { const known = Number.isFinite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; - const span = GROUND_SAMPLE_MAX_ABS_HEIGHT_M / 2; - groundSampleCarto.longitude = record.carto.longitude; - groundSampleCarto.latitude = record.carto.latitude; - groundSampleCarto.height = known ? terrain : span; - Cesium.Cartographic.toCartesian( - groundSampleCarto, - Cesium.Ellipsoid.WGS84, - groundSampleSphere.center, - ); - groundSampleSphere.radius = known ? 1 : span; - const culling = camera.frustum.computeCullingVolume( + const low = groundSamplePoint(record, known ? terrain : 0, groundSampleLow); + const high = known + ? low + : groundSamplePoint( + record, + GROUND_SAMPLE_MAX_ABS_HEIGHT_M, + groundSampleHigh, + ); + // The vertical is unseen only when both ends are outside the same plane. + const { planes } = camera.frustum.computeCullingVolume( camera.positionWC, camera.directionWC, camera.upWC, ); - if ( - culling.computeVisibility(groundSampleSphere) === Cesium.Intersect.OUTSIDE - ) - return false; - // The highest ground the sample could find is the last to drop below the - // horizon. - if (!known) groundSampleCarto.height = GROUND_SAMPLE_MAX_ABS_HEIGHT_M; + for (const plane of planes) { + if ( + Cesium.Cartesian3.dot(plane, low) + plane.w < 0 && + Cesium.Cartesian3.dot(plane, high) + plane.w < 0 + ) + return false; + } + // The ellipsoidal occluder hides every point below the ellipsoid from a + // camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is + // tested at 0 m; unknown ground at the top of the vertical. + const horizonHeight = known + ? Math.max(terrain, 0) + : GROUND_SAMPLE_MAX_ABS_HEIGHT_M; groundSampleOccluder.cameraPosition = camera.positionWC; return groundSampleOccluder.isPointVisible( - known - ? groundSampleSphere.center - : Cesium.Cartographic.toCartesian( - groundSampleCarto, - Cesium.Ellipsoid.WGS84, - groundSampleTop, - ), + groundSamplePoint(record, horizonHeight, groundSampleHigh), ); }