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