mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04308e45d | ||
|
|
9582f2f28b |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
- Fix rendering stopping for good after a wheel zoom with the pointer on the
|
||||
centre of a whole-globe view. Cesium's zoom could ask the camera to rotate
|
||||
about a zero axis; that rotation is now ignored, and a render loop stopped by
|
||||
any error is restarted from the last drawn camera pose, up to three times in
|
||||
ten seconds (mazy1998, #1704).
|
||||
@@ -0,0 +1,4 @@
|
||||
- ALPR camera badges no longer take depth samples while the overlay paints, or
|
||||
again each time the layer is switched back on. On a terrain globe their
|
||||
height comes from the loaded terrain and is remembered per camera; some
|
||||
badges move a few metres onto the drawn surface (mazy1998, #1706).
|
||||
@@ -1513,10 +1513,14 @@ tile streamed in; floors resolve before new badges appear (bounded wait), and a
|
||||
badge whose cell has not resolved clamps until it does, then moves in place.
|
||||
Overlay anchors start on the same floor and write marker properties only on
|
||||
change. Once the camera is still and tiles have settled, each overlay anchor is
|
||||
replaced once by the rendered surface under it (two depth samples per paint,
|
||||
accepted only within -30/+40 m of the floor cell), so badges do not float or
|
||||
sink on slopes; wedge corners follow their own floor cells instead of staying
|
||||
level with the apex. Unknown bearings never acquire a wedge. The selected
|
||||
replaced once by the rendered surface under it (accepted only within -30/+40 m
|
||||
of the floor cell), so badges do not float or sink on slopes; wedge corners
|
||||
follow their own floor cells instead of staying level with the apex. The
|
||||
surface is read outside the overlay paint, one camera per frame: a terrain
|
||||
globe answers from its loaded mesh (`globe.getHeight`) and only photoreal
|
||||
tiles are depth-sampled (`scene.sampleHeight`). Answers are kept per camera
|
||||
until the map source changes, so a refresh or turning the layer off and on
|
||||
samples nothing again. Unknown bearings never acquire a wedge. The selected
|
||||
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
|
||||
of 359.5° or more reads `0°` rather than `360°`. The row reads
|
||||
`N nearby · M on screen`; when every loaded camera is outside the view it adds
|
||||
|
||||
@@ -207,7 +207,6 @@
|
||||
"modules": [
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/app/renderRecovery.js",
|
||||
"src/globeInView.js",
|
||||
"src/sky/starrySky.js",
|
||||
"src/sky/stars.js",
|
||||
@@ -2611,7 +2610,6 @@
|
||||
"src/sky/starrySky.js",
|
||||
"src/sky/stars.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/app/renderRecovery.js",
|
||||
"src/bloom.js",
|
||||
"src/camera.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Keeps one bad frame from stopping the globe for good.
|
||||
*
|
||||
* Cesium's render loop stops itself on the first error a frame throws and
|
||||
* leaves an error panel over the canvas; nothing restarts it short of a
|
||||
* reload. Two things here:
|
||||
*
|
||||
* 1. `guardDegenerateRotation` removes one such error at its cause. Zooming
|
||||
* with the wheel above 1,000 km, Cesium turns the globe from the point under
|
||||
* the screen centre towards the point under the pointer
|
||||
* (`ScreenSpaceCameraController` `handleZoom`): it normalizes both points,
|
||||
* takes their dot product, and rotates about their cross product when the
|
||||
* dot product is below 1. With the pointer on the centre pixel the two
|
||||
* points are the same, but a unit vector's dot product with itself often
|
||||
* rounds to 0.9999999999999999, so the check passes and the cross product is
|
||||
* the zero vector. `Camera.rotate` then normalizes it and throws
|
||||
* "normalized result is not a number" from `Scene.initializeFrame`, which
|
||||
* runs outside the scene's own error handling. Builds without Cesium's
|
||||
* debug checks would write NaN into the camera instead.
|
||||
*
|
||||
* 2. `installRenderRecovery` restarts the loop after any error that stops it,
|
||||
* from the last pose a frame was drawn with, a bounded number of times.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
|
||||
/** Restarts allowed within RECOVERY_WINDOW_MS before the error is left to stand. */
|
||||
export const RECOVERY_LIMIT = 3;
|
||||
/** An error that keeps coming back this fast is not one a restart gets past. */
|
||||
export const RECOVERY_WINDOW_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Make `camera.rotate` ignore an axis it cannot normalize (zero or not finite)
|
||||
* and an angle that is not finite. There is nothing to rotate about, so the
|
||||
* camera stays where it is and the caller carries on; Cesium's wheel zoom
|
||||
* still moves the camera in after the skipped turn.
|
||||
* @param {Cesium.Camera} camera
|
||||
* @returns {() => void} Restore the camera's own rotate.
|
||||
*/
|
||||
export function guardDegenerateRotation(camera) {
|
||||
const own = Object.hasOwn(camera, 'rotate');
|
||||
const rotate = camera.rotate;
|
||||
const guarded = function (axis, angle) {
|
||||
if (axis) {
|
||||
const magnitudeSquared = Cesium.Cartesian3.magnitudeSquared(axis);
|
||||
if (
|
||||
!(magnitudeSquared > 0) ||
|
||||
!Number.isFinite(magnitudeSquared) ||
|
||||
(angle !== undefined && !Number.isFinite(angle))
|
||||
)
|
||||
return;
|
||||
}
|
||||
return rotate.call(this, axis, angle);
|
||||
};
|
||||
camera.rotate = guarded;
|
||||
return () => {
|
||||
if (camera.rotate !== guarded) return;
|
||||
if (own) camera.rotate = rotate;
|
||||
else delete camera.rotate;
|
||||
};
|
||||
}
|
||||
|
||||
function finite(cartesian) {
|
||||
return (
|
||||
Number.isFinite(cartesian?.x) &&
|
||||
Number.isFinite(cartesian?.y) &&
|
||||
Number.isFinite(cartesian?.z)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart Cesium's render loop when an error stops it.
|
||||
*
|
||||
* Cesium reports a stopped loop only through `showErrorPanel`: errors thrown
|
||||
* while a frame is prepared (camera input, in `Scene.initializeFrame`) never
|
||||
* reach `scene.renderError`, so that event alone would miss them. The panel
|
||||
* call is the one place both kinds of stop pass through, after the loop is
|
||||
* already off.
|
||||
*
|
||||
* On a stop this puts the camera back on the last pose a frame was drawn
|
||||
* with, discards the input that was being applied (Cesium skips its own reset
|
||||
* when the frame throws, so the same gesture would otherwise run again), and
|
||||
* turns the loop back on. After RECOVERY_LIMIT restarts inside
|
||||
* RECOVERY_WINDOW_MS the error is shown as before and rendering stays stopped.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {object} [options]
|
||||
* @param {() => number} [options.now]
|
||||
* @param {(reason: string) => void} [options.requestRender]
|
||||
* @returns {() => void} Idempotent disposer.
|
||||
*/
|
||||
export function installRenderRecovery(
|
||||
viewer,
|
||||
{ now = () => performance.now(), requestRender = governorRequestRender } = {},
|
||||
) {
|
||||
const widget = viewer?.cesiumWidget;
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
if (!widget?.showErrorPanel || !scene?.postRender || !camera)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
// The pose of the last frame that drew: three vector copies a frame.
|
||||
const position = new Cesium.Cartesian3();
|
||||
const direction = new Cesium.Cartesian3();
|
||||
const up = new Cesium.Cartesian3();
|
||||
let hasPose = false;
|
||||
const removePostRender = scene.postRender.addEventListener(() => {
|
||||
if (!finite(camera.position) || !finite(camera.direction)) return;
|
||||
Cesium.Cartesian3.clone(camera.position, position);
|
||||
Cesium.Cartesian3.clone(camera.direction, direction);
|
||||
Cesium.Cartesian3.clone(camera.up, up);
|
||||
hasPose = true;
|
||||
});
|
||||
|
||||
const recoveredAt = [];
|
||||
const own = Object.hasOwn(widget, 'showErrorPanel');
|
||||
const showErrorPanel = widget.showErrorPanel;
|
||||
const recovering = function (title, message, error) {
|
||||
const at = now();
|
||||
while (recoveredAt.length && at - recoveredAt[0] > RECOVERY_WINDOW_MS)
|
||||
recoveredAt.shift();
|
||||
// Only a stopped loop is restarted; other panels (a failed start-up, a
|
||||
// caller's own message) are shown as they are.
|
||||
if (
|
||||
viewer.useDefaultRenderLoop !== false ||
|
||||
recoveredAt.length >= RECOVERY_LIMIT
|
||||
)
|
||||
return showErrorPanel.call(this, title, message, error);
|
||||
recoveredAt.push(at);
|
||||
console.error(
|
||||
`[viewer] render loop stopped by an error; restarting (${recoveredAt.length} of ${RECOVERY_LIMIT})`,
|
||||
error,
|
||||
);
|
||||
if (hasPose) {
|
||||
Cesium.Cartesian3.clone(position, camera.position);
|
||||
Cesium.Cartesian3.clone(direction, camera.direction);
|
||||
Cesium.Cartesian3.clone(up, camera.up);
|
||||
Cesium.Cartesian3.cross(camera.direction, camera.up, camera.right);
|
||||
}
|
||||
// Cesium has no public way to drop the input a failed frame left queued.
|
||||
scene.screenSpaceCameraController?._aggregator?.reset?.();
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
requestRender('render-recovery');
|
||||
};
|
||||
widget.showErrorPanel = recovering;
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removePostRender?.();
|
||||
if (widget.showErrorPanel !== recovering) return;
|
||||
if (own) widget.showErrorPanel = showErrorPanel;
|
||||
else delete widget.showErrorPanel;
|
||||
};
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
guardDegenerateRotation,
|
||||
installRenderRecovery,
|
||||
RECOVERY_LIMIT,
|
||||
RECOVERY_WINDOW_MS,
|
||||
} from './renderRecovery.js';
|
||||
|
||||
/** Cesium's own Camera.rotate on a camera-shaped object, with no scene behind it. */
|
||||
function cameraFixture() {
|
||||
const camera = Object.create(Cesium.Camera.prototype);
|
||||
camera.position = new Cesium.Cartesian3(28_378_137, 0, 0);
|
||||
camera.direction = new Cesium.Cartesian3(-1, 0, 0);
|
||||
camera.up = new Cesium.Cartesian3(0, 0, 1);
|
||||
camera.right = new Cesium.Cartesian3(0, 1, 0);
|
||||
camera._adjustOrthographicFrustum = () => {};
|
||||
return camera;
|
||||
}
|
||||
|
||||
const pose = (camera) =>
|
||||
[camera.position, camera.direction, camera.up, camera.right].map((v) =>
|
||||
Cesium.Cartesian3.clone(v),
|
||||
);
|
||||
|
||||
test("the wheel zoom's own arithmetic hands Camera.rotate a zero axis", () => {
|
||||
// What handleZoom computes with the pointer on the centre pixel: the same
|
||||
// picked point twice. The dot product rounds below 1, the cross product is 0.
|
||||
const picked = Cesium.Cartesian3.fromDegrees(-41.11, -13.37);
|
||||
const pickedNormal = Cesium.Cartesian3.normalize(
|
||||
picked,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const centreNormal = Cesium.Cartesian3.normalize(
|
||||
picked,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const axis = Cesium.Cartesian3.cross(
|
||||
pickedNormal,
|
||||
centreNormal,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.deepEqual([axis.x, axis.y, axis.z], [0, 0, 0]);
|
||||
|
||||
const unguarded = cameraFixture();
|
||||
assert.throws(() => unguarded.rotate(axis, 0.01), /not a number/);
|
||||
|
||||
const camera = cameraFixture();
|
||||
const before = pose(camera);
|
||||
guardDegenerateRotation(camera);
|
||||
assert.doesNotThrow(() => camera.rotate(axis, 0.01));
|
||||
assert.deepEqual(pose(camera), before);
|
||||
});
|
||||
|
||||
test('an axis or angle that is not finite leaves the camera where it is', () => {
|
||||
const camera = cameraFixture();
|
||||
const before = pose(camera);
|
||||
guardDegenerateRotation(camera);
|
||||
camera.rotate(new Cesium.Cartesian3(NaN, 0, 1), 0.01);
|
||||
camera.rotate(new Cesium.Cartesian3(Infinity, 0, 1), 0.01);
|
||||
camera.rotate(Cesium.Cartesian3.UNIT_Z, NaN);
|
||||
camera.rotate(Cesium.Cartesian3.UNIT_Z, Infinity);
|
||||
assert.deepEqual(pose(camera), before);
|
||||
});
|
||||
|
||||
test('an ordinary rotation is exactly what Cesium does unguarded', () => {
|
||||
const axis = new Cesium.Cartesian3(0.2, -0.5, 0.84);
|
||||
const plain = cameraFixture();
|
||||
plain.rotate(axis, 0.37);
|
||||
plain.rotate(Cesium.Cartesian3.UNIT_Z);
|
||||
|
||||
const camera = cameraFixture();
|
||||
const restore = guardDegenerateRotation(camera);
|
||||
camera.rotate(axis, 0.37);
|
||||
// No angle: Cesium's default amount still applies.
|
||||
camera.defaultRotateAmount = plain.defaultRotateAmount;
|
||||
camera.rotate(Cesium.Cartesian3.UNIT_Z);
|
||||
assert.deepEqual(pose(camera), pose(plain));
|
||||
|
||||
restore();
|
||||
assert.equal(Object.hasOwn(camera, 'rotate'), false);
|
||||
assert.equal(camera.rotate, Cesium.Camera.prototype.rotate);
|
||||
});
|
||||
|
||||
/** Vector equality as Cesium has it: a recomputed right vector may hold -0. */
|
||||
function assertPose(camera, expected) {
|
||||
pose(camera).forEach((vector, i) =>
|
||||
assert.ok(Cesium.Cartesian3.equals(vector, expected[i]), `vector ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
function viewerFixture() {
|
||||
const postRender = new Cesium.Event();
|
||||
const panels = [];
|
||||
const resets = [];
|
||||
const camera = cameraFixture();
|
||||
const viewer = {
|
||||
camera,
|
||||
useDefaultRenderLoop: true,
|
||||
cesiumWidget: {
|
||||
showErrorPanel(title, message, error) {
|
||||
panels.push({ title, message, error });
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
postRender,
|
||||
screenSpaceCameraController: {
|
||||
_aggregator: { reset: () => resets.push(true) },
|
||||
},
|
||||
},
|
||||
};
|
||||
// What Cesium's loop does when a frame throws.
|
||||
const fail = (error = new Error('frame failed')) => {
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
viewer.cesiumWidget.showErrorPanel('Rendering has stopped.', undefined, error);
|
||||
};
|
||||
return { viewer, camera, postRender, panels, resets, fail };
|
||||
}
|
||||
|
||||
function quietly(run) {
|
||||
const original = console.error;
|
||||
const logged = [];
|
||||
console.error = (...args) => logged.push(args);
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
return logged;
|
||||
}
|
||||
|
||||
test('a stopped render loop restarts from the last drawn pose', () => {
|
||||
const { viewer, camera, postRender, panels, resets, fail } = viewerFixture();
|
||||
const requests = [];
|
||||
installRenderRecovery(viewer, {
|
||||
now: () => 0,
|
||||
requestRender: (reason) => requests.push(reason),
|
||||
});
|
||||
postRender.raiseEvent();
|
||||
const drawn = pose(camera);
|
||||
|
||||
// The failed frame left the camera unusable.
|
||||
camera.position.x = NaN;
|
||||
camera.direction.y = NaN;
|
||||
const error = new Error('normalized result is not a number');
|
||||
const logged = quietly(() => fail(error));
|
||||
|
||||
assert.equal(viewer.useDefaultRenderLoop, true);
|
||||
assert.deepEqual(panels, []);
|
||||
assertPose(camera, drawn);
|
||||
assert.deepEqual(resets, [true]);
|
||||
assert.deepEqual(requests, ['render-recovery']);
|
||||
assert.equal(logged.length, 1);
|
||||
assert.equal(logged[0][1], error);
|
||||
});
|
||||
|
||||
test('a frame drawn with a broken camera is not kept as the pose to return to', () => {
|
||||
const { viewer, camera, postRender, fail } = viewerFixture();
|
||||
installRenderRecovery(viewer, { now: () => 0, requestRender: () => {} });
|
||||
postRender.raiseEvent();
|
||||
const drawn = pose(camera);
|
||||
camera.position.x = NaN;
|
||||
postRender.raiseEvent();
|
||||
quietly(() => fail());
|
||||
assertPose(camera, drawn);
|
||||
});
|
||||
|
||||
test('an error that keeps returning is shown and rendering stays stopped', () => {
|
||||
const { viewer, panels, fail } = viewerFixture();
|
||||
let at = 0;
|
||||
installRenderRecovery(viewer, { now: () => at, requestRender: () => {} });
|
||||
quietly(() => {
|
||||
for (let i = 0; i < RECOVERY_LIMIT; i++) {
|
||||
at += 100;
|
||||
fail();
|
||||
assert.equal(viewer.useDefaultRenderLoop, true, `restart ${i + 1}`);
|
||||
}
|
||||
at += 100;
|
||||
fail();
|
||||
});
|
||||
assert.equal(viewer.useDefaultRenderLoop, false);
|
||||
assert.equal(panels.length, 1);
|
||||
assert.equal(panels[0].title, 'Rendering has stopped.');
|
||||
|
||||
// Once the window has passed, a later, unrelated error is recovered again.
|
||||
at += RECOVERY_WINDOW_MS + 1;
|
||||
quietly(() => fail());
|
||||
assert.equal(viewer.useDefaultRenderLoop, true);
|
||||
assert.equal(panels.length, 1);
|
||||
});
|
||||
|
||||
test('a panel shown while the loop is running is left alone', () => {
|
||||
const { viewer, panels, resets } = viewerFixture();
|
||||
installRenderRecovery(viewer, { now: () => 0, requestRender: () => {} });
|
||||
viewer.cesiumWidget.showErrorPanel('Error constructing CesiumWidget.', 'x');
|
||||
assert.equal(panels.length, 1);
|
||||
assert.deepEqual(resets, []);
|
||||
});
|
||||
|
||||
test('disposing restores the widget and stops recording poses', () => {
|
||||
const { viewer, postRender, panels, fail } = viewerFixture();
|
||||
const original = viewer.cesiumWidget.showErrorPanel;
|
||||
const dispose = installRenderRecovery(viewer, {
|
||||
now: () => 0,
|
||||
requestRender: () => {},
|
||||
});
|
||||
assert.equal(postRender.numberOfListeners, 1);
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(postRender.numberOfListeners, 0);
|
||||
assert.equal(viewer.cesiumWidget.showErrorPanel, original);
|
||||
fail();
|
||||
assert.equal(viewer.useDefaultRenderLoop, false);
|
||||
assert.equal(panels.length, 1);
|
||||
});
|
||||
|
||||
test('an incomplete viewer is refused', () => {
|
||||
assert.throws(() => installRenderRecovery({}), TypeError);
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import {
|
||||
guardDegenerateRotation,
|
||||
installRenderRecovery,
|
||||
} from './renderRecovery.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
import {
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
@@ -419,11 +415,6 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
||||
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
|
||||
limitZoomOut(viewer);
|
||||
// Cesium's wheel zoom can hand the camera a zero rotation axis and stop
|
||||
// the render loop for good; and whatever else stops it, restart it rather
|
||||
// than leave the globe dead until a reload. See app/renderRecovery.js.
|
||||
guardDegenerateRotation(viewer.camera);
|
||||
installRenderRecovery(viewer);
|
||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||
// atmosphere fails to LINK on Apple's Metal backend and kills the
|
||||
// render loop. See app/atmosphereCompat.js.
|
||||
|
||||
+139
-57
@@ -4,10 +4,10 @@ import {
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
ANCHOR_SAMPLES_PER_PAINT,
|
||||
REMEMBERED_ANCHORS,
|
||||
SURFACE_BELOW_FLOOR_M,
|
||||
SURFACE_ABOVE_FLOOR_M,
|
||||
SURFACE_SAMPLES_PER_PAINT,
|
||||
SURFACE_SAMPLES_PER_FRAME,
|
||||
} from './policy.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
@@ -19,11 +19,17 @@ import {
|
||||
validAlprGroundHeight,
|
||||
} from './visuals.js';
|
||||
|
||||
const nextAnimationFrame = (step) =>
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame(step)
|
||||
: setTimeout(step, 16);
|
||||
|
||||
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
|
||||
export function createAlprOverlay({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange = () => {},
|
||||
requestFrame = nextAnimationFrame,
|
||||
}) {
|
||||
let lane,
|
||||
removeMapListener,
|
||||
@@ -33,7 +39,12 @@ export function createAlprOverlay({
|
||||
hits = [],
|
||||
images = [],
|
||||
destroyed = false,
|
||||
lastCamera = null;
|
||||
lastCamera = null,
|
||||
sampling = false;
|
||||
// Rendered-surface answers by camera id. Entities are rebuilt by a refresh
|
||||
// and by turning the layer off and on; keeping the answers on them meant
|
||||
// every rebuild sampled the same cameras again.
|
||||
const surfaceAnswers = new Map();
|
||||
const requestPaint = () => {
|
||||
if (!destroyed && state.enabled) lane?.requestPaint();
|
||||
};
|
||||
@@ -92,38 +103,72 @@ export function createAlprOverlay({
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
nativeVisible(entity, true);
|
||||
}
|
||||
surfaceAnswers.clear();
|
||||
state.lastAnchorSampleAt = 0;
|
||||
retryUntil = Date.now() + 15000;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
function anchorFor(record, entity, budget) {
|
||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||
function rememberSurface(record, height, checked) {
|
||||
surfaceAnswers.delete(record.id);
|
||||
surfaceAnswers.set(record.id, {
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
height,
|
||||
checked,
|
||||
});
|
||||
if (surfaceAnswers.size > REMEMBERED_ANCHORS)
|
||||
surfaceAnswers.delete(surfaceAnswers.keys().next().value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered surface height under a camera. A terrain globe answers from its
|
||||
* loaded mesh; only photoreal tiles need `sampleHeight`, which renders depth
|
||||
* and compiles its shaders on first use (a freeze of up to 1.9 s at load).
|
||||
*/
|
||||
function surfaceHeight(record, entity) {
|
||||
const scene = state.viewer.scene;
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
// A floor-placed marker anchors where its native badge sits; only a
|
||||
// marker still waiting for its floor samples the rendered surface, and
|
||||
// each paint samples a few at most (each sample is a depth render).
|
||||
if (scene.globe?.show) return scene.globe.getHeight?.(location);
|
||||
if (!scene.sampleHeightSupported) return undefined;
|
||||
try {
|
||||
return scene.sampleHeight(location, [entity]);
|
||||
} catch {
|
||||
/* streaming tiles */
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The anchor a paint can have without sampling, or null while it waits for one. */
|
||||
function anchorFor(record, entity) {
|
||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||
const scene = state.viewer.scene;
|
||||
const answer = surfaceAnswers.get(record.id);
|
||||
if (
|
||||
answer?.latitude === record.latitude &&
|
||||
answer.longitude === record.longitude
|
||||
) {
|
||||
setAnchor(record, entity, answer.height);
|
||||
if (answer.checked)
|
||||
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
|
||||
return entity.gevAlprCanvasPosition;
|
||||
}
|
||||
// A floor-placed marker anchors where its native badge sits. A marker
|
||||
// still waiting for its floor reads the terrain globe, or on photoreal
|
||||
// tiles waits for sampleAnchors().
|
||||
let height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
);
|
||||
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
|
||||
else if (scene.sampleHeightSupported) {
|
||||
if (budget.samples <= 0) return null;
|
||||
budget.samples -= 1;
|
||||
try {
|
||||
height = scene.sampleHeight(location, [entity]);
|
||||
} catch {
|
||||
/* streaming tiles */
|
||||
}
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && scene.globe.show)
|
||||
height = scene.globe.getHeight?.(location);
|
||||
else if (scene.globe.show)
|
||||
height = scene.globe.getHeight?.(
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
|
||||
);
|
||||
if (!validAlprGroundHeight(height)) return null;
|
||||
setAnchor(record, entity, height);
|
||||
return entity.gevAlprCanvasPosition;
|
||||
@@ -158,14 +203,19 @@ export function createAlprOverlay({
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace floor anchors with the rendered surface under each overlay camera.
|
||||
* Floors come from ~111 m cells, so on a slope a badge could float or sink
|
||||
* by tens of metres. Runs only while the camera is still and tiles have
|
||||
* settled, a few depth samples per paint, and each anchor at most once;
|
||||
* samples far outside the floor prior (a roof or a streaming tile) are
|
||||
* rejected. Returns whether anchors remain to check.
|
||||
* Sample the rendered surface for the overlay cameras, outside the paint.
|
||||
*
|
||||
* A camera with no floor yet gets its anchor from a sample. An anchored one
|
||||
* is checked against the surface once: floors come from ~111 m cells, so on
|
||||
* a slope a badge could float or sink by tens of metres. Checks run only
|
||||
* while the camera is still and tiles have settled, and samples far outside
|
||||
* the floor prior (a roof or a streaming tile) are rejected. Each frame
|
||||
* takes SURFACE_SAMPLES_PER_FRAME samples, so no frame carries the whole
|
||||
* cohort; sampling inside paint() put up to six depth renders in one frame.
|
||||
*/
|
||||
function refineAnchors(budget) {
|
||||
function sampleAnchors() {
|
||||
sampling = false;
|
||||
if (destroyed || !state.enabled) return;
|
||||
const camera = state.viewer.camera;
|
||||
const scene = state.viewer.scene;
|
||||
const still =
|
||||
@@ -188,27 +238,41 @@ export function createAlprOverlay({
|
||||
position: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(camera.directionWC),
|
||||
};
|
||||
if (!scene.sampleHeightSupported) return false;
|
||||
let pending = false;
|
||||
const canSample = scene.globe?.show || scene.sampleHeightSupported;
|
||||
let samples = SURFACE_SAMPLES_PER_FRAME,
|
||||
pending = false,
|
||||
progressed = false,
|
||||
moved = false;
|
||||
// Cameras with no anchor come first: they are not on the overlay yet.
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
if (!entity || entity.gevAlprCanvasPosition) continue;
|
||||
// The floor may have resolved since the paint that found none.
|
||||
if (anchorFor(record, entity)) moved = true;
|
||||
else if (!canSample || samples <= 0) pending = true;
|
||||
else {
|
||||
samples -= 1;
|
||||
const sample = surfaceHeight(record, entity);
|
||||
if (validAlprGroundHeight(sample)) {
|
||||
setAnchor(record, entity, sample);
|
||||
rememberSurface(record, sample, false);
|
||||
moved = progressed = true;
|
||||
} else pending = true;
|
||||
}
|
||||
}
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
const anchor = entity?.gevAlprCanvasPosition;
|
||||
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
|
||||
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
|
||||
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
|
||||
continue;
|
||||
if (!still || !surfaceSettled(scene) || samples <= 0) {
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
budget.samples -= 1;
|
||||
let sample;
|
||||
try {
|
||||
sample = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
|
||||
[entity],
|
||||
);
|
||||
} catch {
|
||||
/* streaming tiles */
|
||||
}
|
||||
const current = Cesium.Cartographic.fromCartesian(anchor).height;
|
||||
samples -= 1;
|
||||
progressed = true;
|
||||
const sample = surfaceHeight(record, entity);
|
||||
let height = Cesium.Cartographic.fromCartesian(anchor).height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
@@ -218,21 +282,43 @@ export function createAlprOverlay({
|
||||
(!validAlprGroundHeight(floor) ||
|
||||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
|
||||
sample <= floor + SURFACE_ABOVE_FLOOR_M));
|
||||
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
|
||||
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
|
||||
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - height) > 1) {
|
||||
height = sample + MARKER_FLOOR_LIFT_M;
|
||||
setAnchor(record, entity, height);
|
||||
moved = true;
|
||||
}
|
||||
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
|
||||
// A surface that did not answer is asked again after a rebuild.
|
||||
if (validAlprGroundHeight(sample)) rememberSurface(record, height, true);
|
||||
if (entity.id === state.selectedId)
|
||||
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
|
||||
}
|
||||
return pending;
|
||||
if (moved) requestPaint();
|
||||
if (!pending || Date.now() >= retryUntil) return;
|
||||
// Take the next ration on the next frame; when nothing could be sampled
|
||||
// (camera moving, tiles streaming, no floor yet) look again in 250 ms.
|
||||
if (progressed) scheduleSampling();
|
||||
else if (!retryTimer)
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
scheduleSampling();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function scheduleSampling() {
|
||||
if (sampling || destroyed) return;
|
||||
sampling = true;
|
||||
requestFrame(sampleAnchors);
|
||||
}
|
||||
|
||||
function paint({ ctx, width, height, keyhole, occluder }) {
|
||||
hits = [];
|
||||
if (!state.enabled || destroyed) return;
|
||||
let unresolved = false;
|
||||
let waiting = false;
|
||||
const painted = [];
|
||||
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
|
||||
const canSample =
|
||||
state.viewer.scene.globe?.show ||
|
||||
state.viewer.scene.sampleHeightSupported;
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
if (!entity) continue;
|
||||
@@ -246,12 +332,14 @@ export function createAlprOverlay({
|
||||
nativeVisible(entity, true);
|
||||
continue;
|
||||
}
|
||||
const anchor = anchorFor(record, entity, budget);
|
||||
const anchor = anchorFor(record, entity);
|
||||
if (!anchor) {
|
||||
nativeVisible(entity, true);
|
||||
unresolved = true;
|
||||
waiting = true;
|
||||
continue;
|
||||
}
|
||||
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
|
||||
waiting = true;
|
||||
canvasOwned(entity, true);
|
||||
if (occluder && !occluder.isPointVisible(anchor)) {
|
||||
setShown(entity, false);
|
||||
@@ -335,15 +423,9 @@ export function createAlprOverlay({
|
||||
ctx.restore();
|
||||
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
|
||||
}
|
||||
const refining = refineAnchors({
|
||||
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
|
||||
});
|
||||
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
requestPaint();
|
||||
}, 250);
|
||||
}
|
||||
// Anchors still to find or to check are sampled after this paint, a few
|
||||
// per frame, never inside it.
|
||||
if (waiting && !retryTimer && Date.now() < retryUntil) scheduleSampling();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -45,8 +45,10 @@ function harness(t, count = 2, { floor = null } = {}) {
|
||||
active,
|
||||
removed = 0,
|
||||
paints = 0;
|
||||
const frames = [];
|
||||
const overlay = createAlprOverlay({
|
||||
state,
|
||||
requestFrame: (step) => frames.push(step),
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
@@ -106,6 +108,12 @@ function harness(t, count = 2, { floor = null } = {}) {
|
||||
entities,
|
||||
draw,
|
||||
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
|
||||
/** Run the sampling step queued for the next animation frame, if any. */
|
||||
frame() {
|
||||
const step = frames.shift();
|
||||
step?.();
|
||||
return Boolean(step);
|
||||
},
|
||||
mapChanged() {
|
||||
mapListener({ detail: { status: 'ready' } });
|
||||
},
|
||||
@@ -243,41 +251,149 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
|
||||
});
|
||||
|
||||
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
|
||||
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
|
||||
const h = harness(t, 2, { floor: 150 });
|
||||
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
|
||||
function photoreal(h, heightAt = () => 172) {
|
||||
const scene = h.state.viewer.scene;
|
||||
const camera = h.state.viewer.camera;
|
||||
camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
scene.globe.show = false;
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.primitives = { length: 0, get: () => null };
|
||||
let samples = 0;
|
||||
const sampled = [];
|
||||
scene.sampleHeight = (carto) => {
|
||||
samples++;
|
||||
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
|
||||
sampled.push(carto);
|
||||
return heightAt(Cesium.Math.toDegrees(carto.latitude));
|
||||
};
|
||||
return sampled;
|
||||
}
|
||||
|
||||
const anchorHeight = (h, id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
|
||||
/** Run sampling frames until none is queued, waiting out the 250 ms look-again. */
|
||||
function settle(t, h) {
|
||||
for (let i = 0; i < 400; i++) if (!h.frame()) t.mock.timers.tick(250);
|
||||
}
|
||||
|
||||
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
|
||||
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 2, { floor: 150 });
|
||||
const sampled = photoreal(h, (latitude) => (latitude > 30.00005 ? 400 : 172));
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const height = (id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
assert.ok(
|
||||
Math.abs(anchorHeight(h, 'camera:0') - 151.5) < 0.01,
|
||||
'floor anchor first',
|
||||
);
|
||||
assert.equal(h.frame(), true, 'the paint queued a sampling frame');
|
||||
assert.equal(
|
||||
samples,
|
||||
sampled.length,
|
||||
0,
|
||||
'no depth samples while the camera may still be moving',
|
||||
);
|
||||
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:0') - 173.5) < 0.01,
|
||||
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
|
||||
'rendered surface once still',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:1') - 151.5) < 0.01,
|
||||
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
|
||||
'a rooftop-height sample is rejected',
|
||||
);
|
||||
const taken = samples;
|
||||
assert.equal(sampled.length, 2);
|
||||
h.paint();
|
||||
assert.equal(samples, taken, 'each anchor is checked once');
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 2, 'each anchor is checked once');
|
||||
});
|
||||
|
||||
test('a paint never samples the surface; frames take one sample each', (t) => {
|
||||
// Sampling inside paint() put up to six depth renders into one frame, and
|
||||
// the first of them compiles shaders: a freeze of 0.8 to 1.9 s at load.
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 8);
|
||||
const sampled = photoreal(h);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
h.paint();
|
||||
assert.equal(sampled.length, 0, 'no sample inside a paint');
|
||||
assert.equal(h.draw.length, 0, 'unanchored cameras keep their native badge');
|
||||
for (let taken = 1; taken <= 8; taken++) {
|
||||
assert.equal(h.frame(), true);
|
||||
assert.equal(sampled.length, taken, 'one sample per frame');
|
||||
}
|
||||
for (const record of h.records)
|
||||
assert.ok(Math.abs(anchorHeight(h, record.id) - 172) < 0.01);
|
||||
h.paint();
|
||||
assert.ok(h.draw.length > 0, 'anchored cameras paint on the overlay');
|
||||
});
|
||||
|
||||
test('a terrain globe is read from its mesh, never depth-sampled', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 3, { floor: 150 });
|
||||
const sampled = photoreal(h);
|
||||
const scene = h.state.viewer.scene;
|
||||
scene.globe.show = true;
|
||||
scene.globe.tilesLoaded = true;
|
||||
let reads = 0;
|
||||
scene.globe.getHeight = () => {
|
||||
reads++;
|
||||
return 172;
|
||||
};
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 0);
|
||||
assert.equal(reads, 3);
|
||||
// The same anchor the depth sample gave: the surface plus the marker lift.
|
||||
for (const record of h.records)
|
||||
assert.ok(Math.abs(anchorHeight(h, record.id) - 173.5) < 0.01);
|
||||
});
|
||||
|
||||
test('surface answers outlive rebuilt markers and a re-enable, until the map changes', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 4, { floor: 150 });
|
||||
const sampled = photoreal(h);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 4);
|
||||
const before = h.records.map((record) => anchorHeight(h, record.id));
|
||||
|
||||
// A refresh or a toggle removes the entities and builds new ones.
|
||||
const rebuild = () => {
|
||||
h.overlay.clear();
|
||||
h.entities.removeAll();
|
||||
for (const record of h.records)
|
||||
h.entities.add({
|
||||
id: record.id,
|
||||
billboard: { image: 'fallback', show: true },
|
||||
polyline: { show: true },
|
||||
polygon: { show: true },
|
||||
});
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
};
|
||||
rebuild();
|
||||
h.state.enabled = false;
|
||||
h.overlay.clear();
|
||||
h.state.enabled = true;
|
||||
rebuild();
|
||||
assert.equal(sampled.length, 4, 'no camera is sampled again');
|
||||
assert.deepEqual(
|
||||
h.records.map((record) => anchorHeight(h, record.id)),
|
||||
before,
|
||||
);
|
||||
|
||||
// A camera that moved is a different question.
|
||||
h.records[0] = { ...h.records[0], latitude: h.records[0].latitude + 0.001 };
|
||||
rebuild();
|
||||
assert.equal(sampled.length, 5);
|
||||
|
||||
h.mapChanged();
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 9, 'a new map surface is sampled afresh');
|
||||
});
|
||||
@@ -66,14 +66,15 @@ export const MAX_CANVAS_FRUSTUMS = 64;
|
||||
/** Metres a floor-placed marker sits above its resolved ground cell. */
|
||||
export const MARKER_FLOOR_LIFT_M = 1.5;
|
||||
|
||||
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
|
||||
export const ANCHOR_SAMPLES_PER_PAINT = 4;
|
||||
/** Rendered-surface samples one frame may take (on photoreal tiles each is a
|
||||
* depth render of several milliseconds, so they are spread over frames). */
|
||||
export const SURFACE_SAMPLES_PER_FRAME = 1;
|
||||
/** Surface answers kept by camera id, so a refresh or re-enable asks nothing. */
|
||||
export const REMEMBERED_ANCHORS = 2048;
|
||||
|
||||
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
|
||||
* sits within it; a rooftop or an unstreamed tile usually does not). */
|
||||
export const SURFACE_BELOW_FLOOR_M = 30;
|
||||
/** Rendered-surface refinements per paint while the camera is still. */
|
||||
export const SURFACE_SAMPLES_PER_PAINT = 2;
|
||||
export const SURFACE_ABOVE_FLOOR_M = 40;
|
||||
|
||||
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
|
||||
|
||||
Reference in new issue
Block a user