diff --git a/src/app/controls.js b/src/app/controls.js index 8d9b988b5..d29e17ae4 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -5,7 +5,7 @@ import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; /** Construct the existing controls and camera presentation. */ export function createApplicationControls({ - scene: { viewer, mapStackController, operations }, + scene: { viewer, engine, mapStackController, operations }, loaderStatus, Controls = StyleManager, services, @@ -24,6 +24,8 @@ export function createApplicationControls({ ...catalogControlServices(catalog), }, requestServices: operations.requests, + // The page's globe engine: the shell's overlay host runs on it. + engine, mapStackController, placeSearch, }); diff --git a/src/app/controls.test.mjs b/src/app/controls.test.mjs new file mode 100644 index 000000000..0c0d520fd --- /dev/null +++ b/src/app/controls.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +// controls.js and the shell import browser-only UI modules, so these read the +// source: the scene engine is threaded to the shell, which hands it (not the +// viewer) to the world overlay. initWorldOverlay itself is tested on the fake +// engine without a viewer (src/overlays/worldOverlay.test.mjs). +const read = (relative) => + readFileSync(new URL(relative, import.meta.url), 'utf8'); + +test('the controls pass the scene engine to the shell', () => { + const controls = read('./controls.js'); + assert.match(controls, /scene: \{ viewer, engine, mapStackController, operations \}/); + assert.match(controls, /new Controls\(viewer, \{[\s\S]*?\n\s+engine,\n/); +}); + +test('the shell initializes the world overlay on its engine', () => { + const shell = read('../ui/applicationShell.js'); + assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/); + assert.match(shell, /initWorldOverlay\(engine\);/); + assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/); +}); diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index c72782f91..27f80e0b6 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -80,9 +80,9 @@ test('the scene engine is the one engineFromViewer returns for its viewer until assert.equal(engineFromViewer(viewer), engine); for (const cleanup of cleanups.reverse()) cleanup(); assert.equal(engine.fake.destroyed, true); - assert.throws( - () => engineFromViewer(viewer), - /createCesiumEngine needs a viewer/, - 'no longer registered: a fresh one would be built', + assert.equal( + engineFromViewer(viewer), + null, + 'no longer registered, and none is built', ); }); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 06bfe01af..827a6205b 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; +import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; import { countFadingRenderEntries, destroyDetection, @@ -247,8 +248,11 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } scene: { postRender, requestRender() {} }, }; + // Shell modules take the globe engine; the Cesium engine runs on the mock. + const engine = createCesiumEngine(viewer); return { viewer, + engine, postRender, document, ctx, @@ -259,6 +263,7 @@ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } cleanup() { destroyDetection(); destroyWorldOverlay(); + engine.destroy(); Date.now = originalDateNow; if (originalPath2D === undefined) delete globalThis.Path2D; else globalThis.Path2D = originalPath2D; @@ -353,7 +358,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste const env = installEnvironment({ search: '?detectDebug=1' }); const modes = []; try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [detectableLayer()], (mode) => modes.push(mode)); assert.equal(env.postRender.listeners.size, 1); assert.equal(env.document.getElementById('detection-overlay'), null); @@ -507,7 +512,7 @@ test('callouts stop painting the moment the last detectable object goes away', ( type: 'AIR', }]; try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {}); setMode('DENSE'); env.advance(250); @@ -543,7 +548,7 @@ test('every style paints its OWN plate token through the production callout path // path once per style and reads the fill style the shared canvas received. const env = installEnvironment(); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [mixedTierLayer()], () => {}); setMode('DENSE'); settleFrame(env); @@ -581,7 +586,7 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig // tiers at once can show it. const env = installEnvironment(); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [mixedTierLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); @@ -609,7 +614,7 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig test('surveillance military tier reaches the dedicated render target as shipped red', () => { const env = installEnvironment(); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [militaryLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); @@ -631,7 +636,7 @@ test('pathological detection paint holds alternate frames without freezing share // Opts into the telemetry banner: the held-frame assertions count banner paints. const env = installEnvironment({ search: '?detectDebug=1' }); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [detectableLayer()], () => {}); setOverlayEntries('valve-card', [{ id: 'ambient-card', @@ -738,7 +743,7 @@ test('the mode banner is absent by default and present behind the flag', () => { const painted = (search) => { const env = installEnvironment({ search }); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); @@ -765,7 +770,7 @@ test('detection telemetry stays reachable programmatically with the banner hidde // is the supported way to read them, and is what the QA harnesses use. const env = installEnvironment(); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); @@ -793,7 +798,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse metric: 'FL120', type: 'AIR', })); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {}); setDetectionTuning({ densityPct: 25 }); setMode('SPARSE'); @@ -851,7 +856,7 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', ( // the shared canvas actually received for each plate. const env = installEnvironment(); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection(env.viewer, [backdropLayer()], () => {}); setDetectionStyle('normal'); setMode('DENSE'); @@ -909,7 +914,7 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share }, ]; try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); initDetection( env.viewer, [{ id: 'transit', getDetectableObjects: () => objects }], diff --git a/src/globe/cesium/engineFromViewer.js b/src/globe/cesium/engineFromViewer.js index c4142848e..9f926b071 100644 --- a/src/globe/cesium/engineFromViewer.js +++ b/src/globe/cesium/engineFromViewer.js @@ -1,12 +1,10 @@ // The bridge for shell callers that still hold only a Cesium viewer (layers -// until phase 1c, standalone entries, tests): the engine of that viewer. The -// app registers its scene engine (src/app/globeEngine.js); any other viewer -// gets one built on first use and cached. Building one changes nothing in -// the viewer, and it is never destroyed here: its subscriptions live only as -// long as their callers keep them. +// until phase 1c): the engine registered for that viewer. The app registers +// its scene engine (src/app/globeEngine.js) and unregisters it before +// destroying it. Nothing is ever built here, so no engine can outlive its +// viewer without a destroy path: an unregistered or torn-down viewer has none. import { isGlobeEngine } from '../contract.js'; -import { createCesiumEngine } from './createCesiumEngine.js'; const engines = new WeakMap(); @@ -24,17 +22,11 @@ export function registerViewerEngine(viewer, engine) { /** * @param {object | null | undefined} viewer A Cesium viewer, or an engine * (returned as is). - * @param {Parameters[1]} [options] Used only when - * an engine has to be built. - * @returns {import('../contract.js').GlobeEngine | null} + * @returns {import('../contract.js').GlobeEngine | null} The registered + * engine, or null. */ -export function engineFromViewer(viewer, options) { +export function engineFromViewer(viewer) { if (viewer == null) return null; if (isGlobeEngine(viewer)) return viewer; - let engine = engines.get(viewer); - if (!engine) { - engine = createCesiumEngine(viewer, options); - engines.set(viewer, engine); - } - return engine; + return engines.get(viewer) ?? null; } diff --git a/src/globe/cesium/engineFromViewer.test.mjs b/src/globe/cesium/engineFromViewer.test.mjs index 65198be54..ffcd76ed3 100644 --- a/src/globe/cesium/engineFromViewer.test.mjs +++ b/src/globe/cesium/engineFromViewer.test.mjs @@ -5,28 +5,25 @@ import { createFakeEngine } from '../testing/fakeEngine.js'; import { engineFromViewer, registerViewerEngine } from './engineFromViewer.js'; import { createFakeViewer } from './testing/fakeViewer.mjs'; -test('a viewer without a registered engine gets one built once and cached', () => { +test('a viewer without a registered engine has none: nothing is built', () => { const viewer = createFakeViewer(); - const engine = engineFromViewer(viewer); - assert.equal(isGlobeEngine(engine), true); - assert.equal(engine.kind, 'cesium'); - assert.equal(engineFromViewer(viewer), engine); - assert.deepEqual(viewer.calls, [], 'building it changes nothing'); - assert.notEqual(engineFromViewer(createFakeViewer()), engine); + assert.equal(engineFromViewer(viewer), null); + assert.deepEqual(viewer.calls, []); }); -test('the engine registered for a viewer is the one returned', () => { +test('the engine registered for a viewer is returned until it is unregistered', () => { const viewer = createFakeViewer(); const engine = createFakeEngine(); const unregister = registerViewerEngine(viewer, engine); assert.equal(engineFromViewer(viewer), engine); unregister(); - const built = engineFromViewer(viewer); - assert.notEqual(built, engine); + assert.equal(engineFromViewer(viewer), null, 'a torn-down viewer has none'); + const next = createFakeEngine(); + registerViewerEngine(viewer, next); unregister(); assert.equal( engineFromViewer(viewer), - built, + next, 'a stale unregister keeps the new one', ); }); diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index e1147c63c..78b3e682d 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; +import { isGlobeEngine } from '../globe/contract.js'; import { createCyberSonarSampler, isCyberContactSonarActive as isCyberSonarActive, @@ -2664,15 +2664,11 @@ function createDevFacade() { /** * 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). + * @param {import('../globe/contract.js').GlobeEngine} engine */ -export function initWorldOverlay(target) { - const engine = engineFromViewer(target); - if (!engine) - throw new TypeError( - 'initWorldOverlay requires a globe engine or a Cesium viewer', - ); +export function initWorldOverlay(engine) { + if (!isGlobeEngine(engine)) + throw new TypeError('initWorldOverlay requires a globe engine'); if (_engine === engine && _canvas && _removePostRender) return; if (_engine) destroyWorldOverlay(); _destroyed = false; diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 23bd65d3e..34676a655 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; +import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; import { getKeyholeGeometry, keyholeLabelAlphaFromGeometry, @@ -379,8 +380,11 @@ function installMockEnvironment({ }, }; + // Shell modules take the globe engine; the Cesium engine runs on the mock. + const engine = createCesiumEngine(viewer); return { viewer, + engine, ctx, detectionCtx, paintTrace, @@ -395,6 +399,7 @@ function installMockEnvironment({ advanceTime(ms) { currentTime += ms; }, cleanup() { destroyWorldOverlay(); + engine.destroy(); delete globalThis.document; delete globalThis.window; delete globalThis.ResizeObserver; @@ -435,7 +440,7 @@ test('destroying before the first init leaves pre-init buffering intact', () => setOverlayEntries('pre-init', [selectedEntry('buffered')]); assert.equal(getWorldOverlayDiagnostics().entryCount, 1); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); env.postRender.raise(); assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); @@ -450,8 +455,8 @@ test('destroying before the first init leaves pre-init buffering intact', () => test('lifecycle is idempotent and teardown removes listeners, observers, and DOM', () => { const env = installMockEnvironment(); assert.equal(env.document.getElementById('world-overlay-detection-surface'), null); - initWorldOverlay(env.viewer); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); + initWorldOverlay(env.engine); assert.equal(env.postRender.listeners.size, 1); assert.equal(env.moveEnd.listeners.size, 1); assert.equal(env.window.listenerCount('gev:cockpit-mode-changed'), 1); @@ -553,7 +558,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene' // changed was containment. This walks the ancestor chain the host actually // builds and evaluates each node against the shipped stylesheet. const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const css = readStylesheet(new URL('../../style.css', import.meta.url)); const surface = env.document.getElementById('world-overlay-detection-surface'); assert.ok(surface, 'the host owns a detection surface'); @@ -585,7 +590,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene' test('canvas backing store tracks CSS size and live DPR', () => { const env = installMockEnvironment({ width: 400, height: 300, dpr: 2 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); let canvas = env.document.getElementById('world-overlay-canvas'); let detectionSurface = env.document.getElementById('world-overlay-detection-surface'); assert.equal(canvas.width, 0); @@ -619,7 +624,7 @@ test('shared fade tuning reaches a host-painted card on the next rendered frame' }); try { setKeyholeFadeTuning({ fadeRatio: 0.16, outsideOpacity: 0.05 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('fade-host', [selectedEntry('CARD', { position: new Cesium.Cartesian3(0.85, 0, 0), variant: 'card', @@ -649,7 +654,7 @@ test('inlined host alpha binding matches combinedOverlayAlpha across channel ran get() { return paintedAlphas.at(-1) ?? 1; }, set(value) { paintedAlphas.push(value); }, }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const cases = [ { source: 1, entry: 0.9, temporal: 0.8, distance: 0.25, altitude: 0.25 }, { source: 0.8, entry: 0.75, temporal: 0.7, distance: 0.5, altitude: 0.5 }, @@ -695,7 +700,7 @@ test('inlined host alpha binding matches combinedOverlayAlpha across channel ran test('a host dormant since init holds no canvas backing store', () => { const env = installMockEnvironment({ width: 1600, height: 900, dpr: 2 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const canvas = env.document.getElementById('world-overlay-canvas'); const detectionSurface = env.document.getElementById('world-overlay-detection-surface'); assert.equal(canvas.width, 0); @@ -733,7 +738,7 @@ test('a host dormant since init holds no canvas backing store', () => { test('empty postRender stays inert through production mutation, resize, and clock noise', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); env.postRender.raise(); const settledCallCount = env.ctx.calls.length; const settledLayoutReads = { ...env.layoutReads }; @@ -752,7 +757,7 @@ test('empty postRender stays inert through production mutation, resize, and cloc test('HUD mutations defer occluder selector scans until overlay paint work exists', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const settledQueries = { ...env.selectorQueries }; const hudMutation = { type: 'childList', @@ -777,7 +782,7 @@ test('ticking chrome churn (text swaps, non-chrome nodes) never invalidates a li { id: 'traffic-sync-chip', rect: { left: 200, top: 0, width: 90, height: 24 } }, ], }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('ticker-noise', [selectedEntry('live-entry')]); env.postRender.raise(); @@ -819,7 +824,7 @@ test('genuine chrome changes still invalidate: add, remove, and own-attribute fl { id: 'pp-toggles', rect: { left: 300, top: 40, width: 60, height: 200 } }, ], }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('chrome-changes', [selectedEntry('live-entry')]); env.postRender.raise(); const observer = env.mutationObservers[0]; @@ -859,7 +864,7 @@ test('chrome observers scope: body is childList discovery; occluder attributes a { selector: '.hud-top-left', rect: { left: 0, top: 0, width: 140, height: 44 } }, ], }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('observer-scope', [selectedEntry('live-entry')]); env.postRender.raise(); @@ -909,7 +914,7 @@ test('projection records never retain keys for entries that are no longer live', const env = installMockEnvironment(); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); for (let batch = 0; batch < 4; batch++) { const entries = Array.from({ length: 320 }, (_, index) => ( selectedEntry(`batch-${batch}-${index}`, { @@ -944,7 +949,7 @@ test('projection records never retain keys for entries that are no longer live', test('the per-domain candidate index stays bounded while identities churn', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const COHORT = 40; const BATCHES = 60; const batchEntries = (batch) => Array.from({ length: COHORT }, (_, index) => ( @@ -1003,7 +1008,7 @@ test('entry normalization validates required fields and source lifecycle is stab assert.throws(() => normalizeOverlayEntry('fires', { id: 'a', position: position(), variant: 'table' }), /variant/); const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('fires', [normalized]); upsertOverlayEntry('fires', { id: 'b', position: position(), variant: 'card' }); assert.deepEqual(getWorldOverlayDiagnostics().entriesBySource, { fires: 2 }); @@ -1019,7 +1024,7 @@ test('accessible actions announce only accepted focus and expose selected state' const env = installMockEnvironment(); let acceptedActivations = 0; let staleActivations = 0; - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('accessible-actions', [selectedEntry('vessel:123', { interactive: true, accessibilityLabel: 'Focus vessel TEST, MMSI 123', @@ -1092,7 +1097,7 @@ test('viewport and horizon culling obey explicit policy inputs', () => { test('paint lanes are deterministic across all seven binding lanes', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); assert.deepEqual(WORLD_OVERLAY_PAINT_LANES, [ 'detection', 'ambient-label', @@ -1136,7 +1141,7 @@ test('custom detection lane receives the shared host frame and paints below ordi dpr: 2, occluderRect: { left: 10, top: 20, width: 80, height: 40 }, }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); let capturedFrame = null; let paintCount = 0; const lane = registerWorldOverlayPaintLane('detection', (frame) => { @@ -1200,7 +1205,7 @@ test('custom-lane painter state is bracketed by host save/restore', () => { // host wraps every custom-lane painter in save/try/finally-restore. Pin the // bracketing structurally (the mock ctx records call order, not state). const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const lane = registerWorldOverlayPaintLane('detection', (frame) => { frame.ctx.beginPath(); frame.ctx.rect(0, 0, 1, 1); @@ -1227,7 +1232,7 @@ test('custom-lane painter state is bracketed by host save/restore', () => { test('custom lanes without the detection target paint on the shared canvas', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); let target = null; registerWorldOverlayPaintLane('ambient-label', (frame) => { target = { surface: frame.surface, ctx: frame.ctx }; @@ -1279,7 +1284,7 @@ test('UI exclusions stay per-rect: overlapping chrome never merges into a boundi { id: 'command-dock', rect: chrome.bottom }, ], }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); let uiRects = null; registerWorldOverlayPaintLane('detection', (frame) => { uiRects = frame.uiRects.slice(0, frame.uiRectCount) @@ -1336,7 +1341,7 @@ test('UI exclusions ignore chrome the user cannot see', () => { { id: 'pp-toggles', rect: { left: 0, top: 0, width: 0, height: 0 } }, ], }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); let uiRectCount = -1; registerWorldOverlayPaintLane('detection', (frame) => { uiRectCount = frame.uiRectCount; }, { id: 'detection', @@ -1363,7 +1368,7 @@ test('chrome that fills the viewport suppresses neither cards nor the detection dpr: 1, occluderRect: { left: 0, top: 0, width: 400, height: 300 }, }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); let detectionPaints = 0; registerWorldOverlayPaintLane('detection', (frame) => { detectionPaints++; @@ -1402,7 +1407,7 @@ test('a placement is only ever kept under chrome that composites ABOVE the host' const paintedUnder = (occluders) => { const env = installMockEnvironment({ width: 400, height: 300, dpr: 1, occluders }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('ambient', [selectedEntry('CONTACT')]); env.postRender.raise(); const { paintedCount } = getWorldOverlayDiagnostics(); @@ -1446,7 +1451,7 @@ test('cockpit keeps its cards and its detection lane, and hides only the tracked { selector: '.cockpit-roll-arc', inInventory: false, rect: { left: 88, top: 2, width: 224, height: 32 } }, ], }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); let uiRectCount = -1; let detectionPaints = 0; registerWorldOverlayPaintLane('detection', (frame) => { @@ -1492,7 +1497,7 @@ test('opt-in anchor separation thins a co-located cohort before the arbiter sees const anchors = [500, 540, 580, 620, 660, 700]; const project = (separation) => { const env = installMockEnvironment({ width: 400, height: 800, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('cctv', anchors.map((y, index) => selectedEntry(`cam-${index}`, { position: positionAtScreen(200, y, 400, 800), protected: false, @@ -1532,7 +1537,7 @@ test('the occluder inventory carries no cockpit line-art chrome', () => { test('position getters snapshot once per frame and cockpit-gated sources disappear', () => { const env = installMockEnvironment(); let getterCalls = 0; - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('tracked', [selectedEntry('flight', { position: () => { getterCalls++; return position(); }, interactive: true, @@ -1568,7 +1573,7 @@ test('bounded per-source cohorts feed the domain arbiter without universal selec assert.equal(selectBoundedOverlayCohort([...normalized, protectedEntry], 3).length, 4); const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('bulk', normalized, { cohortLimit: 3, collisionCapacity: 2 }); env.postRender.raise(); const diagnostics = getWorldOverlayDiagnostics(); @@ -1580,7 +1585,7 @@ test('bounded per-source cohorts feed the domain arbiter without universal selec test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregate budget', () => { const env = installMockEnvironment({ width: 1600, height: 900, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const makeEntries = (sourceOffset, count = 200) => Array.from({ length: count }, (_, index) => { const slot = index % 160; const column = slot % 16; @@ -1637,7 +1642,7 @@ test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregat test('paint rectangles publish from a pool and topmost interactive lookup wins', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('hits', [ selectedEntry('under', { interactive: true, zIndex: 1, collisionGroup: 'under' }), selectedEntry('over', { interactive: true, zIndex: 2, collisionGroup: 'over' }), @@ -1662,7 +1667,7 @@ test('paint rectangles publish from a pool and topmost interactive lookup wins', test('CCTV stable frame slots gate ambient chrome and wire the exact host hit rectangle', () => { const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const slot = createFrameSlot(); const entry = createCctvThumbnailOverlayEntry({ id: 'cam-a', @@ -1711,7 +1716,7 @@ test('CCTV stable frame slots gate ambient chrome and wire the exact host hit re test('pinned CCTV chrome may paint before frame one while ordinary safe-top policy still yields', () => { const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const ambient = createCctvThumbnailOverlayEntry({ id: 'ambient-top', position: new Cesium.Cartesian3(0, 0.9, 0), @@ -1741,7 +1746,7 @@ test('safe-top yield culls an uncontested ambient entry at projection, with a be // pinned neighbor to arbiter-collide with, so only the projection gate can // null it; the below-band twin proves the paint path itself is live. const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const above = createCctvThumbnailOverlayEntry({ id: 'yield-above', position: new Cesium.Cartesian3(-0.5, 0.9, 0), @@ -1779,7 +1784,7 @@ test('CCTV host binding preserves the smoothstep scale and 0.45/0.35 fade curve' get() { return paintedAlphas.at(-1) ?? 1; }, set(value) { paintedAlphas.push(value); }, }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const entry = createCctvThumbnailOverlayEntry({ id: 'altitude-curve', position: position(), @@ -1830,7 +1835,7 @@ test('scene presentation samples once per frame and recovers after a callback fa get() { return alphas.at(-1) ?? 1; }, set(value) { alphas.push(value); }, }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const entry = createCctvThumbnailOverlayEntry({ id: 'scene-presentation', position: position(), title: 'SCENE', frameSlot: { frame: { ready: true }, stamp: 1 }, active: true, @@ -1904,7 +1909,7 @@ test('scene presentation samples once per frame and recovers after a callback fa test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => { const env = installMockEnvironment({ width: 500, height: 400, dpr: 1 }); env.viewer.camera.positionCartographic.height = 9500; - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const entry = createCctvThumbnailOverlayEntry({ id: 'scaled-leader', position: position(), @@ -1930,7 +1935,7 @@ test('CCTV leader remains one CSS pixel at the 0.35 altitude scale point', () => test('active CCTV thumbnail is protected outside the ambient quota and excludes ambient footprint', () => { const env = installMockEnvironment({ width: 800, height: 600, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const readySlot = () => ({ frame: { ready: true }, stamp: 1 }); const active = createCctvThumbnailOverlayEntry({ id: 'active', @@ -1967,7 +1972,7 @@ test('active CCTV thumbnail is protected outside the ambient quota and excludes test('protected tracked entry bypasses ambient quota and excludes its paint footprint', () => { const env = installMockEnvironment({ width: 800, height: 600, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const position = new Cesium.Cartesian3(0, 0, 0); setOverlayEntries('ambient-source', [{ id: 'ambient', @@ -2010,7 +2015,7 @@ test('protected tracked entry bypasses ambient quota and excludes its paint foot test('three clustered protected cards fall back to the least-overlapping placement', () => { const env = installMockEnvironment({ width: 800, height: 600, dpr: 1 }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const anchor = new Cesium.Cartesian3(0, 0, 0); setOverlayEntries('protected-cluster', [ selectedEntry('wide-first', { @@ -2051,7 +2056,7 @@ test('three clustered protected cards fall back to the least-overlapping placeme test('steady frames reuse paint-item and paint-rectangle pool identities', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('pool-reuse', [selectedEntry('ambient', { selected: false, protected: false, @@ -2078,7 +2083,7 @@ test('steady frames reuse paint-item and paint-rectangle pool identities', () => test('destroy empties paint pools and releases pooled record and entry payloads', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('pool-release', [selectedEntry('payload', { metadata: { retainedProbe: new Uint8Array(1024) }, })]); @@ -2095,7 +2100,7 @@ test('destroy empties paint pools and releases pooled record and entry payloads' assert.equal(released.paintItemPoolSize, 0); assert.equal(released.paintRectPoolSize, 0); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('pool-release', [selectedEntry('next-payload')]); env.postRender.raise(); assert.notEqual(getOverlayPaintRect('pool-release', 'next-payload'), firstRect); @@ -2109,7 +2114,7 @@ function togglablePosition(box) { test('a candidate that is not projected this frame stops painting immediately', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const box = { hidden: false }; setOverlayEntries('stamp', [selectedEntry('ambient', { selected: false, @@ -2136,7 +2141,7 @@ test('a candidate that is not projected this frame stops painting immediately', test('published paint rectangles are scoped to the publishing key and frame', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const first = { hidden: false }; const second = { hidden: false }; setOverlayEntries('rects', [ @@ -2171,7 +2176,7 @@ test('published paint rectangles are scoped to the publishing key and frame', () test('every exported entry point is inert after destroy', () => { const env = installMockEnvironment(); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('post-destroy', [selectedEntry('live', { interactive: true })]); env.postRender.raise(); assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); @@ -2206,7 +2211,7 @@ test('every exported entry point is inert after destroy', () => { assert.equal(env.window.listenerCount('resize'), 0); // A later init still produces a fully working host. - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); setOverlayEntries('post-destroy', [selectedEntry('revived', { interactive: true })]); env.postRender.raise(); assert.equal(getWorldOverlayDiagnostics().paintedCount, 1); @@ -2246,7 +2251,7 @@ test('installation titles share persistent card arbitration and retain a bounded overlayHost: { setEntries: setOverlayEntries, clearSource: clearOverlaySource, setVisible: setOverlaySourceVisible, getDiagnostics: getWorldOverlayDiagnostics }, }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); markers.enable(); const records = Array.from({ length: 40 }, (_, i) => ({ id: `site:${i}`, name: `Base ${i}`, namedArea: true, pointOnly: i % 2 === 0, areaM2: 1000 - i, @@ -2285,7 +2290,7 @@ test('installation titles share persistent card arbitration and retain a bounded test('promoting an ambient entry to selected paints it once with no fading duplicate', () => { const env = installMockEnvironment(); try { - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const entry = { id: 'site', position: position(), title: 'One site', variant: 'card', collisionGroup: 'ambient-card', horizonCull: false, edgeFade: false }; const paint = () => { env.ctx.calls.length = 0; env.postRender.raise(); return env.ctx.calls.filter(([op, text]) => op === 'fillText' && text === 'One site').length; }; setOverlayEntries('military-installations', [entry]); paint(); env.advanceTime(200); @@ -2364,3 +2369,12 @@ test('on a globe engine the host hands painters the live camera position and req assert.equal(frame.occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(0, 0, 0)), true); env.cleanup(); }); + +test('the host takes a globe engine and refuses a bare viewer', async () => { + const { env, engine } = await fakeEngineHost(); + assert.throws(() => initWorldOverlay(env.viewer), /requires a globe engine/); + assert.throws(() => initWorldOverlay(null), /requires a globe engine/); + initWorldOverlay(engine); + assert.equal(engine.frames.postRender.numberOfListeners, 1); + env.cleanup(); +}); diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index ea463f080..3ac5792c5 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; import { AMBIENT_CARD_COLLISION_CAPACITY, destroyWorldOverlay, @@ -316,6 +317,8 @@ function installMockEnvironment({ width, height, dpr }) { return { viewer, + // Shell modules take the globe engine; the Cesium engine runs on the mock. + engine: createCesiumEngine(viewer), postRender, advanceTime(ms) { currentTime += ms; @@ -1174,7 +1177,7 @@ function main() { height: VIEWPORT_HEIGHT, dpr: 2, }); - initWorldOverlay(env.viewer); + initWorldOverlay(env.engine); const workload = PROFILE === 'phase6-detection' ? buildPhase6DetectionWorkload(ENTRY_COUNT) @@ -1318,6 +1321,7 @@ function main() { if (detectionActive) destroyDetection(); destroyWorldOverlay(); + env.engine.destroy(); } main(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 76b708de0..0fda3daee 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -61,11 +61,18 @@ import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; export class StyleManager extends ShellFacade { /** * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. - * @param {object} [options] + * @param {object} [options] `engine`: the page's globe engine (the world + * overlay host runs on it). */ constructor( viewer, - { mapStackController = null, placeSearch, services, requestServices } = {}, + { + engine = null, + mapStackController = null, + placeSearch, + services, + requestServices, + } = {}, ) { super(); const { @@ -460,7 +467,7 @@ export class StyleManager extends ShellFacade { // The shared world-overlay host must own its one postRender lane before // detection and tracked-readout initialize. It stays transparent until a // production source explicitly registers entries. - initWorldOverlay(viewer); + initWorldOverlay(engine); // Initialize detection overlay BEFORE style stages so the composite // stage is first in the post-process pipeline