Compare commits

...
Author SHA1 Message Date
Said Turksever 8767eb23ab Merge remote-tracking branch 'upstream/main' into pr1277 2026-11-11 13:53:03 +00:00
Said Turksever b90b5e30e0 Merge remote-tracking branch 'upstream/main' into pr1277 2026-11-10 23:54:40 +00:00
Said Turksever 0781b4a8de fix(annotations): repair the main merge and port the sampling tests (#1277)
The last merge of main kept four tests on a DOM harness that main had
removed, so they threw ReferenceError and CI failed. They now run on the
store-backed board. The merge had also dropped main's empty-board publish,
the leaving/byKey clear in destroy, and three unrelated CURRENT-STATE
paragraphs; all are restored. The projection now builds each height-cache
key once per point, and the changelog entry says what changed.
2026-11-10 23:54:40 +00:00
Said Turksever e0bc863a69 Merge branch 'main' into codex/fix-annotation-height-culling 2026-11-10 23:38:30 +00:00
Said TurkseverandClaude Opus 5.5 aa4e0ca936 Fix annotation height culling review findings (#1277)
- Restrict the conservative frustum test to height sampling only: an
  endpoint outside it still projects at its cached or zero height, so an
  arrow with one off-frustum endpoint keeps its visible connector and label.
- Test a settled height as the actual point; reject an unknown height
  before sampling only when both ends of the accepted -430..9000 m range
  fail the same constraint (behind camera, or below horizon), which the
  convexity of each rejection region makes a proof for the whole range.
- Queue keys refused by an exhausted budget and reserve the next pass's
  budget for the oldest of them, so anchors beyond the first 32 are served
  under sparse passes while keeping 32 samples per pass and 1 s retries.
- Add regressions with a real frustum, a low-angle authored camera, and
  33 persistent points under sparse passes; consolidate CURRENT-STATE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:30:39 +01:00
Said 8551c5ed4f Merge remote-tracking branch 'origin/main' into fix/1277
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:18 +01:00
Said TurkseverandClaude Opus 5.5 00a3b8e29d Merge branch 'main' into codex/fix-annotation-height-culling
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:41 +01:00
Said f2b4162d76 docs: record annotation fix 2026-10-20 01:07:09 +01:00
Said ad003118c5 fix(annotations): cull and budget surface height sampling 2026-10-20 01:06:53 +01:00
4 changed files with 413 additions and 12 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- Stop screen annotations sampling surface height for marks that are hidden,
behind the camera or over the horizon. Visible marks still waiting for
terrain share 32 samples per projection pass and retry once a second, so a
large board no longer stalls the frame (asturksever, #1277).
+10
View File
@@ -145,6 +145,16 @@ voice follow-ups match on them and bikeshare uses the raw station name as its
ID. The text is still untrusted: normalization does not prevent prompt
injection.
Screen annotations skip hidden records and, before surface sampling, reject an
unsettled anchor only when its whole accepted height range (-430 to 9000 m) is
behind the camera or below the globe horizon; a settled height is tested as the
actual point. Anchors outside the conservative frustum still project at their
cached or zero height, so a partly visible arrow keeps its connector, but do
not sample. Unresolved visible heights share a 32-sample projection-pass budget,
served oldest-refused first, retry at most once per second per anchor on
rendered frames, and retain no extra timer or listener. Validated heights remain
settled in the bounded cache.
Terrain height requests accept at most 64 points, matching the browser service's
existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
+113 -10
View File
@@ -100,6 +100,20 @@ 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
// Accepted surface-height range; samples outside it are treated as misses.
const MIN_SURFACE_HEIGHT = -430;
const MAX_SURFACE_HEIGHT = 9000;
const HEIGHT_SAMPLE_BUDGET = 32; // clampToHeight calls per projection pass
const HEIGHT_RETRY_MS = 1000; // minimum interval between misses per anchor
let cullingVolume = null;
const heightBounds = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 9500);
// Fair sampling: keys refused by an exhausted budget queue in arrival order,
// and each pass reserves budget for the oldest queued keys first, so a fixed
// iteration order cannot starve anchors beyond the first budget's worth.
let heightQueue = new Set(); // keys refused in earlier passes, oldest first
let heightReserved = new Set(); // queued keys this pass serves first
let heightRefused = new Set(); // keys refused during this pass
let heightBudget = HEIGHT_SAMPLE_BUDGET; // unreserved slots left this pass
let projGen = 0; // bumped each projection frame; entries used this frame are "hot"
function trimHeightCache() {
if (heightCache.size <= HEIGHT_CACHE_SOFT) return;
@@ -127,11 +141,20 @@ export function createScreenAnnotationRenderer(
return PALETTE[anno.color] || PALETTE.primary;
}
function clearHeights() {
heightCache.clear();
heightQueue.clear();
}
function heightKey(lon, lat) {
return `${lon.toFixed(5)},${lat.toFixed(5)}`;
}
// Best-effort surface height under a coordinate (clamps onto the 3D tiles).
// Returns 0 until the tile loads, then caches the validated height.
function groundHeight(lon, lat) {
const key = `${lon.toFixed(5)},${lat.toFixed(5)}`;
const cached = heightCache.get(key);
// canSample=false returns the cached height (or 0) without surface sampling.
// `key` and `cached` are the caller's cache lookup for this coordinate.
function groundHeight(lon, lat, key, cached, canSample = true) {
if (cached && cached.settled) {
// Mark hot for this frame + bump to most-recently-used so heights for live
// marks survive eviction while places we've navigated away from age out.
@@ -140,6 +163,20 @@ export function createScreenAnnotationRenderer(
heightCache.set(key, cached);
return cached.h;
}
const now = Date.now();
if (cached && (!canSample || now < cached.nextAttempt)) {
cached.gen = projGen;
return cached.h;
}
if (!canSample) return 0;
if (heightReserved.has(key)) {
heightReserved.delete(key);
} else if (heightBudget > 0) {
heightBudget--;
} else {
heightRefused.add(key);
return cached?.h || 0;
}
try {
if (
scene.clampToHeightSupported &&
@@ -150,7 +187,11 @@ export function createScreenAnnotationRenderer(
);
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) {
if (
Number.isFinite(h) &&
h > MIN_SURFACE_HEIGHT &&
h < MAX_SURFACE_HEIGHT
) {
heightCache.set(key, { h, settled: true, gen: projGen });
trimHeightCache();
return h;
@@ -161,7 +202,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 + HEIGHT_RETRY_MS,
});
trimHeightCache();
return fallback;
}
@@ -307,7 +353,7 @@ export function createScreenAnnotationRenderer(
} catch {
/* the board is unreachable */
}
if (records.size === 0) heightCache.clear();
if (records.size === 0) clearHeights();
throw error;
}
}
@@ -363,11 +409,32 @@ 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).
const world = Cesium.Cartesian3.fromDegrees(
// A settled height is tested as the actual point below. An unknown height
// is rejected before GPU-backed surface sampling only when the whole
// accepted height range is proven hidden by one constraint.
const key = heightKey(lon, lat);
const cached = heightCache.get(key);
let height;
if (cached?.settled) {
height = groundHeight(lon, lat, key, cached);
} else {
if (heightRangeHidden(lon, lat)) return null;
// The frustum restricts sampling only; an endpoint outside it still
// projects (at its cached or zero height) so a partly visible arrow,
// route or ring keeps its on-screen connector and label.
Cesium.Cartesian3.fromDegrees(
lon,
lat,
groundHeight(lon, lat),
0,
undefined,
heightBounds.center,
);
const inFrustum =
cullingVolume?.computeVisibility(heightBounds) !==
Cesium.Intersect.OUTSIDE;
height = groundHeight(lon, lat, key, cached, inFrustum);
}
const world = Cesium.Cartesian3.fromDegrees(lon, lat, height);
// Reject points behind the camera (they produce wrapped/extreme coords).
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
@@ -391,6 +458,21 @@ export function createScreenAnnotationRenderer(
return { x: win.x, y: win.y };
}
// Points along one surface normal are linear in height, and each rejection
// region (behind the camera plane; the occluder's cone beyond the horizon
// plane) is convex. So when both height-range endpoints fail the SAME test,
// every intermediate height fails it too. Mixed failures prove nothing.
function heightRangeHidden(lon, lat) {
const low = Cesium.Cartesian3.fromDegrees(lon, lat, MIN_SURFACE_HEIGHT);
const high = Cesium.Cartesian3.fromDegrees(lon, lat, MAX_SURFACE_HEIGHT);
const behind = (point) => {
Cesium.Cartesian3.subtract(point, scene.camera.positionWC, scratchDir);
return Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0;
};
if (behind(low) && behind(high)) return true;
return !occluder.isPointVisible(low) && !occluder.isPointVisible(high);
}
// Tracked-entity z-order: the tracked aircraft is a Cesium billboard in the
// CANVAS, which sits BELOW this SVG overlay (z-90, HTML over canvas). So a screen mark
// could hide it. After layout, any mark whose ACTUAL projected bounding box (rings,
@@ -475,6 +557,20 @@ export function createScreenAnnotationRenderer(
publish();
return;
}
// Serve the oldest refused keys first; the rest of the budget is open.
heightReserved = new Set();
for (const key of heightQueue) {
if (heightReserved.size >= HEIGHT_SAMPLE_BUDGET) break;
heightReserved.add(key);
}
heightBudget = HEIGHT_SAMPLE_BUDGET - heightReserved.size;
heightRefused = new Set();
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;
@@ -632,6 +728,13 @@ export function createScreenAnnotationRenderer(
}
}
}
// Still-refused queued keys keep their place; new refusals join the back.
// Keys no longer requesting a sample (settled, culled, removed) drop out.
const nextQueue = new Set();
for (const key of heightQueue)
if (heightRefused.has(key)) nextQueue.add(key);
for (const key of heightRefused) nextQueue.add(key);
heightQueue = nextQueue;
// Keep clustered callouts readable.
const placed = [];
@@ -811,7 +914,7 @@ export function createScreenAnnotationRenderer(
records.delete(anno.id);
publish();
// Board emptied → drop accumulated height samples (natural reset point).
if (records.size === 0) heightCache.clear();
if (records.size === 0) clearHeights();
}
function sync() {
@@ -834,7 +937,7 @@ export function createScreenAnnotationRenderer(
records.clear();
leaving.clear();
byKey.clear();
heightCache.clear();
clearHeights();
}
// Claim the board; from here on only this renderer's own board changes.
@@ -166,10 +166,17 @@ test('an arrow defines its arrowhead and drops the dash once drawn on', async (t
test('outline upgrade preserves the existing SVG group identity', async (t) => {
const { store, svg, groups } = board(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 = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
@@ -575,3 +582,280 @@ test('a mark hidden from Draw is not drawn or placed, and comes back when shown'
await settle();
assert.equal(drawn(), true, 'shown again, it is drawn');
});
test('hidden and back-facing annotations do not sample; visible unresolved heights have bounded retries', (t) => {
const store = samplingBoard(t);
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 = samplingRenderer(store, scene);
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);
});
function sampledLon(world) {
const carto = Cesium.Cartographic.fromCartesian(world);
return Number(Cesium.Math.toDegrees(carto.longitude).toFixed(5));
}
/**
* A board with no view mounted, for tests that count surface samples: the
* renderer projects on `add` and on each post-render event, and the store
* holds what it would draw. Animation frames run at once.
*/
function samplingBoard(t) {
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => {
fn();
return 1;
};
t.after(() => {
globalThis.requestAnimationFrame = previousRaf;
});
return useScreenAnnotationsStore(createPinia());
}
/** The renderer under test, on the given board and scene. */
function samplingRenderer(store, scene) {
return createScreenAnnotationRenderer(
{ scene, camera: scene.camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
}
function squareFrustum() {
const frustum = new Cesium.PerspectiveFrustum();
frustum.fov = Cesium.Math.toRadians(60);
frustum.aspectRatio = 1;
frustum.near = 1;
frustum.far = 1e8;
return frustum;
}
test('an arrow with one endpoint outside a real frustum keeps its visible connector', (t) => {
const store = samplingBoard(t);
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', (_scene, world) =>
sampledLon(world) > 0.1 ? { x: 1100, y: 400 } : { x: 400, y: 400 },
);
// 5 km straight down over (0, 0): east is +Y, north is +Z in ECEF here.
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 5000),
directionWC: new Cesium.Cartesian3(-1, 0, 0),
upWC: new Cesium.Cartesian3(0, 0, 1),
positionCartographic: { height: 5000 },
frustum: squareFrustum(),
};
const sampled = [];
const postRender = new Cesium.Event();
const scene = {
camera,
canvas: { width: 800, height: 800 },
postRender,
clampToHeightSupported: true,
clampToHeight(world) {
sampled.push(sampledLon(world));
return world;
},
};
// The 0.2-degree endpoint (~22 km east) is well beyond the side plane plus
// the 9.5 km height-bound radius, yet in front of the camera and the horizon.
const far = Cesium.Cartesian3.fromDegrees(0.2, 0, 0);
const volume = camera.frustum.computeCullingVolume(
camera.positionWC,
camera.directionWC,
camera.upWC,
);
assert.equal(
volume.computeVisibility(new Cesium.BoundingSphere(far, 9500)),
Cesium.Intersect.OUTSIDE,
);
const renderer = samplingRenderer(store, scene);
renderer.add({
id: 'partial-arrow',
type: 'arrow',
label: 'Ridge',
anchor: { lon: 0, lat: 0 },
to: { lon: 0.2, lat: 0 },
});
postRender.raiseEvent();
const [mark] = store.board.marks;
assert.equal(mark.display, '', 'the arrow stays drawn');
const path = mark.children.find((child) => child.attrs.class?.includes('gev-anno-arrow'));
assert.match(path.attrs.d, /^M 400\.0 400\.0 Q .* 1100\.0 400\.0$/);
assert.deepEqual([...new Set(sampled)], [0], 'only the in-frustum endpoint samples');
renderer.destroy();
});
test('a low-angle cached roof stays visible when range endpoints fail different tests', (t) => {
const store = samplingBoard(t);
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 380 }));
// Camera 1 m above the ellipsoid, heading east, pitched 25 degrees down.
const pitch = Cesium.Math.toRadians(25);
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1),
directionWC: new Cesium.Cartesian3(-Math.sin(pitch), Math.cos(pitch), 0),
upWC: new Cesium.Cartesian3(Math.cos(pitch), Math.sin(pitch), 0),
positionCartographic: { height: 1 },
frustum: squareFrustum(),
};
const roofLon = 4000 / 111319.49;
let samples = 0;
const postRender = new Cesium.Event();
const scene = {
camera,
canvas: { width: 800, height: 800 },
postRender,
clampToHeightSupported: true,
clampToHeight() {
samples++;
return Cesium.Cartesian3.fromDegrees(roofLon, 0, 20);
},
};
// Sanity: height 0 is beyond the horizon and 9000 m is behind the camera,
// while the 20 m roof itself is in front and above the horizon.
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.positionWC);
const ahead = (h) => {
const p = Cesium.Cartesian3.fromDegrees(roofLon, 0, h);
const v = Cesium.Cartesian3.subtract(p, camera.positionWC, new Cesium.Cartesian3());
return Cesium.Cartesian3.dot(v, camera.directionWC) > 0;
};
assert.equal(occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(roofLon, 0, 0)), false);
assert.equal(ahead(9000), false);
assert.equal(ahead(20), true);
assert.equal(occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(roofLon, 0, 20)), true);
const renderer = samplingRenderer(store, scene);
renderer.add({ id: 'roof', type: 'label', label: 'Roof', anchor: { lon: roofLon, lat: 0 } });
assert.equal(samples, 1, 'the unknown height is sampled, not culled');
assert.equal(store.board.marks[0].display, '');
for (let i = 0; i < 5; i++) postRender.raiseEvent();
assert.equal(samples, 1, 'the settled height is reused');
assert.equal(store.board.marks[0].display, '', 'the cached roof point is tested and shown');
renderer.destroy();
});
test('height retries beyond the first budget are served under sparse passes', (t) => {
const store = samplingBoard(t);
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 },
};
const lons = Array.from({ length: 33 }, (_, i) => Number(((i + 1) / 1000).toFixed(3)));
const last = lons.at(-1);
let tilesReady = false;
let pass = [];
const postRender = new Cesium.Event();
const scene = {
camera,
canvas: { width: 800, height: 600 },
postRender,
clampToHeightSupported: true,
clampToHeight(world) {
const lon = sampledLon(world);
pass.push(lon);
return tilesReady && lon === last ? Cesium.Cartesian3.fromDegrees(lon, 0, 12) : undefined;
},
};
const renderer = samplingRenderer(store, scene);
for (const lon of lons) {
renderer.add({ id: `p${lon}`, type: 'label', label: 'P', anchor: { lon, lat: 0 } });
}
assert.ok(pass.includes(last), 'every point gets its initial miss');
tilesReady = true; // tiles under the final point arrive after its miss
const lastSamples = [];
for (let i = 0; i < 4; i++) {
t.mock.timers.tick(1000);
pass = [];
postRender.raiseEvent();
assert.ok(pass.length <= 32, `pass ${i} keeps the 32-sample budget`);
lastSamples.push(pass.filter((lon) => lon === last).length);
}
assert.ok(lastSamples.slice(0, 2).includes(1), 'the 33rd point is served within two passes');
assert.equal(lastSamples.at(-1), 0, 'and then stays settled');
// Points refused in one pass are served first in the next: every point is
// retried within any two consecutive sparse passes.
pass = [];
t.mock.timers.tick(1000);
postRender.raiseEvent();
const firstPass = pass;
pass = [];
t.mock.timers.tick(1000);
postRender.raiseEvent();
const served = new Set([...firstPass, ...pass]);
for (const lon of lons.slice(0, -1)) assert.ok(served.has(lon), `${lon} retried`);
renderer.destroy();
});