From aaa60746638f66dc4bf9ced0590eb61b27b59765 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 18:32:40 +0400 Subject: [PATCH] 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). --- scripts/package-boundaries.json | 46 ++++++++- src/globe/cesium/camera.js | 10 +- src/globe/cesium/camera.test.mjs | 12 +-- src/globe/cesium/events.js | 2 +- src/globe/contract.js | 45 ++++++--- src/globe/contract.test.mjs | 20 +++- src/globe/testing/contractSuite.mjs | 40 ++++---- src/globe/testing/fakeEngine.js | 17 ++-- src/globe/testing/fakeEngine.test.mjs | 23 ++++- src/overlays/worldOverlay.js | 136 ++++++++++++-------------- src/overlays/worldOverlay.test.mjs | 67 +++++++++++++ 11 files changed, 284 insertions(+), 134 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 200cf817a..76540e1a2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index 4d0eaee8a..f3a9375c4 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -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) { diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index 2d372e633..192ba91e0 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -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); }); diff --git a/src/globe/cesium/events.js b/src/globe/cesium/events.js index b45b31d30..250977c4f 100644 --- a/src/globe/cesium/events.js +++ b/src/globe/cesium/events.js @@ -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); diff --git a/src/globe/contract.js b/src/globe/contract.js index 579b43778..efde38fff 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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, diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index f94569e2c..f128974e2 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -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); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index fc388b630..309c8778b 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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()'); }, ], [ diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index a275cb3b1..b20fa9da5 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -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 }), }; diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 0f8e0f7a1..3b2ad1808 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -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', + }); }); diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 660ba56fe..e1147c63c 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -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} */ 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; diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index b2060420a..23bd65d3e 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -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(); +});