refactor(overlays): use the globe engine

The world overlay host takes its frames (frames.postRender), camera events
(camera.moveEnd), canvas size (host.size), projection matrix
(projector.viewProjection), horizon test (projector.horizonVisible) and
render requests (render.request('world-overlay')) from the globe engine.
initWorldOverlay accepts an engine or, for callers that still hold a
viewer, resolves the viewer's engine through engineFromViewer.

Contract: camera.position(result) becomes camera.livePosition, one
engine-owned object holding the current position, so per-frame readers
and paint lanes keep a live camera position without allocating; engine
events gain raiseValue for argument-list-free frame dispatch. Steady-state
frame allocation is unchanged (worker probe, 30 chunks: 2696/9536/38593
B/frame before and after; detection 473749 -> 408085).
This commit is contained in:
German Khokhlov committed 2026-10-15 18:32:40 +04:00
1 parent 58a196374f
commit aaa6074663
11 files changed
+284 -134

No files matched your search

+45 -1
View File
@@ -550,6 +550,7 @@
"ui-shell": {
"exports": ["./ui/shell"],
"modules": [
"src/globe/contract.js",
"src/hudLayouts.js",
"src/hudLayoutPolicy.js",
"src/cyberSonar.js",
@@ -2528,6 +2529,27 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/globe/cesium/engineFromViewer.js",
"src/globe/cesium/createCesiumEngine.js",
"src/globe/cesium/maps.js",
"src/maps/terrain.js",
"src/maps/terrainRetry.js",
"src/maps/sceneApply.js",
"src/maps/credits.js",
"src/globe/cesium/surface.js",
"src/maps/imagery.js",
"src/services/groundSnap.js",
"src/maps/google3d.js",
"src/globe/cesium/input.js",
"src/globe/cesium/convert.js",
"src/globe/cesium/camera.js",
"src/globe/cesium/frames.js",
"src/globe/cesium/picker.js",
"src/globe/cesium/events.js",
"src/globe/cesium/render.js",
"src/globe/cesium/projector.js",
"src/globe/cesium/credits.js",
"src/globe/contract.js",
"src/cyberSonar.js",
"src/data/bhoteKoshiEvent.js",
"src/ui/imagerySplit.js",
@@ -3064,6 +3086,8 @@
"application-operations": {
"exports": ["./application/operations"],
"modules": [
"src/services/groundSnap.js",
"src/globe/contract.js",
"src/annotations/annotationResolver.js",
"src/annotations/resolver.js",
"src/app/operations.js",
@@ -3095,7 +3119,6 @@
"src/search/placeSearch.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/groundSnap.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
"src/services/terrainHeights.js",
@@ -3508,6 +3531,27 @@
"transit-standalone": {
"exports": ["./layers/transit/standalone"],
"modules": [
"src/globe/cesium/events.js",
"src/globe/cesium/picker.js",
"src/globe/cesium/frames.js",
"src/globe/cesium/camera.js",
"src/globe/cesium/convert.js",
"src/globe/cesium/input.js",
"src/maps/google3d.js",
"src/services/groundSnap.js",
"src/maps/imagery.js",
"src/globe/cesium/surface.js",
"src/maps/credits.js",
"src/maps/sceneApply.js",
"src/maps/terrainRetry.js",
"src/maps/terrain.js",
"src/globe/cesium/maps.js",
"src/globe/cesium/createCesiumEngine.js",
"src/globe/cesium/engineFromViewer.js",
"src/globe/cesium/projector.js",
"src/globe/cesium/render.js",
"src/globe/cesium/credits.js",
"src/globe/contract.js",
"src/cyberSonar.js",
"src/app/sourceSlot.js",
"src/celestialRing.js",
+3 -7
View File
@@ -111,13 +111,9 @@ export function createCesiumCamera(viewer) {
fovy: cesium.frustum.fovy,
};
},
position(result) {
const { x, y, z } = cesium.positionWC;
if (!result) return geo.cartesian3.create(x, y, z);
result.x = x;
result.y = y;
result.z = z;
return result;
// Cesium updates positionWC in place; it is the live object.
get livePosition() {
return cesium.positionWC;
},
height: () => cesium.positionCartographic.height,
setView(view) {
+3 -9
View File
@@ -347,16 +347,10 @@ test('rectangle destinations become a Cesium.Rectangle in flyTo and setView', ()
}
});
test('position fills a result from positionWC and height reads the camera cartographic', () => {
test('livePosition is the Cesium camera positionWC and height reads its cartographic', () => {
const { camera, cesium } = setup();
cesium.positionCartographic = new Cesium.Cartographic(0.1, 0.2, 1234.5);
const result = geo.cartesian3.create();
assert.equal(camera.position(result), result);
assert.deepEqual({ ...result }, { x: 7e6, y: 1e5, z: 2e5 });
const fresh = camera.position();
notCesium(fresh, 'position()');
assert.notEqual(fresh, cesium.positionWC);
assert.deepEqual({ ...fresh }, { x: 7e6, y: 1e5, z: 2e5 });
assert.equal(camera.livePosition, cesium.positionWC);
assert.equal(camera.height(), 1234.5);
});
@@ -364,5 +358,5 @@ test('the camera is the viewer camera (Viewer.camera is scene.camera)', () => {
const viewer = createFakeViewer();
const own = { ...viewer.camera, positionWC: new Cesium.Cartesian3(1, 2, 3) };
const { camera } = createCesiumCamera({ ...viewer, camera: own });
assert.deepEqual({ ...camera.position() }, { x: 1, y: 2, z: 3 });
assert.equal(camera.livePosition, own.positionWC);
});
+1 -1
View File
@@ -18,7 +18,7 @@ import { createEngineEvent } from '../contract.js';
export function bridgeEvent(source, mapArgs, { unary } = {}) {
const event = createEngineEvent();
const forward = unary
? (a, b, c) => event.raiseEvent(unary(a, b, c))
? (a, b, c) => event.raiseValue(unary(a, b, c))
: mapArgs
? (...args) => event.raiseEvent(...mapArgs(...args))
: (...args) => event.raiseEvent(...args);
+34 -11
View File
@@ -83,9 +83,10 @@
*
* @typedef {object} GlobeCamera
* @property {() => CameraPose} pose Fresh values on every call.
* @property {(result?: Cartesian3) => Cartesian3} position The pose's world
* position; fills and returns `result` when given (no allocation, for
* per-frame readers), else a fresh value.
* @property {Cartesian3} livePosition The pose's world position as one
* engine-owned object: the same object on every read, holding the current
* position (at the latest when read). For per-frame readers, which must not
* allocate; read-only, copy it to keep a value.
* @property {() => number} height The camera's height above the WGS84
* ellipsoid (the pose's cartographic height), without building a pose.
* @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView
@@ -209,9 +210,10 @@ export const isEngineEvent = (value) =>
value != null && value[ENGINE_EVENT] === true;
/**
* An event with the contract's semantics (see EngineEvent). `raiseEvent` is
* for engine implementations; a listener that throws propagates, and the
* event stays usable.
* An event with the contract's semantics (see EngineEvent). `raiseEvent` and
* `raiseValue` (one argument, no argument list: for per-frame events) are for
* engine implementations; a listener that throws propagates, and the event
* stays usable.
*/
export function createEngineEvent() {
let entries = [];
@@ -226,6 +228,13 @@ export function createEngineEvent() {
else entries.splice(entries.indexOf(entry), 1);
return true;
};
const settle = () => {
depth--;
if (!depth && removedDuringRaise) {
entries = entries.filter((entry) => entry.listener);
removedDuringRaise = false;
}
};
const event = {
get numberOfListeners() {
return entries.reduce(
@@ -259,11 +268,21 @@ export function createEngineEvent() {
if (listener) listener(...args);
}
} finally {
depth--;
if (!depth && removedDuringRaise) {
entries = entries.filter((entry) => entry.listener);
removedDuringRaise = false;
settle();
}
},
// raiseEvent(value) without an argument list, for per-frame events.
raiseValue(value) {
const snapshot = entries;
const length = snapshot.length;
depth++;
try {
for (let index = 0; index < length; index++) {
const listener = snapshot[index].listener;
if (listener) listener(value);
}
} finally {
settle();
}
},
};
@@ -282,7 +301,11 @@ const SHAPE = {
host: { container: isObject, canvas: isObject, size: isFunction },
camera: {
pose: isFunction,
position: isFunction,
livePosition: (value) =>
value != null &&
isNumber(value.x) &&
isNumber(value.y) &&
isNumber(value.z),
height: isFunction,
setView: isFunction,
flyTo: isFunction,
+17 -3
View File
@@ -188,7 +188,7 @@ test('assertGlobeEngine accepts a complete engine and names every missing capabi
test('the contract carries what the shell infrastructure needs', () => {
for (const member of [
'camera.position',
'camera.livePosition',
'camera.height',
'maps.releasePhotorealTiles',
])
@@ -196,12 +196,12 @@ test('the contract carries what the shell infrastructure needs', () => {
const engine = createFakeEngine();
const broken = {
...engine,
camera: { ...engine.camera, position: undefined, height: 1 },
camera: { ...engine.camera, livePosition: undefined, height: 1 },
maps: { ...engine.maps, releasePhotorealTiles: undefined },
};
assert.throws(
() => assertGlobeEngine(broken),
/camera\.position.*camera\.height.*maps\.releasePhotorealTiles/,
/camera\.livePosition.*camera\.height.*maps\.releasePhotorealTiles/,
);
});
@@ -256,3 +256,17 @@ test('the contract suite rejects a raw Cesium-style event that double-registers'
failed.join('\n'),
);
});
test('raiseValue raises exactly one argument with the event semantics', () => {
const event = createEngineEvent();
const seen = [];
const second = (...values) => seen.push(['second', values]);
event.addEventListener((...values) => {
seen.push(['first', values]);
event.removeEventListener(second);
});
event.addEventListener(second);
event.raiseValue(42);
assert.deepEqual(seen, [['first', [42]]]);
assert.equal(event.numberOfListeners, 1);
});
+21 -19
View File
@@ -177,38 +177,40 @@ export function createContractSuite(
},
],
[
'camera.position and camera.height: the pose position and height, without a pose',
'camera.livePosition and camera.height: the pose position and height, without a pose',
async () => {
camera.setView({
destination: cartesian3.fromDegrees(-3.7, 40.4, 12000),
orientation: { heading: 0.4, pitch: -0.5, roll: 0 },
});
await settle();
const pose = camera.pose();
const result = cartesian3.create();
const filled = camera.position(result);
ensure(
filled === result,
'position(result) returns the result it filled',
);
const live = camera.livePosition;
ensure(isCartesian3(live), 'livePosition is a Cartesian3');
near(
cartesian3.distance(filled, pose.position),
cartesian3.distance(live, camera.pose().position),
0,
1e-6,
'position(result) vs pose().position',
);
const fresh = camera.position();
ensure(
isCartesian3(fresh) && fresh !== filled,
'position() returns a fresh Cartesian3',
'livePosition vs pose().position',
);
near(
cartesian3.distance(fresh, pose.position),
camera.height(),
camera.pose().cartographic.height,
0.01,
'height()',
);
const destination = cartesian3.fromDegrees(2.35, 48.85, 30000);
camera.setView({ destination });
await settle();
ensure(
camera.livePosition === live,
'livePosition is the same engine-owned object on every read',
);
near(
cartesian3.distance(camera.livePosition, destination),
0,
1e-6,
'position() vs pose().position',
1e-3,
'livePosition follows the camera',
);
near(camera.height(), pose.cartographic.height, 0.01, 'height()');
},
],
[
+9 -8
View File
@@ -210,8 +210,10 @@ function rectangleFromCartographics(points) {
/**
* @param {{ kind?: 'cesium'|'navara', width?: number, height?: number, fovy?: number,
* longitude?: number, latitude?: number, altitude?: number }} [options]
* Canvas size in CSS px, angles in radians. The initial pose looks straight
* longitude?: number, latitude?: number, altitude?: number,
* container?: object, canvas?: object }} [options]
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
* for the host elements (DOM mocks for shell tests). The initial pose looks straight
* down from `altitude` metres (10 000 km).
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
*/
@@ -238,6 +240,7 @@ export function createFakeEngine(options = {}) {
'frames.postRender': createEngineEvent(),
};
const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
const livePosition = cartesian3.create();
const scripts = new Map();
let transform = [...matrix4.IDENTITY];
let percentageChanged = 0.5;
@@ -602,10 +605,8 @@ export function createFakeEngine(options = {}) {
fovy,
};
},
position(result) {
return result
? cartesian3.clone(state.position, result)
: copy3(state.position);
get livePosition() {
return cartesian3.clone(state.position, livePosition);
},
height: () => cartographic.fromCartesian(state.position, WGS84).height,
setView(view) {
@@ -718,8 +719,8 @@ export function createFakeEngine(options = {}) {
};
const host = {
container: { kind: 'fake-container' },
canvas: { kind: 'fake-canvas' },
container: options.container ?? { kind: 'fake-container' },
canvas: options.canvas ?? { kind: 'fake-canvas' },
size: () => ({ width, height }),
};
+19 -4
View File
@@ -354,12 +354,27 @@ test('fake drillPick records width and height; maps return promises', async () =
}
});
test('fake camera position fills a result and height is the pose height', () => {
test('fake livePosition is one object that follows the camera; height is the pose height', () => {
const engine = createFakeEngine({ altitude: 2_000_000 });
const result = geo.cartesian3.create();
assert.equal(engine.camera.position(result), result);
const live = engine.camera.livePosition;
assert.ok(
geo.cartesian3.distance(result, engine.camera.pose().position) < 1e-6,
geo.cartesian3.distance(live, engine.camera.pose().position) < 1e-6,
);
assert.ok(Math.abs(engine.camera.height() - 2_000_000) < 0.01);
engine.fake.setPose({ longitude: 0.3, latitude: 0.2, height: 500 });
assert.equal(engine.camera.livePosition, live);
assert.ok(
geo.cartesian3.distance(live, engine.camera.pose().position) < 1e-6,
);
});
test('fake host elements can be supplied', () => {
const container = { id: 'globe' };
const canvas = { id: 'canvas' };
const engine = createFakeEngine({ container, canvas });
assert.equal(engine.host.container, container);
assert.equal(engine.host.canvas, canvas);
assert.deepEqual(createFakeEngine().host.container, {
kind: 'fake-container',
});
});
+65 -71
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -27,6 +29,11 @@ import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
* @description Shared screen-space renderer/scheduler for world-anchored
* labels and cards. Sources own data, business rules, and bounded candidate
* generation; this host owns projection, final placement, paint, and hits.
*
* It runs on the page's globe engine (src/globe/contract.js): frames, camera
* events, canvas size, projection matrix, horizon test and render requests
* all come from the engine. Sources still hand it world positions they
* computed themselves.
*/
const ROOT_ID = 'world-overlay-root';
@@ -131,7 +138,7 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
* @typedef {object} WorldOverlayEntry
* @property {string} id Stable identity within the source.
* @property {string} source Owning source id.
* @property {Cesium.Cartesian3|function():Cesium.Cartesian3} position
* @property {{x: number, y: number, z: number}|function():{x: number, y: number, z: number}} position
* @property {'label'|'track'|'card'|'thumbnail'|'selected'|'tracked'} variant
* @property {string} [accessibilityLabel] Accessible name for an actionable card.
* @property {function():*} [activate] Keyboard/assistive activation callback.
@@ -148,8 +155,8 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
* @property {number} [solveIntervalMs=125]
*/
/** @type {Cesium.Viewer|null} */
let _viewer = null;
/** @type {import('../globe/contract.js').GlobeEngine|null} */
let _engine = null;
/** @type {HTMLElement|null} */
let _root = null;
/** @type {HTMLCanvasElement|null} */
@@ -218,13 +225,14 @@ const _domains = new Map();
const _domainList = [];
/** @type {Map<string, object>} */
const _records = new Map();
const _scratchViewProjection = new Cesium.Matrix4();
const _scratchViewProjection = geo.matrix4.create();
const _viewProjectionScalars = {};
const _viewport = { width: 0, height: 0 };
/** `{ isPointVisible }` over the engine's horizon test, for painters too. */
let _occluder = null;
let _occluderCameraX = Number.NaN;
let _occluderCameraY = Number.NaN;
let _occluderCameraZ = Number.NaN;
/** The engine's live camera position and the camera height, per frame. */
let _cameraPosition = null;
let _cameraHeight = Number.NaN;
/** Cached keyhole geometry, versioned by both its box and shared tuning. */
let _keyhole = null;
let _keyholeWidth = -1;
@@ -421,6 +429,7 @@ function snapshotCullPosition(entry) {
const { x, y, z } = candidate;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
return null;
// Still the sources' own vector type until they move off Cesium (phase 1c).
return new Cesium.Cartesian3(x, y, z);
}
@@ -860,7 +869,11 @@ function invalidateHost({ solve = true, layout = false } = {}) {
_paintRectCount = 0;
_hitRectCount = 0;
_paintRectByKey.clear();
_viewer?.scene?.requestRender?.();
requestRender();
}
function requestRender() {
_engine?.render.request('world-overlay');
}
function inertPaintLaneHandle() {
@@ -1227,9 +1240,10 @@ function ensureOverlayDom() {
_root = document.createElement('div');
_root.id = ROOT_ID;
_root.setAttribute('aria-hidden', 'true');
const parent = _viewer.container?.parentElement || document.body;
if (_viewer.container?.nextSibling)
parent.insertBefore(_root, _viewer.container.nextSibling);
const container = _engine.host.container;
const parent = container?.parentElement || document.body;
if (container?.nextSibling)
parent.insertBefore(_root, container.nextSibling);
else parent.appendChild(_root);
}
_canvas =
@@ -1249,7 +1263,7 @@ function ensureOverlayDom() {
// the browser silently discard the blend while the CSS string stayed
// `'screen'`. Paint order is expressed purely by z-index: this surface is
// z5, the shared card canvas inside the root is z6.
const detectionParent = _viewer?.container || document.body;
const detectionParent = _engine?.host.container || document.body;
_detectionSurface =
detectionParent.querySelector?.(`#${DETECTION_SURFACE_ID}`) ||
document.getElementById(DETECTION_SURFACE_ID);
@@ -1317,15 +1331,10 @@ function sizeCanvasSurface(canvas, ctx, width, height, dpr) {
* that actually has paint work.
*/
function ensureCanvasSize() {
if (!_canvas || !_viewer?.canvas) return false;
const width = Math.max(
0,
Math.round(Number(_viewer.canvas.clientWidth) || 0),
);
const height = Math.max(
0,
Math.round(Number(_viewer.canvas.clientHeight) || 0),
);
if (!_canvas || !_engine) return false;
const size = _engine.host.size();
const width = Math.max(0, Math.round(Number(size.width) || 0));
const height = Math.max(0, Math.round(Number(size.height) || 0));
const dpr = Math.max(1, Number(globalThis.window?.devicePixelRatio) || 1);
const changed =
_canvas.width !== Math.round(width * dpr) ||
@@ -1450,7 +1459,7 @@ function refreshUiOccluders(timestamp, force = false) {
_occluderRefreshTimer = setTimeout(
() => {
_occluderRefreshTimer = null;
_viewer?.scene?.requestRender?.();
requestRender();
},
Math.max(0, OCCLUDER_REFRESH_MS - elapsed),
);
@@ -1694,12 +1703,7 @@ function overlayHasPaintWork(timestamp = nowMs()) {
}
function prepareProjectionMatrix() {
const camera = _viewer.camera;
const matrix = Cesium.Matrix4.multiply(
camera.frustum.projectionMatrix,
camera.viewMatrix,
_scratchViewProjection,
);
const matrix = _engine.projector.viewProjection(_scratchViewProjection);
_viewProjectionScalars.m0 = matrix[0];
_viewProjectionScalars.m1 = matrix[1];
_viewProjectionScalars.m3 = matrix[3];
@@ -1717,17 +1721,9 @@ function prepareProjectionMatrix() {
function prepareCustomPaintFrame(timestamp, keyhole) {
const viewProjection = prepareProjectionMatrix();
const camera = _viewer.camera.positionWC;
if (
camera.x !== _occluderCameraX ||
camera.y !== _occluderCameraY ||
camera.z !== _occluderCameraZ
) {
_occluder.cameraPosition = camera;
_occluderCameraX = camera.x;
_occluderCameraY = camera.y;
_occluderCameraZ = camera.z;
}
const camera = _engine.camera.livePosition;
_cameraPosition = camera;
_cameraHeight = _engine.camera.height();
_customPaintFrame.canvas = _canvas;
_customPaintFrame.surface = _canvas;
_customPaintFrame.ctx = _ctx;
@@ -1749,7 +1745,7 @@ function getProjectionRecord(entry) {
record = {
key: entry._overlayKey,
entry,
position: new Cesium.Cartesian3(),
position: geo.cartesian3.create(),
screen: { x: 0, y: 0 },
layout: entry._overlayLayout,
placements: [],
@@ -1933,7 +1929,7 @@ function snapshotAndProject(entry, source, viewProjection, keyhole) {
// scalar steps stay local: the range is computed inline, and the common
// "no far limit" fade policy resolves without calling out at all. Both are
// exact restatements of `Cartesian3.distance` / `distanceFade`.
const cameraPosition = _viewer.camera.positionWC;
const cameraPosition = _cameraPosition;
const rangeX = cameraPosition.x - record.position.x;
const rangeY = cameraPosition.y - record.position.y;
const rangeZ = cameraPosition.z - record.position.z;
@@ -1946,7 +1942,7 @@ function snapshotAndProject(entry, source, viewProjection, keyhole) {
? 1
: 0
: distanceFade(distance, record.distanceOptions);
const cameraAltitude = _viewer.camera.positionCartographic?.height;
const cameraAltitude = _cameraHeight;
record.paintScale = entry.distanceScale
? distanceScale(distance, entry.distanceScale)
: 1;
@@ -2607,7 +2603,7 @@ function drawWorldOverlay() {
clearTimeout(_sonarRenderTimer);
_sonarRenderTimer = null;
}
if (_destroyed || !_viewer || !_canvas || !_ctx) return;
if (_destroyed || !_engine || !_canvas || !_ctx) return;
const timestamp = nowMs();
if (!overlayHasPaintWork(timestamp)) {
resetFrameDiagnostics();
@@ -2653,13 +2649,12 @@ function drawWorldOverlay() {
if (_paintRectCount > 0 && isCyberSonarActive()) {
_sonarRenderTimer = setTimeout(() => {
_sonarRenderTimer = null;
if (!_destroyed && isCyberSonarActive())
_viewer?.scene?.requestRender?.();
if (!_destroyed && isCyberSonarActive()) requestRender();
}, CYBER_SONAR_RENDER_INTERVAL_MS);
}
const animationsRemaining =
activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp);
if (animationsRemaining > 0) _viewer.scene.requestRender?.();
if (animationsRemaining > 0) requestRender();
}
function createDevFacade() {
@@ -2668,37 +2663,37 @@ function createDevFacade() {
}
/**
* Initialize the singleton host. Repeated calls for the same viewer are no-op.
* @param {Cesium.Viewer} viewer
* Initialize the singleton host. Repeated calls for the same engine are no-op.
* @param {import('../globe/contract.js').GlobeEngine | object} target The
* globe engine, or a Cesium viewer (its engine: engineFromViewer).
*/
export function initWorldOverlay(viewer) {
if (!viewer?.scene?.postRender?.addEventListener) {
export function initWorldOverlay(target) {
const engine = engineFromViewer(target);
if (!engine)
throw new TypeError(
'initWorldOverlay requires a Cesium viewer with scene.postRender',
'initWorldOverlay requires a globe engine or a Cesium viewer',
);
}
if (_viewer === viewer && _canvas && _removePostRender) return;
if (_viewer) destroyWorldOverlay();
if (_engine === engine && _canvas && _removePostRender) return;
if (_engine) destroyWorldOverlay();
_destroyed = false;
_viewer = viewer;
_engine = engine;
ensureOverlayDom();
_occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
viewer.camera?.positionWC || new Cesium.Cartesian3(),
);
const { projector } = engine;
_occluder = {
isPointVisible: (position) => projector.horizonVisible(position),
};
_cockpitActive = !!document.body?.classList?.contains('cockpit-mode');
_cockpitModeHandler = (event) => {
_cockpitActive = event?.detail?.active === true;
invalidateHost();
};
window.addEventListener('gev:cockpit-mode-changed', _cockpitModeHandler);
_removePostRender =
viewer.scene.postRender.addEventListener(drawWorldOverlay);
if (viewer.camera?.moveEnd?.addEventListener) {
_removeMoveEnd = viewer.camera.moveEnd.addEventListener(() =>
invalidateHost(),
);
}
_removePostRender = engine.frames.postRender.addEventListener(() =>
drawWorldOverlay(),
);
_removeMoveEnd = engine.camera.moveEnd.addEventListener(() =>
invalidateHost(),
);
installUiOccluderObservers();
// The backing store and the occluder inventory are both deferred to the
// first frame with real paint work: a dormant host must not hold a
@@ -2719,7 +2714,7 @@ export function destroyWorldOverlay() {
// Tearing down a host that was never initialized must not arm the
// post-destroy guard: sources are allowed to publish before the first
// `initWorldOverlay`, and that buffering has to survive a stray destroy.
const hadHost = _viewer !== null;
const hadHost = _engine !== null;
_removePostRender?.();
_removePostRender = null;
_removeMoveEnd?.();
@@ -2785,7 +2780,7 @@ export function destroyWorldOverlay() {
_uiOcclusionRects.length = 0;
_uiOcclusionRectPool.length = 0;
_destroyed = hadHost || _destroyed;
_viewer = null;
_engine = null;
_root = null;
_canvas = null;
_ctx = null;
@@ -2795,9 +2790,8 @@ export function destroyWorldOverlay() {
_detectionSurface = null;
_detectionCtx = null;
_occluder = null;
_occluderCameraX = Number.NaN;
_occluderCameraY = Number.NaN;
_occluderCameraZ = Number.NaN;
_cameraPosition = null;
_cameraHeight = Number.NaN;
_keyhole = null;
_keyholeWidth = -1;
_keyholeHeight = -1;
+67
View File
@@ -2297,3 +2297,70 @@ test('promoting an ambient entry to selected paints it once with no fading dupli
assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown');
} finally { env.cleanup(); }
});
// --- The host on a globe engine (src/globe contract) -------------------------
async function fakeEngineHost() {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
const engine = createFakeEngine({
width: 400,
height: 300,
container: env.viewer.container,
});
return { env, engine };
}
test('the host runs on a globe engine: frames, camera events and canvas size', async () => {
const { env, engine } = await fakeEngineHost();
initWorldOverlay(engine);
initWorldOverlay(engine);
assert.equal(engine.frames.postRender.numberOfListeners, 1);
assert.equal(engine.camera.moveEnd.numberOfListeners, 1);
const surface = env.document.getElementById('world-overlay-detection-surface');
assert.equal(surface.parentElement, env.viewer.container,
'the detection surface hangs off the engine host container');
setOverlayEntries('engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]);
engine.fake.frame();
const canvas = env.document.getElementById('world-overlay-canvas');
assert.equal(canvas.width, 400, 'sized from engine.host.size()');
assert.equal(canvas.height, 300);
assert.equal(getWorldOverlayDiagnostics().paintedCount, 1);
destroyWorldOverlay();
assert.equal(engine.frames.postRender.numberOfListeners, 0);
assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
env.cleanup();
});
test('on a globe engine the host projects with viewProjection and culls with horizonVisible', async () => {
const { env, engine } = await fakeEngineHost();
initWorldOverlay(engine);
setOverlayEntries('engine', [
selectedEntry('NEAR', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0), horizonCull: true }),
selectedEntry('FAR', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0), horizonCull: true,
cullPosition: Cesium.Cartesian3.fromDegrees(180, 0, 0) }),
]);
engine.fake.frame();
assert.equal(getOverlayPaintRect('engine', 'NEAR') !== null, true);
assert.equal(getOverlayPaintRect('engine', 'FAR'), null, 'hidden behind the globe');
const rect = getOverlayPaintRect('engine', 'NEAR');
const centre = engine.projector.worldToWindow(Cesium.Cartesian3.fromDegrees(0, 0, 0));
assert.ok(Math.abs(rect.anchorX - centre.x) < 1e-6 && Math.abs(rect.anchorY - centre.y) < 1e-6,
'anchored at the projected point');
env.cleanup();
});
test('on a globe engine the host hands painters the live camera position and requests renders by reason', async () => {
const { env, engine } = await fakeEngineHost();
initWorldOverlay(engine);
let frame = null;
registerWorldOverlayPaintLane('detection', (value) => { frame = value; },
{ id: 'detection', active: true, target: 'detection' });
setOverlayEntries('engine', [selectedEntry('CENTRE', { position: Cesium.Cartesian3.fromDegrees(0, 0, 0) })]);
assert.ok(engine.fake.callsOf('render.request').some(({ args }) => args[0] === 'world-overlay'));
engine.fake.frame();
assert.equal(frame.cameraPosition, engine.camera.livePosition);
assert.equal(frame.occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(180, 0, 0)), false);
assert.equal(frame.occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(0, 0, 0)), true);
env.cleanup();
});