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.
This commit is contained in:
German Khokhlov committed 2026-10-15 18:18:19 +04:00
1 parent a5c98c6b07
commit 58a196374f
26 files changed
+657 -45

No files matched your search

+2
View File
@@ -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",
+1
View File
@@ -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",
+8 -2
View File
@@ -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;
}
+21
View File
@@ -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',
);
});
+11 -1
View File
@@ -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');
+20
View File
@@ -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 });
});
+6 -5
View File
@@ -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(
@@ -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();
});
+40
View File
@@ -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<typeof createCesiumEngine>[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;
}
@@ -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);
});
+21 -11
View File
@@ -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;
+32
View File
@@ -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();
});
+18 -4
View File
@@ -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(
+26
View File
@@ -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();
});
+48 -11
View File
@@ -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),
};
+116
View File
@@ -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);
});
+18 -2
View File
@@ -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);
},
};
+12 -4
View File
@@ -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);
+24
View File
@@ -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, []);
});
+31 -3
View File
@@ -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<void>, setTerrain: (source: object) => Promise<void>, setPhotorealTiles: (source: object | null) => Promise<void>, photorealActive: () => boolean }} maps
* @property {{ setBaseImagery: (source: object) => Promise<void>, setTerrain: (source: object) => Promise<void>, setPhotorealTiles: (source: object | null) => Promise<void>, 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');
+20
View File
@@ -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, {
+35
View File
@@ -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 () => {
+11 -2
View File
@@ -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() {
+10
View File
@@ -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);
});
+17
View File
@@ -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;
+12
View File
@@ -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);
});