mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
- 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]>
613 lines
21 KiB
JavaScript
613 lines
21 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as Cesium from 'cesium';
|
|
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
|
|
|
class FakeClassList {
|
|
constructor(owner) {
|
|
this.owner = owner;
|
|
this.names = new Set();
|
|
}
|
|
|
|
reset(value) {
|
|
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
|
}
|
|
|
|
add(...names) {
|
|
for (const name of names) this.names.add(name);
|
|
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
|
}
|
|
|
|
remove(...names) {
|
|
for (const name of names) this.names.delete(name);
|
|
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
|
}
|
|
|
|
contains(name) {
|
|
return this.names.has(name);
|
|
}
|
|
}
|
|
|
|
class FakeElement {
|
|
constructor(tagName) {
|
|
this.tagName = tagName;
|
|
this.attributes = new Map();
|
|
this.children = [];
|
|
this.parentNode = null;
|
|
this.style = {};
|
|
this.classList = new FakeClassList(this);
|
|
this.textContent = '';
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
this.attributes.set(name, String(value));
|
|
if (name === 'class') this.classList.reset(value);
|
|
}
|
|
|
|
getAttribute(name) {
|
|
return this.attributes.get(name) ?? null;
|
|
}
|
|
|
|
set className(value) {
|
|
this.setAttribute('class', value);
|
|
}
|
|
|
|
get className() {
|
|
return this.getAttribute('class') || '';
|
|
}
|
|
|
|
set id(value) {
|
|
this.setAttribute('id', value);
|
|
}
|
|
|
|
get id() {
|
|
return this.getAttribute('id') || '';
|
|
}
|
|
|
|
appendChild(child) {
|
|
child.parentNode = this;
|
|
this.children.push(child);
|
|
return child;
|
|
}
|
|
|
|
removeChild(child) {
|
|
const index = this.children.indexOf(child);
|
|
if (index >= 0) this.children.splice(index, 1);
|
|
child.parentNode = null;
|
|
return child;
|
|
}
|
|
|
|
remove() {
|
|
this.parentNode?.removeChild(this);
|
|
}
|
|
|
|
querySelector(selector) {
|
|
return this.querySelectorAll(selector)[0] || null;
|
|
}
|
|
|
|
querySelectorAll(selector) {
|
|
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
|
const matches = [];
|
|
const visit = (node) => {
|
|
for (const child of node.children) {
|
|
if (className && child.classList.contains(className)) matches.push(child);
|
|
visit(child);
|
|
}
|
|
};
|
|
visit(this);
|
|
return matches;
|
|
}
|
|
|
|
addEventListener() {}
|
|
|
|
getBBox() {
|
|
return { x: 0, y: 0, width: 40, height: 16 };
|
|
}
|
|
|
|
set innerHTML(value) {
|
|
this._innerHTML = value;
|
|
}
|
|
}
|
|
|
|
function fakeDocument() {
|
|
const head = new FakeElement('head');
|
|
const body = new FakeElement('body');
|
|
const roots = [head, body];
|
|
return {
|
|
head,
|
|
body,
|
|
createElement: (tag) => new FakeElement(tag),
|
|
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
|
getElementById(id) {
|
|
let found = null;
|
|
const visit = (node) => {
|
|
if (node.getAttribute('id') === id) found = node;
|
|
for (const child of node.children) visit(child);
|
|
};
|
|
for (const root of roots) visit(root);
|
|
return found;
|
|
},
|
|
};
|
|
}
|
|
|
|
function findAnnotationGroup(document) {
|
|
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
|
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
|
return {
|
|
svg,
|
|
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
|
};
|
|
}
|
|
|
|
test('outline upgrade preserves the existing SVG group identity', (t) => {
|
|
const originalDocument = globalThis.document;
|
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
globalThis.document = fakeDocument();
|
|
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
|
t.after(() => {
|
|
if (originalDocument === undefined) delete globalThis.document;
|
|
else globalThis.document = originalDocument;
|
|
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
|
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
});
|
|
|
|
const camera = {
|
|
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,
|
|
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
|
clampToHeightSupported: true,
|
|
clampToHeight(world) {
|
|
const cartographic = Cesium.Cartographic.fromCartesian(world);
|
|
sampledAnchors.push([
|
|
Number(Cesium.Math.toDegrees(cartographic.longitude).toFixed(4)),
|
|
Number(Cesium.Math.toDegrees(cartographic.latitude).toFixed(4)),
|
|
]);
|
|
return world;
|
|
},
|
|
postRender: { addEventListener() {}, removeEventListener() {} },
|
|
};
|
|
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
|
const anno = {
|
|
id: 'anno-fb3',
|
|
type: 'area',
|
|
color: 'primary',
|
|
label: 'Texas',
|
|
alpha: 1,
|
|
anchor: { lon: -99, lat: 31, height: 0 },
|
|
ring: null,
|
|
};
|
|
renderer.add(anno);
|
|
const before = findAnnotationGroup(globalThis.document);
|
|
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
|
|
|
const labelProxy = Object.assign(Object.create(anno), {
|
|
type: 'label',
|
|
ring: null,
|
|
anchor: { lon: -97.5, lat: 31.2, height: 0 },
|
|
});
|
|
renderer.update(labelProxy);
|
|
const after = findAnnotationGroup(globalThis.document);
|
|
|
|
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
|
|
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
|
|
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
|
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
|
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
|
|
renderer.destroy();
|
|
});
|
|
|
|
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
|
|
const originalDocument = globalThis.document;
|
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
|
globalThis.document = fakeDocument();
|
|
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
|
t.after(() => {
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
|
if (originalDocument === undefined) delete globalThis.document;
|
|
else globalThis.document = originalDocument;
|
|
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
|
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
});
|
|
|
|
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
|
const directionWC = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
|
const scene = {
|
|
camera,
|
|
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
|
clampToHeightSupported: false,
|
|
postRender: { addEventListener() {}, removeEventListener() {} },
|
|
};
|
|
const paintRectCalls = [];
|
|
const renderer = createScreenAnnotationRenderer(
|
|
{ scene, camera, trackedEntity: null },
|
|
{
|
|
activeTrackedReadoutId: () => 'installations:test',
|
|
overlayPaintRect(sourceId, entryId) {
|
|
paintRectCalls.push({ sourceId, entryId });
|
|
return { x: -10, y: -10, w: 120, h: 80 };
|
|
},
|
|
},
|
|
);
|
|
renderer.add({
|
|
id: 'anno-overlap',
|
|
type: 'label',
|
|
color: 'primary',
|
|
label: 'OVERLAP',
|
|
alpha: 1,
|
|
anchor: { lon: 0, lat: 0, height: 0 },
|
|
});
|
|
const { group } = findAnnotationGroup(globalThis.document);
|
|
assert.deepEqual(paintRectCalls.at(-1), {
|
|
sourceId: 'tracked',
|
|
entryId: 'installations:test',
|
|
});
|
|
assert.ok(
|
|
Number(group.getAttribute('opacity')) < 1,
|
|
'final annotation bbox fades against the complete painted card rectangle',
|
|
);
|
|
renderer.destroy();
|
|
});
|
|
|
|
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
|
|
//
|
|
// add() inserts the group and records it, then does more live-document work
|
|
// (draw-on wiring, the first projection pass). A throw in that tail used to
|
|
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
|
|
// deletes its own map entry — so re-annotating the same place stacked a second
|
|
// mark over the corpse.
|
|
|
|
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
|
|
const originalDocument = globalThis.document;
|
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
|
globalThis.document = fakeDocument();
|
|
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
|
t.after(() => {
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
|
if (originalDocument === undefined) delete globalThis.document;
|
|
else globalThis.document = originalDocument;
|
|
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
|
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
});
|
|
|
|
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
|
const directionWC = Cesium.Cartesian3.normalize(
|
|
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
|
const scene = {
|
|
camera,
|
|
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
|
clampToHeightSupported: false,
|
|
postRender: { addEventListener() {}, removeEventListener() {} },
|
|
};
|
|
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
|
const anno = {
|
|
id: 'anno-partial-screen',
|
|
type: 'pin',
|
|
color: 'primary',
|
|
label: 'Capitol',
|
|
alpha: 1,
|
|
anchor: { lon: 0, lat: 0, height: 0 },
|
|
};
|
|
|
|
// The group is in the document and recorded by the time add() reaches its
|
|
// first projection pass — so a throw there is real partial state, not a
|
|
// clean bail-out.
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
|
|
assert.throws(() => renderer.add(anno), /projection failed/);
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
|
const { svg } = findAnnotationGroup(globalThis.document);
|
|
assert.equal(
|
|
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
|
0,
|
|
'the half-built group must not stay in the document',
|
|
);
|
|
|
|
// The id is free again: a retry draws exactly one mark, and remove() still
|
|
// owns it (a leaked record would leave a second group behind).
|
|
renderer.add(anno);
|
|
assert.equal(
|
|
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
|
1,
|
|
'the retry must draw one mark, not stack over an orphan',
|
|
);
|
|
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();
|
|
});
|