diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..12a40da09 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Screen annotations reject hidden records and anchors wholly behind the camera or globe horizon before surface sampling. Unresolved visible heights share a 32-sample projection-pass budget, retry at most once per second per anchor on rendered frames, and retain no extra timer or listener. Unsettled points continue retrying as late terrain arrives; behind-camera, horizon and conservative frustum culls run before sampling. Validated heights remain settled in the bounded cache. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index c6c214fff..da06b3763 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -83,6 +83,9 @@ export function createScreenAnnotationRenderer( const heightCache = new Map(); const HEIGHT_CACHE_SOFT = 600; // target size; over this we evict keys not used this frame const HEIGHT_CACHE_HARD = 8000; // absolute ceiling (one big ring + others) — never exceeded + let cullingVolume = null; + const heightBounds = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 9500); + let heightBudget = 32; let projGen = 0; // bumped each projection frame; entries used this frame are "hot" function trimHeightCache() { if (heightCache.size <= HEIGHT_CACHE_SOFT) return; @@ -123,6 +126,13 @@ export function createScreenAnnotationRenderer( heightCache.set(key, cached); return cached.h; } + const now = Date.now(); + if (cached && now < cached.nextAttempt) { + cached.gen = projGen; + return cached.h; + } + if (heightBudget <= 0) return cached?.h || 0; + heightBudget--; try { if ( scene.clampToHeightSupported && @@ -144,7 +154,12 @@ export function createScreenAnnotationRenderer( /* tiles not ready */ } const fallback = cached ? cached.h : 0; - heightCache.set(key, { h: fallback, settled: false, gen: projGen }); + heightCache.set(key, { + h: fallback, + settled: false, + gen: projGen, + nextAttempt: now + 1000, + }); trimHeightCache(); return fallback; } @@ -362,6 +377,24 @@ export function createScreenAnnotationRenderer( // Anchor at the real surface height (cached + validated), so marks sit on // the ground/building instead of at sea level. The settle-once cache keeps // it stable (no per-frame jitter that earlier broke nearby projections). + // Cheap conservative endpoint tests precede GPU-backed surface sampling. + // Testing the accepted height range avoids culling an elevated roof solely + // because its sea-level anchor lies just beyond the horizon. + const visibleAt = (height) => { + const point = Cesium.Cartesian3.fromDegrees(lon, lat, height); + Cesium.Cartesian3.subtract(point, scene.camera.positionWC, scratchDir); + return ( + Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) > 0 && + occluder.isPointVisible(point) + ); + }; + Cesium.Cartesian3.fromDegrees(lon, lat, 0, undefined, heightBounds.center); + if ( + cullingVolume?.computeVisibility(heightBounds) === + Cesium.Intersect.OUTSIDE + ) + return null; + if (!visibleAt(0) && !visibleAt(9000)) return null; const world = Cesium.Cartesian3.fromDegrees( lon, lat, @@ -467,6 +500,13 @@ export function createScreenAnnotationRenderer( function projectAll() { if (!records.size) return; + heightBudget = 32; + cullingVolume = + scene.camera.frustum?.computeCullingVolume?.( + scene.camera.positionWC, + scene.camera.directionWC, + scene.camera.upWC, + ) || null; projGen += 1; // new frame: entries touched below are "hot" and survive trimming const trackedRect = trackedEntityRect(); occluder.cameraPosition = scene.camera.positionWC; diff --git a/src/annotations/screenAnnotationRenderer.test.mjs b/src/annotations/screenAnnotationRenderer.test.mjs index ef4509ed0..94615e831 100644 --- a/src/annotations/screenAnnotationRenderer.test.mjs +++ b/src/annotations/screenAnnotationRenderer.test.mjs @@ -152,10 +152,17 @@ test('outline upgrade preserves the existing SVG group identity', (t) => { }); const camera = { - positionWC: Cesium.Cartesian3.ZERO, - directionWC: Cesium.Cartesian3.ZERO, + positionWC: Cesium.Cartesian3.fromDegrees(-97.5, 31.2, 1000000), + directionWC: Cesium.Cartesian3.normalize( + Cesium.Cartesian3.negate( + Cesium.Cartesian3.fromDegrees(-97.5, 31.2), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), positionCartographic: { height: 1000 }, }; + t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, y: 100 })); const sampledAnchors = []; const scene = { camera, @@ -326,3 +333,94 @@ test('an add that throws after inserting its group unwinds instead of orphaning ); renderer.destroy(); }); + +test('hidden and back-facing annotations do not sample; visible unresolved heights have bounded retries', (t) => { + const previous = globalThis.document; + const previousRaf = globalThis.requestAnimationFrame; + globalThis.document = fakeDocument(); + globalThis.requestAnimationFrame = (fn) => { + fn(); + return 1; + }; + t.after(() => { + globalThis.document = previous; + globalThis.requestAnimationFrame = previousRaf; + }); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 }); + t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ + x: 100, + y: 100, + })); + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100000), + directionWC: new Cesium.Cartesian3(-1, 0, 0), + positionCartographic: { height: 100000 }, + moveEnd: new Cesium.Event(), + }; + let samples = 0; + const postRender = new Cesium.Event(); + const scene = { + camera, + canvas: { width: 800, height: 600 }, + postRender, + clampToHeightSupported: true, + clampToHeight() { + samples++; + }, + requestRender() {}, + }; + const renderer = createScreenAnnotationRenderer({ scene, camera }); + const mark = (id, lon) => ({ + id, + type: 'label', + anchor: { lon, lat: 0 }, + label: id, + }); + renderer.add(mark('far-side', 180)); + for (let i = 0; i < 100; i++) postRender.raiseEvent(); + assert.equal(samples, 0); + renderer.add(mark('near-side', 0)); + for (let i = 0; i < 100; i++) postRender.raiseEvent(); + assert.equal(samples, 1); + for (let i = 0; i < 10; i++) { + t.mock.timers.tick(1000); + postRender.raiseEvent(); + } + assert.equal(samples, 11); + camera.moveEnd.raiseEvent(); + postRender.raiseEvent(); + assert.equal(samples, 11, 'camera events cannot bypass the retry interval'); + renderer.update(mark('near-side', 0), { empty: true }); + t.mock.timers.tick(1000); + postRender.raiseEvent(); + assert.equal(samples, 11, 'hidden records do not sample'); + camera.directionWC = new Cesium.Cartesian3(1, 0, 0); + renderer.update(mark('near-side', 0)); + postRender.raiseEvent(); + assert.equal(samples, 11, 'behind-camera anchors do not sample'); + camera.directionWC = new Cesium.Cartesian3(-1, 0, 0); + camera.frustum = { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.OUTSIDE, + }), + }; + postRender.raiseEvent(); + assert.equal(samples, 11, 'outside-frustum anchors do not sample'); + camera.frustum = null; + const before = samples; + renderer.add({ + id: 'large-area', + type: 'area', + label: 'Area', + ring: Array.from({ length: 100 }, (_, i) => [0.001 + i / 10000, 0]), + anchor: { lon: 0, lat: 0 }, + }); + assert.equal( + samples - before, + 32, + 'a projection pass caps synchronous surface work', + ); + renderer.destroy(); + assert.equal(camera.moveEnd.numberOfListeners, 0); + assert.equal(postRender.numberOfListeners, 0); +});