diff --git a/changelog.d/1464-infrastructure-ground-sample-in-view.md b/changelog.d/1464-infrastructure-ground-sample-in-view.md index 8ab5d1d88..b563c86b4 100644 --- a/changelog.d/1464-infrastructure-ground-sample-in-view.md +++ b/changelog.d/1464-infrastructure-ground-sample-in-view.md @@ -1,4 +1,4 @@ -- Ground-sample Data Centers and Dams markers only when the camera sees their - base. An off-screen base read through the Earth and retried every 2 s, which - held the page near 2 s a frame over Ashburn, Virginia on the Esri map +- Ground-sample Data Centers and Dams markers only when the camera sees the + ground under them. Unseen ground read through the Earth and retried every + 2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s (quanticsoul4772, #1464). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e5202e55c..065d67112 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,9 +1959,11 @@ 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 base is in the -camera's view and above the horizon (an unseen base reads through the Earth -and would retry every 2 s), and uses valid loaded terrain as a floor while +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 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 89de41529..1c47cb8c1 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -787,6 +787,64 @@ 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 }], +]) { + 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. + 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 = 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'); + + 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); + }); +} + 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 9d97df61c..ec8fc824e 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1140,29 +1140,60 @@ const groundSampleOccluder = new Cesium.EllipsoidalOccluder( Cesium.Ellipsoid.WGS84, ); +const groundSampleCarto = new Cesium.Cartographic(); +const groundSampleTop = new Cesium.Cartesian3(); + /** - * Whether the camera sees the record's base: inside the view frustum and - * above the horizon. scene.sampleHeight reads only surface rendered for the - * current view, so a sample of an unseen base passes through 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 the page near 2 s - * a frame indefinitely. An unseen base keeps its stem at the unsampled height - * and is sampled on the first walk that sees it. + * Whether the camera can see the ground under the record: inside the view + * frustum and above the horizon. scene.sampleHeight reads only surface + * rendered for the current view, so a sample of unseen ground passes through + * 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. */ function groundSampleInView(viewer, record) { const camera = viewer.camera; + const globe = viewer.scene.globe; + const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined; + 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( camera.positionWC, camera.directionWC, camera.upWC, ); - Cesium.Cartesian3.clone(record.base, groundSampleSphere.center); 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; groundSampleOccluder.cameraPosition = camera.positionWC; - return groundSampleOccluder.isPointVisible(record.base); + return groundSampleOccluder.isPointVisible( + known + ? groundSampleSphere.center + : Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + groundSampleTop, + ), + ); } function sampleLocalGroundHeight(viewer, record, now) {