From a5c98c6b071b16b6ca35813be2d8e24d9a9e6274 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 18:02:11 +0400 Subject: [PATCH] feat(app): construct the globe engine with the viewer The scene builds the page's Cesium engine right after the viewer (src/app/globeEngine.js), names the startup Google tileset to it once loaded, returns it beside the viewer and destroys it before the viewer. The debug handle exposes it as window.__godsEyeView.engine. Nothing consumes it yet; render holds taken before the governor is installed apply at install. qa-globe-contract now runs on the app's engine (self-built only when the app has none); a throwaway engine checks creation and destroy leave the scene untouched. --- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 14 +++++++ src/app/application.test.mjs | 26 +++++++++++++ src/app/globeEngine.js | 23 +++++++++++ src/app/globeEngine.test.mjs | 67 +++++++++++++++++++++++++++++++++ src/app/scene.js | 8 +++- src/app/tools.js | 3 +- 7 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/app/globeEngine.js create mode 100644 src/app/globeEngine.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 84777f813..daa945d3e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -736,6 +736,8 @@ "src/globe/cesium/createCesiumEngine.test.mjs", "src/globe/cesium/testing/fakeViewer.mjs", "scripts/qa-globe-contract.mjs", + "src/app/globeEngine.js", + "src/app/globeEngine.test.mjs", "src/sources/reference.js", "src/sources/reference.test.mjs", "src/search/nominatim.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ed8a4fc4b..1203467cc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1737,6 +1737,20 @@ "src/app/layers/traffic.js", "src/app/operations.js", "src/app/scene.js", + "src/app/globeEngine.js", + "src/globe/contract.js", + "src/globe/cesium/createCesiumEngine.js", + "src/globe/cesium/camera.js", + "src/globe/cesium/convert.js", + "src/globe/cesium/credits.js", + "src/globe/cesium/events.js", + "src/globe/cesium/frames.js", + "src/globe/cesium/input.js", + "src/globe/cesium/maps.js", + "src/globe/cesium/picker.js", + "src/globe/cesium/projector.js", + "src/globe/cesium/render.js", + "src/globe/cesium/surface.js", "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index d3ef8c7cc..679e7c0ef 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -83,6 +83,32 @@ test('startup shares one promise and teardown keeps data alive for controls', as await assert.rejects(app.start(), /destroyed/); }); +test('later components receive the scene engine beside its viewer', async () => { + const viewer = { name: 'viewer' }; + const engine = { kind: 'cesium' }; + const seen = []; + const receive = + (phase) => + ({ scene }) => { + seen.push([phase, scene.viewer, scene.engine]); + return { name: phase }; + }; + const { app } = fixture({ + createScene: () => ({ viewer, engine }), + createControls: receive('Controls'), + createData: receive('Data'), + createTools: receive('Tools'), + }); + const components = await app.start(); + assert.equal(components.scene.engine, engine); + assert.deepEqual(seen, [ + ['Controls', viewer, engine], + ['Data', viewer, engine], + ['Tools', viewer, engine], + ]); + await app.destroy(); +}); + for (const phase of phases) { test(`failure during ${phase} releases partially acquired resources`, async () => { const { app, events } = fixture({ diff --git a/src/app/globeEngine.js b/src/app/globeEngine.js new file mode 100644 index 000000000..e1e67f9b7 --- /dev/null +++ b/src/app/globeEngine.js @@ -0,0 +1,23 @@ +import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; + +/** + * Build the page's one globe engine on the application viewer and tie its + * teardown to the scene lifetime. Call after the viewer's own cleanup is + * deferred, so the engine is destroyed first. Render holds taken before the + * render governor is installed apply when it is (src/renderGovernor.js). + * @param {Cesium.Viewer} viewer + * @param {{ + * tilesetRef: { current: object | null } | (() => object | null) | null, + * defer: (dispose: () => void) => void, + * createEngine?: typeof createCesiumEngine, + * }} options + * @returns {import('../globe/contract.js').GlobeEngine} + */ +export function createSceneEngine( + viewer, + { tilesetRef, defer, createEngine = createCesiumEngine }, +) { + const engine = createEngine(viewer, { tilesetRef }); + defer(() => engine.destroy()); + return engine; +} diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs new file mode 100644 index 000000000..194136b6d --- /dev/null +++ b/src/app/globeEngine.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSceneEngine } from './globeEngine.js'; +import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); + +test('the scene engine is built on the viewer and destroyed by the scene lifetime', () => { + const viewer = { name: 'viewer' }; + const tilesetRef = { current: null }; + const cleanups = []; + const calls = []; + const engine = createSceneEngine(viewer, { + tilesetRef, + defer: (dispose) => cleanups.push(dispose), + createEngine(target, options) { + calls.push([target, options]); + return { kind: 'cesium', destroy: () => calls.push('destroy') }; + }, + }); + assert.equal(engine.kind, 'cesium'); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], viewer); + assert.equal(calls[0][1].tilesetRef, tilesetRef); + assert.equal(cleanups.length, 1, 'engine teardown is deferred at once'); + cleanups[0](); + assert.equal(calls.at(-1), 'destroy'); +}); + +test('the scene engine defaults to the Cesium engine factory', () => { + assert.throws( + () => createSceneEngine({}, { tilesetRef: null, defer: () => {} }), + /createCesiumEngine needs a viewer/, + ); + assert.equal(typeof createCesiumEngine, 'function'); +}); + +test('the scene constructs the engine with the viewer and returns it beside the viewer', () => { + const scene = read('src/app/scene.js'); + const viewerTeardown = scene.indexOf('uninstallRenderGovernor(viewer);'); + const engineAt = scene.indexOf('createSceneEngine(viewer, {'); + assert.ok(viewerTeardown >= 0 && engineAt >= 0); + // Cleanups run last-in first-out: the engine goes before the viewer. + assert.ok(engineAt > viewerTeardown, 'engine teardown must precede viewer'); + assert.match(scene, /tilesetRef\.current = tileset;/); + assert.match( + scene, + /return \{ viewer, engine, tileset, mapStackController, operations \};/, + ); +}); + +test('the debug handle exposes the engine beside the viewer', () => { + const tools = read('src/app/tools.js'); + assert.match( + tools, + /const \{ viewer, engine, tileset, mapStackController, operations \} = scene;/, + ); + assert.match(tools, /window\.__godsEyeView = \{\n\s+viewer,\n\s+engine,/); +}); diff --git a/src/app/scene.js b/src/app/scene.js index b98a28914..993efee3c 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,6 +14,7 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { createSceneEngine } from './globeEngine.js'; /** Construct the application globe using the caller's local configuration. */ export async function createApplicationScene({ @@ -56,6 +57,10 @@ export async function createApplicationScene({ uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); + // The globe engine over this viewer; Google's startup tileset is named + // once it loads. + const tilesetRef = { current: null }; + const engine = createSceneEngine(viewer, { tilesetRef, defer }); defer(installTrackpadPinchZoom(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); @@ -69,6 +74,7 @@ export async function createApplicationScene({ cesiumToken, }); const tileset = photoreal.tileset; + tilesetRef.current = tileset; // A provider can finish after cancellation; retain ownership of its result. defer(() => { if (tileset && !tileset.isDestroyed()) { @@ -121,5 +127,5 @@ export async function createApplicationScene({ }); signal.throwIfAborted(); - return { viewer, tileset, mapStackController, operations }; + return { viewer, engine, tileset, mapStackController, operations }; } diff --git a/src/app/tools.js b/src/app/tools.js index 25d763e2d..1f3120986 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -28,7 +28,7 @@ export function createApplicationTools({ signal, defer, }) { - const { viewer, tileset, mapStackController, operations } = scene; + const { viewer, engine, tileset, mapStackController, operations } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { @@ -144,6 +144,7 @@ export function createApplicationTools({ window.__godsEyeView = { viewer, + engine, styleManager, tileset, dataManager,