mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4e0ca936 | ||
|
|
8551c5ed4f | ||
|
|
00a3b8e29d | ||
|
|
f2b4162d76 | ||
|
|
ad003118c5 |
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
- Cull and budget surface height sampling (asturksever, #1277).
|
||||||
@@ -9,6 +9,16 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
|||||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||||
search keeps a successful geocode, with no search or view-based region recovery.
|
search keeps a successful geocode, with no search or view-based region recovery.
|
||||||
|
|
||||||
|
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
|
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
|
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||||
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
|
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ export function createScreenAnnotationRenderer(
|
|||||||
const heightCache = new Map();
|
const heightCache = new Map();
|
||||||
const HEIGHT_CACHE_SOFT = 600; // target size; over this we evict keys not used this frame
|
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
|
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"
|
let projGen = 0; // bumped each projection frame; entries used this frame are "hot"
|
||||||
function trimHeightCache() {
|
function trimHeightCache() {
|
||||||
if (heightCache.size <= HEIGHT_CACHE_SOFT) return;
|
if (heightCache.size <= HEIGHT_CACHE_SOFT) return;
|
||||||
@@ -110,10 +124,20 @@ export function createScreenAnnotationRenderer(
|
|||||||
return PALETTE[anno.color] || PALETTE.primary;
|
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).
|
// Best-effort surface height under a coordinate (clamps onto the 3D tiles).
|
||||||
// Returns 0 until the tile loads, then caches the validated height.
|
// Returns 0 until the tile loads, then caches the validated height.
|
||||||
function groundHeight(lon, lat) {
|
// canSample=false returns the cached height (or 0) without surface sampling.
|
||||||
const key = `${lon.toFixed(5)},${lat.toFixed(5)}`;
|
function groundHeight(lon, lat, canSample = true) {
|
||||||
|
const key = heightKey(lon, lat);
|
||||||
const cached = heightCache.get(key);
|
const cached = heightCache.get(key);
|
||||||
if (cached && cached.settled) {
|
if (cached && cached.settled) {
|
||||||
// Mark hot for this frame + bump to most-recently-used so heights for live
|
// Mark hot for this frame + bump to most-recently-used so heights for live
|
||||||
@@ -123,6 +147,20 @@ export function createScreenAnnotationRenderer(
|
|||||||
heightCache.set(key, cached);
|
heightCache.set(key, cached);
|
||||||
return cached.h;
|
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 {
|
try {
|
||||||
if (
|
if (
|
||||||
scene.clampToHeightSupported &&
|
scene.clampToHeightSupported &&
|
||||||
@@ -133,7 +171,11 @@ export function createScreenAnnotationRenderer(
|
|||||||
);
|
);
|
||||||
if (c) {
|
if (c) {
|
||||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
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 });
|
heightCache.set(key, { h, settled: true, gen: projGen });
|
||||||
trimHeightCache();
|
trimHeightCache();
|
||||||
return h;
|
return h;
|
||||||
@@ -144,7 +186,12 @@ export function createScreenAnnotationRenderer(
|
|||||||
/* tiles not ready */
|
/* tiles not ready */
|
||||||
}
|
}
|
||||||
const fallback = cached ? cached.h : 0;
|
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();
|
trimHeightCache();
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -310,7 +357,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
} catch {
|
} catch {
|
||||||
/* never inserted */
|
/* never inserted */
|
||||||
}
|
}
|
||||||
if (records.size === 0) heightCache.clear();
|
if (records.size === 0) clearHeights();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,11 +409,30 @@ export function createScreenAnnotationRenderer(
|
|||||||
// Anchor at the real surface height (cached + validated), so marks sit on
|
// 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
|
// the ground/building instead of at sea level. The settle-once cache keeps
|
||||||
// it stable (no per-frame jitter that earlier broke nearby projections).
|
// 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
|
||||||
lon,
|
// is rejected before GPU-backed surface sampling only when the whole
|
||||||
lat,
|
// accepted height range is proven hidden by one constraint.
|
||||||
groundHeight(lon, lat),
|
let height;
|
||||||
);
|
if (heightCache.get(heightKey(lon, lat))?.settled) {
|
||||||
|
height = groundHeight(lon, lat);
|
||||||
|
} 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,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
heightBounds.center,
|
||||||
|
);
|
||||||
|
const inFrustum =
|
||||||
|
cullingVolume?.computeVisibility(heightBounds) !==
|
||||||
|
Cesium.Intersect.OUTSIDE;
|
||||||
|
height = groundHeight(lon, lat, inFrustum);
|
||||||
|
}
|
||||||
|
const world = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
||||||
@@ -386,6 +452,21 @@ export function createScreenAnnotationRenderer(
|
|||||||
return { x: win.x, y: win.y };
|
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
|
// 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
|
// 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,
|
// could hide it. After layout, any mark whose ACTUAL projected bounding box (rings,
|
||||||
@@ -467,6 +548,20 @@ export function createScreenAnnotationRenderer(
|
|||||||
|
|
||||||
function projectAll() {
|
function projectAll() {
|
||||||
if (!records.size) return;
|
if (!records.size) 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
|
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||||
const trackedRect = trackedEntityRect();
|
const trackedRect = trackedEntityRect();
|
||||||
occluder.cameraPosition = scene.camera.positionWC;
|
occluder.cameraPosition = scene.camera.positionWC;
|
||||||
@@ -613,6 +708,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.
|
// Keep clustered callouts readable.
|
||||||
const placed = [];
|
const placed = [];
|
||||||
@@ -693,7 +795,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
}, 360);
|
}, 360);
|
||||||
records.delete(anno.id);
|
records.delete(anno.id);
|
||||||
// Board emptied → drop accumulated height samples (natural reset point).
|
// Board emptied → drop accumulated height samples (natural reset point).
|
||||||
if (records.size === 0) heightCache.clear();
|
if (records.size === 0) clearHeights();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sync() {
|
function sync() {
|
||||||
@@ -713,7 +815,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
/* gone */
|
/* gone */
|
||||||
}
|
}
|
||||||
records.clear();
|
records.clear();
|
||||||
heightCache.clear();
|
clearHeights();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { add, update, remove, sync, destroy };
|
return { add, update, remove, sync, destroy };
|
||||||
|
|||||||
@@ -152,10 +152,17 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const camera = {
|
const camera = {
|
||||||
positionWC: Cesium.Cartesian3.ZERO,
|
positionWC: Cesium.Cartesian3.fromDegrees(-97.5, 31.2, 1000000),
|
||||||
directionWC: Cesium.Cartesian3.ZERO,
|
directionWC: Cesium.Cartesian3.normalize(
|
||||||
|
Cesium.Cartesian3.negate(
|
||||||
|
Cesium.Cartesian3.fromDegrees(-97.5, 31.2),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
positionCartographic: { height: 1000 },
|
positionCartographic: { height: 1000 },
|
||||||
};
|
};
|
||||||
|
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, y: 100 }));
|
||||||
const sampledAnchors = [];
|
const sampledAnchors = [];
|
||||||
const scene = {
|
const scene = {
|
||||||
camera,
|
camera,
|
||||||
@@ -326,3 +333,280 @@ test('an add that throws after inserting its group unwinds instead of orphaning
|
|||||||
);
|
);
|
||||||
renderer.destroy();
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sampledLon(world) {
|
||||||
|
const carto = Cesium.Cartographic.fromCartesian(world);
|
||||||
|
return Number(Cesium.Math.toDegrees(carto.longitude).toFixed(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDom(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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
installDom(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 = createScreenAnnotationRenderer({ scene, camera });
|
||||||
|
renderer.add({
|
||||||
|
id: 'partial-arrow',
|
||||||
|
type: 'arrow',
|
||||||
|
label: 'Ridge',
|
||||||
|
anchor: { lon: 0, lat: 0 },
|
||||||
|
to: { lon: 0.2, lat: 0 },
|
||||||
|
});
|
||||||
|
postRender.raiseEvent();
|
||||||
|
const { group } = findAnnotationGroup(globalThis.document);
|
||||||
|
assert.equal(group.style.display, '', 'the arrow stays drawn');
|
||||||
|
const path = group.children.find((child) => child.classList.contains('gev-anno-arrow'));
|
||||||
|
assert.match(path.getAttribute('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) => {
|
||||||
|
installDom(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 = createScreenAnnotationRenderer({ scene, camera });
|
||||||
|
renderer.add({ id: 'roof', type: 'label', label: 'Roof', anchor: { lon: roofLon, lat: 0 } });
|
||||||
|
const { group } = findAnnotationGroup(globalThis.document);
|
||||||
|
assert.equal(samples, 1, 'the unknown height is sampled, not culled');
|
||||||
|
assert.equal(group.style.display, '');
|
||||||
|
for (let i = 0; i < 5; i++) postRender.raiseEvent();
|
||||||
|
assert.equal(samples, 1, 'the settled height is reused');
|
||||||
|
assert.equal(group.style.display, '', 'the cached roof point is tested and shown');
|
||||||
|
renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('height retries beyond the first budget are served under sparse passes', (t) => {
|
||||||
|
installDom(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 = createScreenAnnotationRenderer({ scene, camera });
|
||||||
|
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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user