diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 09ae87f1a..e5202e55c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,7 +1959,9 @@ 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 and uses valid loaded terrain as a floor while +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 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 13cde65a5..89de41529 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -90,6 +90,8 @@ async function createRealLocalLayerHarness({ globalThis.window = { dispatchEvent() {} }; let sampleHeightImpl = sampleHeight; const sampleCalls = { count: 0 }; + // The camera frustum, reduced to the one answer ground sampling asks of it. + let baseVisibility = Cesium.Intersect.INSIDE; const overlayHost = { setVisible: (...args) => hostCalls.push(['visible', ...args]), setEntries: (...args) => hostCalls.push(['entries', ...args]), @@ -107,7 +109,14 @@ async function createRealLocalLayerHarness({ }, camera: { positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000), - frustum: { fov: Math.PI / 3 }, + directionWC: new Cesium.Cartesian3(), + upWC: new Cesium.Cartesian3(), + frustum: { + fov: Math.PI / 3, + computeCullingVolume: () => ({ + computeVisibility: () => baseVisibility, + }), + }, moveEnd, flyTo() {}, }, @@ -151,6 +160,10 @@ async function createRealLocalLayerHarness({ sampleCalls, /** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */ setSampleHeight(next) { sampleHeightImpl = next; }, + /** Put the feature's base inside (true) or outside the camera frustum. */ + setBaseInView(inView) { + baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE; + }, cleanup() { if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; @@ -736,6 +749,44 @@ test('ground sampling waits for visible globe tiles before caching a height', as assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); +test('an unseen base is not sampled and arms no retry until it is in view', async (t) => { + // Out of view the scene has no surface under the base, so the depth sample + // would pass through the Earth (about -6,344 km) and fail on every retry. + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 117, + }); + _resetRenderGovernorForTest(); + const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; + installRenderGovernor({ scene: governorScene }); + governorScene.renders = 0; // discard the governor's own install settling frame + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + _resetRenderGovernorForTest(); + }); + env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 }; + setCameraAltitude(env, 20_000); + env.setBaseInView(false); + runArmedRetryChain(env, t, clock, 5); + assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled'); + assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering'); + assert.ok(Math.abs(baseHeightM(env)) < 0.01); + + // In the frustum but over the horizon: 50 m up, about 67 km away. + env.setBaseInView(true); + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50); + runArmedRetryChain(env, t, clock, 5); + assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled'); + assert.equal(governorScene.renders, 0); + + setCameraAltitude(env, 20_000); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it'); + assert.ok(Math.abs(baseHeightM(env) - 117) < 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 1d04fa46c..9d97df61c 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1000,9 +1000,9 @@ export function createLocalGeoJsonLayer( // governor the retry's preRender never arrives on a parked camera // and the stem stays at ellipsoid height (buried/floating) until // the user happens to move. Schedule the frame the retry needs. - // Gated on a sampleable scene and in-range records only, so a far - // camera (or a keyless scene) stays fully idle; the distance is - // only computed for still-unsampled stems. + // Gated on a sampleable scene and in-range, in-view records only, + // so a far camera (or a keyless scene) stays fully idle; the + // distance is only computed for still-unsampled stems. if ( canSampleGround && !record.groundSampled && @@ -1010,7 +1010,8 @@ export function createLocalGeoJsonLayer( Cesium.Cartesian3.distance( viewer.camera.positionWC, record.base, - ) < GROUND_SAMPLE_MAX_DISTANCE_M + ) < GROUND_SAMPLE_MAX_DISTANCE_M && + groundSampleInView(viewer, record) ) { groundRetryPending = true; } @@ -1130,9 +1131,44 @@ function refreshLocalTerrainFloor(viewer, record) { return true; } +const groundSampleSphere = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1, +); + +const groundSampleOccluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, +); + +/** + * 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. + */ +function groundSampleInView(viewer, record) { + const camera = viewer.camera; + 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; + groundSampleOccluder.cameraPosition = camera.positionWC; + return groundSampleOccluder.isPointVisible(record.base); +} + function sampleLocalGroundHeight(viewer, record, now) { if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; + if (!groundSampleInView(viewer, record)) return false; record.lastGroundSampleMs = now; const globe = viewer.scene.globe; if (globe?.show && globe.tilesLoaded === false) return false;