diff --git a/CHANGELOG.md b/CHANGELOG.md index 02af2c6e2..b51d8c8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Unchanged local infrastructure overlays no longer sustain idle rendering. + Ground samples wait for visible terrain to settle and cannot place a marker + below its loaded surface; roofs and valid below-sea-level heights are retained. + - Datacenter and dam marker stems use bounded, zoom-dependent active sets with stable selection during camera motion. Close-up stems scale to the actual camera distance; source totals and submarine cables remain unchanged. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a4f1fe706..8e7d65622 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -8,6 +8,11 @@ and 420 below 200 km. Selection favors existing label priority and nearby features, retains stable choices across small camera movements, and refreshes 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 +retaining rooftop and below-sea-level elevations. A hidden globe does not +constrain photoreal geometry, and retry work remains bounded. The submarine-cable renderer is unchanged. These limits bound active stem work; they do not reduce the materialized entity count or establish an FPS gain. diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs index be78b2c89..5be19fe64 100644 --- a/scripts/qa-infra-lod.mjs +++ b/scripts/qa-infra-lod.mjs @@ -296,12 +296,27 @@ try { // ── parked-idle honesty: the LOD walk must not force continuous render ─ await new Promise((r) => setTimeout(r, 4_000)); - const idle = await page.evaluate(() => new Promise((resolve) => { - const scene = window.__godsEyeView.viewer.scene; - let renders = 0; - const remove = scene.postRender.addEventListener(() => { renders += 1; }); - setTimeout(() => { remove(); resolve({ renders }); }, 5_000); - })); + const idle = await page.evaluate(async () => { + const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js'); + return new Promise((resolve) => { + const scene = window.__godsEyeView.viewer.scene; + const startedAt = Date.now(); + const tilesLoadedBefore = scene.globe.tilesLoaded; + let renders = 0; + let framesWithPendingTiles = 0; + const remove = scene.postRender.addEventListener(() => { + renders += 1; + if (!scene.globe.tilesLoaded) framesWithPendingTiles += 1; + }); + setTimeout(() => { + remove(); + const governor = getRenderGovernorDiagnostics(); + resolve({ renders, tilesLoadedBefore, tilesLoadedAfter: scene.globe.tilesLoaded, + framesWithPendingTiles, mode: governor.mode, holds: governor.holds, + requests: governor.recentRequests.filter(request => request.at >= startedAt) }); + }, 5_000); + }); + }); report(CONTROL ? 'parked idle, control (postRender fires / 5s)' : 'parked idle with infra ON (postRender fires / 5s)', idle); diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js index aba4b7ee5..b32fef1f7 100644 --- a/src/data/localGeojson.js +++ b/src/data/localGeojson.js @@ -225,6 +225,11 @@ export function createLocalInfrastructureOverlayPublisher({ let visible = false; let published = false; let destroyed = false; + // Local entries have immutable metadata and a mutable Cartesian position. + // Snapshot coordinates: retaining only the entry reference would miss a + // stem tip moving in place. Republishing an unchanged cohort invalidates + // the host and can sustain a render loop when frames exceed the 450 ms walk. + let lastPublication = null; const sourceOptions = { cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, collisionCapacity: LOCAL_OVERLAY_COLLISION_CAPACITY, @@ -239,7 +244,18 @@ export function createLocalInfrastructureOverlayPublisher({ }, publish(entries) { if (destroyed || !visible) return; + if (lastPublication && entries.length === lastPublication.length + && entries.every((entry, index) => { + const previous = lastPublication[index]; + return entry === previous.entry + && entry.position?.x === previous.x + && entry.position?.y === previous.y + && entry.position?.z === previous.z; + })) return; host.setEntries(sourceId, entries, sourceOptions); + lastPublication = entries.map(entry => ({ + entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z, + })); published = entries.length > 0; }, hide() { @@ -248,6 +264,7 @@ export function createLocalInfrastructureOverlayPublisher({ if (visible) host.setVisible(sourceId, false); visible = false; published = false; + lastPublication = null; }, destroy() { if (destroyed) return; @@ -255,6 +272,7 @@ export function createLocalInfrastructureOverlayPublisher({ if (visible) host.setVisible(sourceId, false); visible = false; published = false; + lastPublication = null; destroyed = true; }, }; @@ -897,6 +915,8 @@ function sampleLocalGroundHeight(viewer, record, now) { if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; record.lastGroundSampleMs = now; + const globe = viewer.scene.globe; + if (globe?.show && globe.tilesLoaded === false) return false; let sampled; try { sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); @@ -904,6 +924,14 @@ function sampleLocalGroundHeight(viewer, record, now) { return false; // tiles not ready; retry on a later bounded update } if (!Number.isFinite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) return false; + // A coarse scene-depth sample can lie below the terrain already loaded by + // the visible globe. Keep that terrain as a floor while allowing roofs and + // valid below-sea-level elevations. Photoreal scenes hide the globe, so its + // inactive terrain must not constrain their geometry. + const terrainHeight = globe?.show ? globe.getHeight?.(record.carto) : undefined; + if (Number.isFinite(terrainHeight) && Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M) { + sampled = Math.max(sampled, terrainHeight); + } record.groundSampled = true; record.groundHeight = sampled; Cesium.Cartesian3.fromRadians( diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 3b543954a..0fd84f24d 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -314,6 +314,33 @@ test('local overlay publisher owns add/remove/visibility lifecycle and becomes i assert.deepEqual(calls[7], ['visible', 'local-datacenters', false]); }); +test('slow parked frames do not keep republishing the same local overlay cohort', async (t) => { + const env = await createRealLocalLayerHarness(); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + const publications = () => env.hostCalls.filter(([type]) => type === 'entries'); + env.preRender.raise(); + assert.equal(publications().length, 1); + assert.ok(publications()[0][2].length > 0, 'the initial cohort must be populated'); + for (let i = 0; i < 6; i++) { + clock.advance(1_000); // Every slow frame passes the 450 ms source throttle. + env.preRender.raise(); + } + assert.equal(publications().length, 1, + 'unchanged publication invalidates the real host and requests another slow frame'); + + const camera = env.viewer.camera.positionWC; + env.viewer.camera.positionWC = Cesium.Cartesian3.add(camera, + new Cesium.Cartesian3(10_000, 0, 0), new Cesium.Cartesian3()); + env.moveEnd.raise(); + env.preRender.raise(); + assert.equal(publications().length, 2, 'changed stem positions must still reach the host'); + env.layer.disable(env.viewer); + await env.layer.enable(env.viewer); + env.preRender.raise(); + assert.equal(publications().length, 3, 're-enable must republish after clearing the host'); +}); + test('real layer disable clears its published host entries and balances settle listeners', async () => { const env = await createRealLocalLayerHarness(); env.preRender.raise(); @@ -683,6 +710,44 @@ function governorReasons() { return getRenderGovernorDiagnostics().recentRequests.map((entry) => entry.reason); } +test('ground sampling waits for visible globe tiles before caching a height', async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 117, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + env.viewer.scene.globe = { show: true, tilesLoaded: false, getHeight: () => 117 }; + setCameraAltitude(env, 20_000); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 0, 'streaming terrain must not become a permanent coarse sample'); + assert.ok(Math.abs(baseHeightM(env)) < 0.01); + env.viewer.scene.globe.tilesLoaded = true; + clock.advance(2_100); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 1, 'the existing retry must sample the settled scene'); + assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); +}); + +for (const [terrainHeight, sampledHeight, expectedHeight, globeShown] of [ + [117, -2238, 117, true], + [-400, -410, -400, true], + [117, 180, 180, true], + [117, -20, -20, false], +]) { + test(`ground sampling respects terrain ${terrainHeight} and geometry ${sampledHeight} with globe ${globeShown}`, async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => sampledHeight, + }); + installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + env.viewer.scene.globe = { show: globeShown, getHeight: () => terrainHeight }; + setCameraAltitude(env, 20_000); + env.preRender.raise(); + assert.ok(Math.abs(baseHeightM(env) - expectedHeight) < 0.01, + `base ${baseHeightM(env)} must respect the visible terrain without flattening roofs or below-sea-level terrain`); + }); +} + test('a failed ground sample schedules the retry frame the idle governor would never produce', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); diff --git a/src/data/localGeojsonLod.test.mjs b/src/data/localGeojsonLod.test.mjs index fcbdc754f..c492b0404 100644 --- a/src/data/localGeojsonLod.test.mjs +++ b/src/data/localGeojsonLod.test.mjs @@ -58,7 +58,7 @@ test('infraLodBudget band boundaries are inclusive at the lower edge', () => { assert.deepEqual(infraLodBudget(INFRA_LOD_REGIONAL_HEIGHT_M - 1), { activeLimit: INFRA_LOD_ACTIVE_MAX }); }); -test('infraLodBudget treats non-finite / negative height as the global (cheapest) band', () => { +test('infraLodBudget uses the global band for non-finite height and clamps negative height to zero', () => { assert.deepEqual(infraLodBudget(undefined), { activeLimit: INFRA_LOD_ACTIVE_MIN }); assert.deepEqual(infraLodBudget(NaN), { activeLimit: INFRA_LOD_ACTIVE_MIN }); assert.deepEqual(infraLodBudget(Number.POSITIVE_INFINITY), { activeLimit: INFRA_LOD_ACTIVE_MIN });