From 58a196374f18b353331d52e04101d0b0b8fcfb83 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 18:18:19 +0400 Subject: [PATCH] feat(globe): engine members and the viewer bridge for the shell Contract: camera.position(result?) and camera.height() for per-frame readers, render.request(reason?), maps.releasePhotorealTiles, sources with a caller-owned ready provider, isGlobeEngine. engine.maps now hides every tileset it has shown, lets the last imagery/photoreal call win, and setPhotorealTiles(null) restores the last base imagery and its credit instead of leaving the photoreal credit on a bare globe. Cesium engine: reads the Viewer camera/canvas aliases, detaches bridged events through the remover Cesium returns, raises frame timestamps without allocating, re-aims the horizon occluder only when the camera moved, and renders the scene directly when the governor governs another viewer (or none yet). engineFromViewer returns the registered scene engine of a viewer, else builds and caches one. The render governor stays the Cesium render backend under engine.render and gains isRenderGovernorInstalled. --- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 1 + src/app/globeEngine.js | 10 +- src/app/globeEngine.test.mjs | 21 ++++ src/globe/cesium/camera.js | 12 +- src/globe/cesium/camera.test.mjs | 20 ++++ src/globe/cesium/createCesiumEngine.js | 11 +- src/globe/cesium/createCesiumEngine.test.mjs | 51 ++++++++ src/globe/cesium/engineFromViewer.js | 40 +++++++ src/globe/cesium/engineFromViewer.test.mjs | 46 ++++++++ src/globe/cesium/events.js | 32 +++-- src/globe/cesium/events.test.mjs | 32 +++++ src/globe/cesium/frames.js | 22 +++- src/globe/cesium/frames.test.mjs | 26 +++++ src/globe/cesium/maps.js | 59 ++++++++-- src/globe/cesium/maps.test.mjs | 116 +++++++++++++++++++ src/globe/cesium/projector.js | 20 +++- src/globe/cesium/render.js | 16 ++- src/globe/cesium/render.test.mjs | 24 ++++ src/globe/contract.js | 34 +++++- src/globe/contract.test.mjs | 20 ++++ src/globe/testing/contractSuite.mjs | 35 ++++++ src/globe/testing/fakeEngine.js | 13 ++- src/globe/testing/fakeEngine.test.mjs | 10 ++ src/renderGovernor.js | 17 +++ src/renderGovernor.test.mjs | 12 ++ 26 files changed, 657 insertions(+), 45 deletions(-) create mode 100644 src/globe/cesium/engineFromViewer.js create mode 100644 src/globe/cesium/engineFromViewer.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index daa945d3e..dfed388ca 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -734,6 +734,8 @@ "src/globe/cesium/credits.test.mjs", "src/globe/cesium/maps.test.mjs", "src/globe/cesium/createCesiumEngine.test.mjs", + "src/globe/cesium/engineFromViewer.js", + "src/globe/cesium/engineFromViewer.test.mjs", "src/globe/cesium/testing/fakeViewer.mjs", "scripts/qa-globe-contract.mjs", "src/app/globeEngine.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 1203467cc..200cf817a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1751,6 +1751,7 @@ "src/globe/cesium/projector.js", "src/globe/cesium/render.js", "src/globe/cesium/surface.js", + "src/globe/cesium/engineFromViewer.js", "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", diff --git a/src/app/globeEngine.js b/src/app/globeEngine.js index e1e67f9b7..a90e0ffb7 100644 --- a/src/app/globeEngine.js +++ b/src/app/globeEngine.js @@ -1,8 +1,10 @@ import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; +import { registerViewerEngine } from '../globe/cesium/engineFromViewer.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 + * teardown to the scene lifetime; engineFromViewer(viewer) returns it while + * it lives. 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 @@ -18,6 +20,10 @@ export function createSceneEngine( { tilesetRef, defer, createEngine = createCesiumEngine }, ) { const engine = createEngine(viewer, { tilesetRef }); - defer(() => engine.destroy()); + const unregister = registerViewerEngine(viewer, engine); + defer(() => { + unregister(); + engine.destroy(); + }); return engine; } diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index 194136b6d..c72782f91 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -65,3 +65,24 @@ test('the debug handle exposes the engine beside the viewer', () => { ); assert.match(tools, /window\.__godsEyeView = \{\n\s+viewer,\n\s+engine,/); }); + +test('the scene engine is the one engineFromViewer returns for its viewer until teardown', async () => { + const { engineFromViewer } = + await import('../globe/cesium/engineFromViewer.js'); + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const viewer = { name: 'viewer' }; + const cleanups = []; + const engine = createSceneEngine(viewer, { + tilesetRef: null, + defer: (dispose) => cleanups.push(dispose), + createEngine: () => createFakeEngine(), + }); + 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', + ); +}); diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index 6468bb023..4d0eaee8a 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -54,7 +54,8 @@ const cesiumOffset = (offset) => * @returns {{ camera: import('../contract.js').GlobeCamera, destroy: () => void }} */ export function createCesiumCamera(viewer) { - const cesium = viewer.scene.camera; + // Viewer.camera is scene.camera; the public alias is what shell callers read. + const cesium = viewer.camera ?? viewer.scene.camera; const bridges = { changed: bridgeEvent(cesium.changed), moveStart: bridgeEvent(cesium.moveStart), @@ -110,6 +111,15 @@ 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; + }, + height: () => cesium.positionCartographic.height, setView(view) { if (!view?.destination) throw new TypeError('setView needs a destination'); diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index c129f0976..2d372e633 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -346,3 +346,23 @@ test('rectangle destinations become a Cesium.Rectangle in flyTo and setView', () ); } }); + +test('position fills a result from positionWC and height reads the camera 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.height(), 1234.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 }); +}); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index d1e5513d8..0139af1ee 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -31,7 +31,8 @@ import { createCesiumSurface } from './surface.js'; */ export function createCesiumEngine(viewer, options = {}) { if (!viewer?.scene) throw new TypeError('createCesiumEngine needs a viewer'); - const { scene } = viewer; + // Viewer.canvas is scene.canvas; the engine reads the public alias. + const canvas = viewer.canvas ?? viewer.scene.canvas; const render = createCesiumRender(viewer, options); const parts = { camera: createCesiumCamera(viewer), @@ -44,7 +45,7 @@ export function createCesiumEngine(viewer, options = {}) { credits: createCesiumCredits(viewer), maps: createCesiumMaps(viewer, { ...options, - requestRender: () => render.render.request(), + requestRender: (reason) => render.render.request(reason), }), }; let destroyed = false; @@ -52,10 +53,10 @@ export function createCesiumEngine(viewer, options = {}) { kind: 'cesium', host: { container: viewer.container, - canvas: scene.canvas, + canvas, size: () => ({ - width: scene.canvas.clientWidth, - height: scene.canvas.clientHeight, + width: canvas.clientWidth, + height: canvas.clientHeight, }), }, ...Object.fromEntries( diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index d93935c4d..3a1a56a61 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; import { assertGlobeEngine } from '../contract.js'; import { _resetRenderGovernorForTest, @@ -85,3 +86,53 @@ test('destroy releases holds, listeners, handlers and engine-owned map state', a assert.deepEqual(viewer.staticCredits, []); assert.deepEqual(viewer.imageryList, []); }); + +test('the engine reads the Viewer aliases and builds on a minimal viewer', () => { + // Viewer.camera/.canvas are the scene's; a viewer-shaped object that offers + // only the aliases, a container and a remover-returning postRender works. + const listeners = new Set(); + const postRender = { + addEventListener(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; + let renders = 0; + const viewer = { + container: { kind: 'container' }, + canvas: { clientWidth: 320, clientHeight: 200 }, + camera: { + positionWC: new Cesium.Cartesian3(7e6, 0, 0), + positionCartographic: { height: 621863 }, + viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + frustum: { + projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + }, + }, + scene: { postRender, requestRender: () => renders++ }, + }; + const engine = createCesiumEngine(viewer); + assert.equal(engine.host.canvas, viewer.canvas); + assert.deepEqual(engine.host.size(), { width: 320, height: 200 }); + assert.equal(engine.camera.height(), 621863); + assert.deepEqual( + engine.projector.viewProjection(), + Cesium.Matrix4.toArray(Cesium.Matrix4.IDENTITY), + ); + assert.equal( + engine.projector.horizonVisible({ x: 6378137, y: 0, z: 0 }), + true, + ); + const seen = []; + const off = engine.frames.postRender.addEventListener((time) => + seen.push(time), + ); + assert.equal(listeners.size, 1); + for (const listener of listeners) listener(); + assert.equal(seen.length, 1); + off(); + assert.equal(listeners.size, 0); + engine.render.request(); + assert.equal(renders, 1); + engine.destroy(); +}); diff --git a/src/globe/cesium/engineFromViewer.js b/src/globe/cesium/engineFromViewer.js new file mode 100644 index 000000000..c4142848e --- /dev/null +++ b/src/globe/cesium/engineFromViewer.js @@ -0,0 +1,40 @@ +// 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. + +import { isGlobeEngine } from '../contract.js'; +import { createCesiumEngine } from './createCesiumEngine.js'; + +const engines = new WeakMap(); + +/** + * Make `engine` the one returned for `viewer`. + * @returns {() => void} Unregister (only while it is still the registered one). + */ +export function registerViewerEngine(viewer, engine) { + engines.set(viewer, engine); + return () => { + if (engines.get(viewer) === engine) engines.delete(viewer); + }; +} + +/** + * @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} + */ +export function engineFromViewer(viewer, options) { + 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; +} diff --git a/src/globe/cesium/engineFromViewer.test.mjs b/src/globe/cesium/engineFromViewer.test.mjs new file mode 100644 index 000000000..65198be54 --- /dev/null +++ b/src/globe/cesium/engineFromViewer.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isGlobeEngine } from '../contract.js'; +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', () => { + 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); +}); + +test('the engine registered for a viewer is the one returned', () => { + 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); + unregister(); + assert.equal( + engineFromViewer(viewer), + built, + 'a stale unregister keeps the new one', + ); +}); + +test('an engine passes through, and no viewer is no engine', () => { + const engine = createFakeEngine(); + assert.equal(engineFromViewer(engine), engine); + assert.equal(engineFromViewer(null), null); + assert.equal(engineFromViewer(undefined), null); +}); + +test('isGlobeEngine tells engines from viewers', () => { + assert.equal(isGlobeEngine(createFakeEngine()), true); + assert.equal(isGlobeEngine(createFakeViewer()), false); + assert.equal(isGlobeEngine(null), false); + assert.equal(isGlobeEngine({ kind: 'cesium' }), false); +}); diff --git a/src/globe/cesium/events.js b/src/globe/cesium/events.js index 755f0ac90..b45b31d30 100644 --- a/src/globe/cesium/events.js +++ b/src/globe/cesium/events.js @@ -6,23 +6,33 @@ import { createEngineEvent } from '../contract.js'; /** - * @param {{ addEventListener: Function, removeEventListener: Function }} source A Cesium.Event. - * @param {(...args: any[]) => any[]} [mapArgs] Cesium arguments → contract arguments. + * @param {{ addEventListener: (listener: Function) => () => void }} source A + * Cesium.Event (its addEventListener returns the remover the bridge uses). + * @param {((...args: any[]) => any[]) | null} [mapArgs] Cesium arguments → + * contract arguments. + * @param {{ unary?: (...args: any[]) => any }} [options] `unary` maps the + * Cesium arguments to the one contract argument without building an + * argument list (for per-frame events). * @returns {{ event: import('../contract.js').EngineEvent, detach: () => void }} */ -export function bridgeEvent(source, mapArgs) { +export function bridgeEvent(source, mapArgs, { unary } = {}) { const event = createEngineEvent(); - const forward = mapArgs - ? (...args) => event.raiseEvent(...mapArgs(...args)) - : (...args) => event.raiseEvent(...args); - let attached = false; + const forward = unary + ? (a, b, c) => event.raiseEvent(unary(a, b, c)) + : mapArgs + ? (...args) => event.raiseEvent(...mapArgs(...args)) + : (...args) => event.raiseEvent(...args); + let removeForward = null; let detached = false; const sync = () => { const wanted = !detached && event.numberOfListeners > 0; - if (wanted === attached) return; - attached = wanted; - if (wanted) source.addEventListener(forward); - else source.removeEventListener(forward); + if (wanted === (removeForward !== null)) return; + if (wanted) removeForward = source.addEventListener(forward); + else { + const remove = removeForward; + removeForward = null; + remove(); + } }; const add = event.addEventListener; const remove = event.removeEventListener; diff --git a/src/globe/cesium/events.test.mjs b/src/globe/cesium/events.test.mjs index 9ac5892c2..93fad04e2 100644 --- a/src/globe/cesium/events.test.mjs +++ b/src/globe/cesium/events.test.mjs @@ -61,3 +61,35 @@ test('detach drops the Cesium subscription for good', () => { assert.equal(source.numberOfListeners, 0); source.raiseEvent(); }); + +test('a bridge detaches through the remover the source returned', () => { + // Cesium.Event.addEventListener returns a remover; the bridge relies on it + // alone, so event-like sources without removeEventListener work too. + const listeners = new Set(); + const source = { + addEventListener(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; + const { event, detach } = bridgeEvent(source); + const remove = event.addEventListener(() => {}); + assert.equal(listeners.size, 1); + remove(); + assert.equal(listeners.size, 0); + event.addEventListener(() => {}); + detach(); + assert.equal(listeners.size, 0); +}); + +test('a unary bridge raises one mapped value', () => { + const source = new Cesium.Event(); + const { event, detach } = bridgeEvent(source, null, { + unary: (scene, time) => time * 2, + }); + const seen = []; + event.addEventListener((...values) => seen.push(values)); + source.raiseEvent({}, 4); + assert.deepEqual(seen, [[8]]); + detach(); +}); diff --git a/src/globe/cesium/frames.js b/src/globe/cesium/frames.js index de6f5554c..2d2feee60 100644 --- a/src/globe/cesium/frames.js +++ b/src/globe/cesium/frames.js @@ -4,9 +4,23 @@ import * as Cesium from 'cesium'; import { bridgeEvent } from './events.js'; -const toMilliseconds = (scene, time) => [ - Cesium.JulianDate.toDate(time).getTime(), -]; +const scratchGregorian = new Cesium.GregorianDate(); + +// JulianDate.toDate(time).getTime() without the Date: frame events must not +// allocate. A raise without a time (not Cesium's own) reads the clock. +function unixMilliseconds(scene, time) { + if (!time) return Date.now(); + const date = Cesium.JulianDate.toGregorianDate(time, scratchGregorian); + return Date.UTC( + date.year, + date.month - 1, + date.day, + date.hour, + date.minute, + date.isLeapSecond ? date.second - 1 : date.second, + date.millisecond, + ); +} /** * @param {Cesium.Viewer} viewer @@ -16,7 +30,7 @@ export function createCesiumFrames(viewer) { const { scene } = viewer; const bridges = ['preUpdate', 'preRender', 'postRender'].map((name) => [ name, - bridgeEvent(scene[name], toMilliseconds), + bridgeEvent(scene[name], null, { unary: unixMilliseconds }), ]); return { frames: Object.fromEntries( diff --git a/src/globe/cesium/frames.test.mjs b/src/globe/cesium/frames.test.mjs index c8852d5bd..ef7387437 100644 --- a/src/globe/cesium/frames.test.mjs +++ b/src/globe/cesium/frames.test.mjs @@ -29,3 +29,29 @@ test('frame events bridge the scene events and pass a millisecond timestamp', () for (const name of ['preUpdate', 'preRender', 'postRender']) assert.equal(scene[name].numberOfListeners, 0); }); + +test('timestamps match JulianDate.toDate, and a raise without a time reads the clock', () => { + const viewer = createFakeViewer(); + const { frames, destroy } = createCesiumFrames(viewer); + const seen = []; + frames.postRender.addEventListener((time) => seen.push(time)); + const times = [ + Cesium.JulianDate.fromDate(new Date(Date.UTC(1999, 0, 1, 0, 0, 0, 1))), + Cesium.JulianDate.fromIso8601('2016-12-31T23:59:60.5Z'), + Cesium.JulianDate.addSeconds( + Cesium.JulianDate.fromDate(new Date(Date.UTC(2026, 9, 15))), + 0.1234, + new Cesium.JulianDate(), + ), + ]; + for (const time of times) + viewer.scene.postRender.raiseEvent(viewer.scene, time); + assert.deepEqual( + seen, + times.map((time) => Cesium.JulianDate.toDate(time).getTime()), + ); + const before = Date.now(); + viewer.scene.postRender.raiseEvent(); + assert.ok(seen.at(-1) >= before && seen.at(-1) <= Date.now()); + destroy(); +}); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index fe223324d..8cf29b0ba 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -82,20 +82,31 @@ export function createCesiumMaps( const providers = new Map(); let ownedTileset = null; let shownTileset = null; - const generations = { imagery: 0, terrain: 0, photoreal: 0 }; + // Every tileset shown through the engine, so a switch hides all of them; + // `added` are those the engine put into scene.primitives itself. + const known = new Set(); + const added = new Set(); + // Imagery and photoreal share one generation: whichever was asked for last + // decides what the globe shows. Terrain has its own. + const generations = { display: 0, terrain: 0 }; + let baseSource = null; + let creditOwner = null; let destroyed = false; const startupTileset = () => (typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ?? null; const tilesets = () => - [startupTileset(), shownTileset, ownedTileset].filter( + [startupTileset(), shownTileset, ownedTileset, ...known].filter( (tileset, index, all) => tileset && all.indexOf(tileset) === index, ); // One provider per source, as MapSourceController caches them, so applying - // the same source again keeps the live layer. Failures are not cached. + // the same source again keeps the live layer. Failures are not cached. A + // source carrying a ready `provider` belongs to its caller: it is neither + // cached nor disposed here. function providerFor(source) { + if (source?.provider) return Promise.resolve(source.provider); const key = imageryKey(source); if (providers.has(key)) return providers.get(key); const promise = Promise.resolve() @@ -108,13 +119,23 @@ export function createCesiumMaps( return promise; } + function showCredit(owner, html) { + creditOwner = owner; + mapScene.showCredit(html); + } + + function showBase(source, provider) { + mapScene.showBaseImagery(provider); + showCredit('imagery', imageryCredit(source)); + } + const maps = { async setBaseImagery(source) { - const generation = ++generations.imagery; + const generation = ++generations.display; const provider = await providerFor(source); - if (destroyed || generation !== generations.imagery) return; - mapScene.showBaseImagery(provider); - mapScene.showCredit(imageryCredit(source)); + if (destroyed || generation !== generations.display) return; + baseSource = { source, provider }; + showBase(source, provider); requestRender('map-imagery'); }, async setTerrain(source) { @@ -125,11 +146,17 @@ export function createCesiumMaps( requestRender('map-terrain'); }, async setPhotorealTiles(source) { - const generation = ++generations.photoreal; + const generation = ++generations.display; const { scene } = viewer; if (source == null) { mapScene.showTileset(null, tilesets()); shownTileset = null; + // The globe comes back with the last base imagery and its credit; + // the photoreal credit goes. + if (baseSource) { + if (!mapScene.baseProvider) + showBase(baseSource.source, baseSource.provider); + } else if (creditOwner === 'photoreal') showCredit(null, null); mapScene.showGlobe(true); requestRender('map-photoreal'); return; @@ -144,7 +171,7 @@ export function createCesiumMaps( tileset = loaded; owned = true; } - if (destroyed || generation !== generations.photoreal) { + if (destroyed || generation !== generations.display) { if (owned) dispose(tileset); return; } @@ -155,16 +182,26 @@ export function createCesiumMaps( } ownedTileset = tileset; } - if (!scene.primitives.contains(tileset)) scene.primitives.add(tileset); + if (!scene.primitives.contains(tileset)) { + scene.primitives.add(tileset); + if (!owned) added.add(tileset); + } + known.add(tileset); // As MapSourceController activates a 3D stack: no base layer, the // source's credit, one tileset shown and the globe hidden. mapScene.removeBaseImagery(); - mapScene.showCredit(source.credit); + showCredit('photoreal', source.credit); shownTileset = tileset; mapScene.showTileset(tileset, tilesets()); mapScene.showGlobe(false); requestRender('map-photoreal'); }, + releasePhotorealTiles(source) { + const tileset = source?.tileset; + if (!tileset || !known.delete(tileset)) return; + if (shownTileset === tileset) shownTileset = null; + if (added.delete(tileset)) viewer.scene.primitives.remove(tileset); + }, photorealActive: () => tilesets().some((tileset) => tileset.show === true), }; diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs index f5c759c42..fa2955bb5 100644 --- a/src/globe/cesium/maps.test.mjs +++ b/src/globe/cesium/maps.test.mjs @@ -262,3 +262,119 @@ test('destroy removes the engine imagery layer and its credit', async () => { assert.deepEqual(viewer.imageryList, []); assert.deepEqual(viewer.staticCredits, []); }); + +const esriSource = (provider = { name: 'esri' }) => ({ + descriptor: { id: 'esri-imagery', kind: 'esri-imagery' }, + imagery: () => provider, + credit: ESRI_ATTRIBUTION_HTML, +}); +const credits = (viewer) => viewer.staticCredits.map((credit) => credit.html); + +test('setPhotorealTiles(null) clears the photoreal credit and restores the base imagery', async () => { + const { viewer, maps } = setup(); + const provider = { name: 'esri' }; + await maps.setBaseImagery(esriSource(provider)); + await maps.setPhotorealTiles({ tileset: tileset(), credit: 'Google' }); + assert.deepEqual(viewer.imageryList, []); + assert.deepEqual(credits(viewer), ['Google']); + await maps.setPhotorealTiles(null); + assert.deepEqual(viewer.imageryList, [{ provider }], 'base layer back'); + assert.deepEqual(credits(viewer), [ESRI_ATTRIBUTION_HTML]); + assert.equal(viewer.scene.globe.show, true); +}); + +test('setPhotorealTiles(null) before any base imagery shows no credit', async () => { + const { viewer, maps } = setup(); + await maps.setPhotorealTiles({ tileset: tileset(), credit: 'Google' }); + await maps.setPhotorealTiles(null); + assert.deepEqual(credits(viewer), []); + assert.deepEqual(viewer.imageryList, []); +}); + +test('base imagery then setPhotorealTiles(null) keeps the base layer and its credit', async () => { + const { viewer, maps } = setup(); + const provider = { name: 'esri' }; + await maps.setPhotorealTiles({ tileset: tileset(), credit: 'Google' }); + await maps.setBaseImagery(esriSource(provider)); + await maps.setPhotorealTiles(null); + assert.deepEqual(viewer.imageryList, [{ provider }]); + assert.equal(viewer.callsOf('imageryLayers.add').length, 1, 'not rebuilt'); + assert.deepEqual(credits(viewer), [ESRI_ATTRIBUTION_HTML]); +}); + +test('a newer photoreal switch supersedes a pending imagery switch', async () => { + const { viewer, maps } = setup(); + let resolve; + const pending = maps.setBaseImagery({ + descriptor: { id: 'slow', kind: 'osm' }, + imagery: () => new Promise((done) => (resolve = done)), + credit: 'slow credit', + }); + await maps.setPhotorealTiles({ tileset: tileset(), credit: 'Google' }); + resolve({ name: 'slow' }); + await pending; + assert.deepEqual(viewer.imageryList, []); + assert.deepEqual(credits(viewer), ['Google']); + assert.equal(viewer.scene.globe.show, false); +}); + +test('switching between two tilesets hides the one shown before', async () => { + const { maps } = setup(); + const first = tileset(); + const second = tileset(); + await maps.setPhotorealTiles({ tileset: first }); + await maps.setPhotorealTiles({ tileset: second }); + assert.equal(first.show, false); + assert.equal(second.show, true); + await maps.setPhotorealTiles(null); + assert.equal(second.show, false); +}); + +test('a source with a ready provider is shown as is and stays its caller’s', async () => { + const { viewer, maps, destroy } = setup(); + let destroyed = 0; + const provider = { name: 'owned', destroy: () => destroyed++ }; + const source = { + descriptor: { id: 'osm', kind: 'osm' }, + provider, + credit: 'c', + }; + await maps.setBaseImagery(source); + assert.deepEqual(viewer.imageryList, [{ provider }]); + const other = { name: 'other' }; + await maps.setBaseImagery({ ...source, provider: other }); + assert.deepEqual( + viewer.imageryList, + [{ provider: other }], + 'not cached by id', + ); + destroy(); + assert.equal(destroyed, 0, 'the engine does not dispose a caller provider'); +}); + +test('releasePhotorealTiles forgets a tileset and removes it from the scene', async () => { + const { viewer, maps } = setup(); + const owned = tileset(); + await maps.setPhotorealTiles({ tileset: owned }); + await maps.setPhotorealTiles(null); + maps.releasePhotorealTiles({ tileset: owned }); + assert.equal(viewer.primitiveList.includes(owned), false); + assert.equal(maps.photorealActive(), false); + owned.show = true; + assert.equal( + maps.photorealActive(), + false, + 'no longer one of the engine tilesets', + ); + maps.releasePhotorealTiles({ tileset: owned }); + assert.equal(viewer.callsOf('primitives.remove').length, 1, 'idempotent'); +}); + +test('releasePhotorealTiles leaves a tileset the engine did not add in the scene', async () => { + const { viewer, maps } = setup(); + const startup = tileset(true); + viewer.primitiveList.push(startup); + await maps.setPhotorealTiles({ tileset: startup }); + maps.releasePhotorealTiles({ tileset: startup }); + assert.equal(viewer.primitiveList.includes(startup), true); +}); diff --git a/src/globe/cesium/projector.js b/src/globe/cesium/projector.js index 0345c12c7..dfb0e1b77 100644 --- a/src/globe/cesium/projector.js +++ b/src/globe/cesium/projector.js @@ -16,9 +16,16 @@ export function createCesiumProjector( } = {}, ) { const { scene } = viewer; + // Viewer.camera is scene.camera; the engine reads the public alias. + const camera = viewer.camera ?? scene.camera; const scratchWindow = new Cesium.Cartesian2(); const scratchMatrix = new Cesium.Matrix4(); const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + // The occluder is re-aimed only when the camera has moved: horizonVisible + // runs per label per frame. + let occluderX = Number.NaN; + let occluderY = Number.NaN; + let occluderZ = Number.NaN; const projector = { worldToWindow(position, result) { @@ -33,7 +40,6 @@ export function createCesiumProjector( // The matrix Cesium's worldToWindowCoordinates uses: frustum projection × // view. x, y and w do not depend on the multi-frustum near/far planes. viewProjection(result = geo.matrix4.create()) { - const { camera } = scene; Cesium.Matrix4.multiply( camera.frustum.projectionMatrix, camera.viewMatrix, @@ -44,7 +50,17 @@ export function createCesiumProjector( return result; }, horizonVisible(position) { - occluder.cameraPosition = scene.camera.positionWC; + const { positionWC } = camera; + if ( + positionWC.x !== occluderX || + positionWC.y !== occluderY || + positionWC.z !== occluderZ + ) { + occluder.cameraPosition = positionWC; + occluderX = positionWC.x; + occluderY = positionWC.y; + occluderZ = positionWC.z; + } return occluder.isPointVisible(position); }, }; diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js index 2f219396f..6c15ac522 100644 --- a/src/globe/cesium/render.js +++ b/src/globe/cesium/render.js @@ -5,6 +5,7 @@ import { governorRequestRender, holdContinuousRender, + isRenderGovernorInstalled, releaseContinuousRender, } from '../../renderGovernor.js'; @@ -12,17 +13,24 @@ export const RENDER_OWNER = 'globe-engine'; /** * @param {Cesium.Viewer} viewer - * @param {{ requestRender?: (reason: string) => void }} [options] + * @param {{ requestRender?: (reason: string) => void }} [options] Defaults to + * the render governor when it governs this viewer, else the scene itself + * (before the governor is installed, or on another viewer). * @returns {{ render: import('../contract.js').GlobeRender, destroy: () => void }} */ export function createCesiumRender( viewer, - { requestRender = governorRequestRender } = {}, + { + requestRender = (reason) => { + if (isRenderGovernorInstalled(viewer)) governorRequestRender(reason); + else viewer.scene.requestRender?.(); + }, + } = {}, ) { let held = false; const render = { - request() { - requestRender(RENDER_OWNER); + request(reason = RENDER_OWNER) { + requestRender(reason); }, setContinuous(enabled) { held = Boolean(enabled); diff --git a/src/globe/cesium/render.test.mjs b/src/globe/cesium/render.test.mjs index 50d7df9dd..d2ffd3d22 100644 --- a/src/globe/cesium/render.test.mjs +++ b/src/globe/cesium/render.test.mjs @@ -76,3 +76,27 @@ test('destroy releases the engine hold', () => { destroy(); assert.deepEqual(getRenderGovernorDiagnostics().holds, []); }); + +test('request passes a caller reason through to the governor', () => { + const viewer = createFakeViewer(); + installRenderGovernor(viewer); + const { render } = createCesiumRender(viewer); + render.request('world-overlay'); + assert.deepEqual( + getRenderGovernorDiagnostics().recentRequests.map(({ reason }) => reason), + ['world-overlay'], + ); +}); + +test('without a governor on its viewer, request renders the scene directly', () => { + const viewer = createFakeViewer(); + const { render } = createCesiumRender(viewer); + render.request(); + assert.equal(viewer.callsOf('scene.requestRender').length, 1); + const governed = createFakeViewer(); + installRenderGovernor(governed); + render.request(); + assert.equal(viewer.callsOf('scene.requestRender').length, 2); + assert.equal(governed.callsOf('scene.requestRender').length, 0); + assert.deepEqual(getRenderGovernorDiagnostics().recentRequests, []); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index a0824bb79..579b43778 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -83,6 +83,11 @@ * * @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 {() => 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 * Missing angles default to heading 0, pitch −π/2, roll 0. * @property {(options: FlightOptions) => void} flyTo Cancels a running flight first. @@ -141,7 +146,8 @@ * @property {() => boolean} tilesLoaded * * @typedef {object} GlobeRender - * @property {() => void} request Renders a frame soon in request mode. + * @property {(reason?: string) => void} request Renders a frame soon in + * request mode; `reason` is for diagnostics only. * @property {(enabled: boolean) => void} setContinuous * Owns whatever the engine needs to render every frame or only on request * (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity). @@ -167,10 +173,17 @@ * As in Cesium, a listener with a modifier fires only while that key is * held and one without fires only while no modifier is held. * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits - * @property {{ setBaseImagery: (source: object) => Promise, setTerrain: (source: object) => Promise, setPhotorealTiles: (source: object | null) => Promise, photorealActive: () => boolean }} maps + * @property {{ setBaseImagery: (source: object) => Promise, setTerrain: (source: object) => Promise, setPhotorealTiles: (source: object | null) => Promise, releasePhotorealTiles: (source: object) => void, photorealActive: () => boolean }} maps * Sources are maps/registry.js descriptions; the engine builds its own * providers. Each setter settles once the source is on screen and rejects - * when it cannot be built (the current source then stays). + * when it cannot be built (the current source then stays). A source may + * carry a ready engine `provider` its caller built and owns (the engine + * neither caches nor disposes it). Base imagery and photoreal tiles share + * one switch order: the one asked for last wins, a superseded call settles + * without showing anything. setPhotorealTiles(null) shows the globe again + * with the last base imagery and its credit (no credit when none was set). + * releasePhotorealTiles forgets a tileset source shown before and takes it + * out of the scene when the engine put it there; its caller disposes it. * @property {() => void} destroy */ @@ -269,6 +282,8 @@ const SHAPE = { host: { container: isObject, canvas: isObject, size: isFunction }, camera: { pose: isFunction, + position: isFunction, + height: isFunction, setView: isFunction, flyTo: isFunction, flyToSphere: isFunction, @@ -324,6 +339,7 @@ const SHAPE = { setBaseImagery: isFunction, setTerrain: isFunction, setPhotorealTiles: isFunction, + releasePhotorealTiles: isFunction, photorealActive: isFunction, }, }; @@ -337,6 +353,18 @@ export const GLOBE_ENGINE_MEMBERS = Object.freeze([ 'destroy', ]); +/** + * Whether a value is a globe engine rather than, say, a Cesium viewer (a + * cheap structural test; assertGlobeEngine checks every member). + */ +export const isGlobeEngine = (value) => + isObject(value) && + ENGINE_KINDS.includes(value.kind) && + isObject(value.camera) && + isObject(value.projector) && + isObject(value.frames) && + isFunction(value.destroy); + /** Throws a TypeError naming every missing or mistyped member; returns the engine. */ export function assertGlobeEngine(engine) { if (!isObject(engine)) throw new TypeError('GlobeEngine must be an object'); diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index dc9a8f31a..f94569e2c 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ENGINE_KINDS, + GLOBE_ENGINE_MEMBERS, INPUT_TYPES, MODIFIERS, assertGlobeEngine, @@ -185,6 +186,25 @@ test('assertGlobeEngine accepts a complete engine and names every missing capabi assert.throws(() => assertGlobeEngine(null), TypeError); }); +test('the contract carries what the shell infrastructure needs', () => { + for (const member of [ + 'camera.position', + 'camera.height', + 'maps.releasePhotorealTiles', + ]) + assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); + const engine = createFakeEngine(); + const broken = { + ...engine, + camera: { ...engine.camera, position: undefined, height: 1 }, + maps: { ...engine.maps, releasePhotorealTiles: undefined }, + }; + assert.throws( + () => assertGlobeEngine(broken), + /camera\.position.*camera\.height.*maps\.releasePhotorealTiles/, + ); +}); + test('the fake engine passes the engine contract suite', async (t) => { const engine = createFakeEngine(); const checks = createContractSuite(engine, { diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index be06e3936..fc388b630 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -176,6 +176,41 @@ export function createContractSuite( near(cartesian3.dot(pose.up, up), 1, 1e-9, 'up·expected'); }, ], + [ + 'camera.position 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', + ); + near( + cartesian3.distance(filled, pose.position), + 0, + 1e-6, + 'position(result) vs pose().position', + ); + const fresh = camera.position(); + ensure( + isCartesian3(fresh) && fresh !== filled, + 'position() returns a fresh Cartesian3', + ); + near( + cartesian3.distance(fresh, pose.position), + 0, + 1e-6, + 'position() vs pose().position', + ); + near(camera.height(), pose.cartographic.height, 0.01, 'height()'); + }, + ], [ 'camera.pose: cartographic, frame vectors and fovy are consistent', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 2d5ee6b09..a275cb3b1 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -602,6 +602,12 @@ export function createFakeEngine(options = {}) { fovy, }; }, + position(result) { + return result + ? cartesian3.clone(state.position, result) + : copy3(state.position); + }, + height: () => cartographic.fromCartesian(state.position, WGS84).height, setView(view) { record('camera.setView', [view]); if (!view?.destination) @@ -756,8 +762,8 @@ export function createFakeEngine(options = {}) { postRender: events['frames.postRender'], }, render: { - request() { - record('render.request', []); + request(reason) { + record('render.request', reason === undefined ? [] : [reason]); fake.renderRequests++; }, setContinuous(enabled) { @@ -825,6 +831,9 @@ export function createFakeEngine(options = {}) { record('maps.setPhotorealTiles', [source]); fake.maps.photoreal = source ?? null; }, + releasePhotorealTiles(source) { + record('maps.releasePhotorealTiles', [source]); + }, photorealActive: () => fake.maps.photoreal != null, }, destroy() { diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index a1bb820d4..0f8e0f7a1 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -353,3 +353,13 @@ test('fake drillPick records width and height; maps return promises', async () = await call; } }); + +test('fake camera position fills a result and height is the pose height', () => { + const engine = createFakeEngine({ altitude: 2_000_000 }); + const result = geo.cartesian3.create(); + assert.equal(engine.camera.position(result), result); + assert.ok( + geo.cartesian3.distance(result, engine.camera.pose().position) < 1e-6, + ); + assert.ok(Math.abs(engine.camera.height() - 2_000_000) < 0.01); +}); diff --git a/src/renderGovernor.js b/src/renderGovernor.js index 215132c1c..f83da4803 100644 --- a/src/renderGovernor.js +++ b/src/renderGovernor.js @@ -29,6 +29,13 @@ * diagnostics read like a story. * * The governor is O(1) passive: no per-frame work of its own, ever. + * + * Globe engine: the governor is the Cesium engine's render backend. + * `engine.render.request()/setContinuous()` (src/globe/cesium/render.js) + * delegate here, so the governor never uses `engine.render` (that would be a + * cycle) and keeps the viewer for what the contract leaves to the engine: + * `scene.requestRenderMode`, `scene.maximumRenderTimeChange` and + * `scene.requestRender()`. It needs no frame events (`engine.frames`). */ let _viewer = null; @@ -129,6 +136,16 @@ export function getRenderGovernorDiagnostics() { }; } +/** + * Whether the governor is installed on this viewer (its render requests then + * go through `governorRequestRender`). + * @param {object} viewer + * @returns {boolean} + */ +export function isRenderGovernorInstalled(viewer) { + return _installed && viewer != null && _viewer === viewer; +} + /** Release the installed viewer after its animation owners have stopped. */ export function uninstallRenderGovernor(viewer) { if (_viewer !== viewer) return; diff --git a/src/renderGovernor.test.mjs b/src/renderGovernor.test.mjs index 5057fd926..d59151ebf 100644 --- a/src/renderGovernor.test.mjs +++ b/src/renderGovernor.test.mjs @@ -98,3 +98,15 @@ test('holds registered before install apply at install time', () => { releaseContinuousRender('flights'); assert.equal(scene.requestRenderMode, true); }); + +test('isRenderGovernorInstalled names the one governed viewer', async () => { + const { isRenderGovernorInstalled, uninstallRenderGovernor } = await import('./renderGovernor.js'); + const { viewer } = makeViewer(); + const other = makeViewer().viewer; + assert.equal(isRenderGovernorInstalled(viewer), false); + installRenderGovernor(viewer); + assert.equal(isRenderGovernorInstalled(viewer), true); + assert.equal(isRenderGovernorInstalled(other), false); + uninstallRenderGovernor(viewer); + assert.equal(isRenderGovernorInstalled(viewer), false); +});