diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 7ebdd16c9..e49c8fe39 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -721,6 +721,19 @@
"src/tooling/importDirections.test.mjs",
"src/globe/contract.test.mjs",
"src/globe/testing/fakeEngine.test.mjs",
+ "src/globe/cesium/events.test.mjs",
+ "src/globe/cesium/camera.test.mjs",
+ "src/globe/cesium/projector.test.mjs",
+ "src/globe/cesium/picker.test.mjs",
+ "src/globe/cesium/frames.test.mjs",
+ "src/globe/cesium/render.test.mjs",
+ "src/globe/cesium/surface.test.mjs",
+ "src/globe/cesium/input.test.mjs",
+ "src/globe/cesium/credits.test.mjs",
+ "src/globe/cesium/maps.test.mjs",
+ "src/globe/cesium/createCesiumEngine.test.mjs",
+ "src/globe/cesium/testing/fakeViewer.mjs",
+ "scripts/qa-globe-contract.mjs",
"src/sources/reference.js",
"src/sources/reference.test.mjs",
"src/search/nominatim.js",
diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js
new file mode 100644
index 000000000..ca8e714a6
--- /dev/null
+++ b/src/globe/cesium/camera.js
@@ -0,0 +1,208 @@
+// GlobeCamera on a Cesium camera. Geo values in, Cesium values into Cesium
+// calls, fresh geo values out.
+
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+import { bridgeEvent } from './events.js';
+import { toCesium2, toCesium3, toGeo3 } from './convert.js';
+
+const { math } = geo;
+const MINIMUM_SPHERE_RANGE = 100; // Cesium's MINIMUM_ZOOM for flyToBoundingSphere
+const EASINGS = {
+ cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
+ sinusoidalInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
+ linear: Cesium.EasingFunction.LINEAR_NONE,
+};
+
+// Cesium reports heading and roll in [0, 2π]; the contract wants [0, 2π).
+function wrapAngle(angle) {
+ const value = math.zeroToTwoPi(angle);
+ return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value;
+}
+
+function cesiumOrientation(orientation) {
+ if (!orientation) return undefined;
+ if (orientation.direction)
+ return {
+ direction: toCesium3(orientation.direction),
+ up: toCesium3(orientation.up),
+ };
+ const { heading, pitch, roll } = orientation;
+ return { heading, pitch, roll };
+}
+
+// A HeadingPitchRange or a Cartesian offset, as Cesium's lookAt takes them.
+const cesiumOffset = (offset) =>
+ 'heading' in offset
+ ? new Cesium.HeadingPitchRange(offset.heading, offset.pitch, offset.range)
+ : toCesium3(offset);
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @returns {{ camera: import('../contract.js').GlobeCamera, destroy: () => void }}
+ */
+export function createCesiumCamera(viewer) {
+ const cesium = viewer.scene.camera;
+ const bridges = {
+ changed: bridgeEvent(cesium.changed),
+ moveStart: bridgeEvent(cesium.moveStart),
+ moveEnd: bridgeEvent(cesium.moveEnd),
+ };
+
+ // The engine's own flight, so flightActive is exact even for flights that
+ // completed synchronously (Cesium keeps no current flight for those).
+ let flight = null;
+ function trackFlight(options) {
+ const token = {};
+ flight = token;
+ const end = () => {
+ if (flight === token) flight = null;
+ };
+ return {
+ end,
+ complete: () => {
+ end();
+ options.complete?.();
+ },
+ cancel: () => {
+ end();
+ options.cancel?.();
+ },
+ };
+ }
+ function startFlight(options, start) {
+ const tracked = trackFlight(options);
+ try {
+ start(tracked);
+ } catch (error) {
+ tracked.end();
+ throw error;
+ }
+ }
+
+ const camera = {
+ pose() {
+ const position = toGeo3(cesium.positionWC);
+ return {
+ position,
+ cartographic: geo.cartographic.fromCartesian(
+ position,
+ geo.ellipsoid.WGS84,
+ ),
+ heading: wrapAngle(cesium.heading),
+ pitch: cesium.pitch,
+ roll: wrapAngle(cesium.roll),
+ direction: toGeo3(cesium.directionWC),
+ up: toGeo3(cesium.upWC),
+ right: toGeo3(cesium.rightWC),
+ fovy: cesium.frustum.fovy,
+ };
+ },
+ setView(view) {
+ if (!view?.destination)
+ throw new TypeError('setView needs a destination');
+ cesium.setView({
+ destination: toCesium3(view.destination),
+ orientation: cesiumOrientation(view.orientation),
+ });
+ },
+ flyTo(options) {
+ if (!options?.destination)
+ throw new TypeError('flyTo needs a destination');
+ startFlight(options, ({ complete, cancel }) =>
+ cesium.flyTo({
+ destination: toCesium3(options.destination),
+ orientation: cesiumOrientation(options.orientation),
+ duration: options.duration,
+ easingFunction: options.easing ? EASINGS[options.easing] : undefined,
+ complete,
+ cancel,
+ }),
+ );
+ },
+ flyToSphere(sphere, options = {}) {
+ let range = options.range;
+ if (!range) {
+ const { fovy, aspectRatio } = cesium.frustum;
+ const tanPhi = Math.tan(fovy / 2);
+ const tanTheta = aspectRatio * tanPhi;
+ range = Math.max(
+ MINIMUM_SPHERE_RANGE,
+ sphere.radius / tanTheta,
+ sphere.radius / tanPhi,
+ );
+ }
+ startFlight(options, ({ complete, cancel }) =>
+ cesium.flyToBoundingSphere(
+ new Cesium.BoundingSphere(toCesium3(sphere.center), sphere.radius),
+ {
+ offset: new Cesium.HeadingPitchRange(
+ options.heading ?? 0,
+ options.pitch ?? -Math.PI / 4,
+ range,
+ ),
+ duration: options.duration,
+ complete,
+ cancel,
+ },
+ ),
+ );
+ },
+ flightActive: () => flight != null,
+ lookAt(target, offset) {
+ cesium.lookAt(toCesium3(target), cesiumOffset(offset));
+ },
+ lookAtTransform(transform, offset) {
+ const matrix = Cesium.Matrix4.fromArray(transform);
+ if (offset) cesium.lookAtTransform(matrix, cesiumOffset(offset));
+ else cesium.lookAtTransform(matrix);
+ },
+ transform: () => Cesium.Matrix4.toArray(cesium.transform),
+ release() {
+ cesium.lookAtTransform(Cesium.Matrix4.IDENTITY);
+ },
+ cancelFlight() {
+ cesium.cancelFlight();
+ flight = null;
+ },
+ zoomIn(amount = cesium.defaultZoomAmount) {
+ cesium.zoomIn(amount);
+ },
+ zoomOut(amount = cesium.defaultZoomAmount) {
+ cesium.zoomOut(amount);
+ },
+ viewRectangle() {
+ const rectangle = cesium.computeViewRectangle(Cesium.Ellipsoid.WGS84);
+ if (!rectangle) return undefined;
+ const { west, south, east, north } = rectangle;
+ return { west, south, east, north };
+ },
+ pickEllipsoid(screen) {
+ return toGeo3(
+ cesium.pickEllipsoid(toCesium2(screen), Cesium.Ellipsoid.WGS84),
+ );
+ },
+ pickRay(screen) {
+ const ray = cesium.getPickRay(toCesium2(screen));
+ if (!ray) return undefined;
+ return { origin: toGeo3(ray.origin), direction: toGeo3(ray.direction) };
+ },
+ changed: bridges.changed.event,
+ moveStart: bridges.moveStart.event,
+ moveEnd: bridges.moveEnd.event,
+ get percentageChanged() {
+ return cesium.percentageChanged;
+ },
+ set percentageChanged(value) {
+ cesium.percentageChanged = value;
+ },
+ };
+
+ return {
+ camera,
+ destroy() {
+ if (flight) camera.cancelFlight();
+ for (const bridge of Object.values(bridges)) bridge.detach();
+ },
+ };
+}
diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs
new file mode 100644
index 000000000..23b57aad7
--- /dev/null
+++ b/src/globe/cesium/camera.test.mjs
@@ -0,0 +1,307 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+import { isEngineEvent } from '../contract.js';
+import { createCesiumCamera } from './camera.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+const notCesium = (value, label) => {
+ assert.ok(value != null, `${label} is missing`);
+ for (const type of [
+ Cesium.Cartesian2,
+ Cesium.Cartesian3,
+ Cesium.Cartographic,
+ Cesium.Matrix4,
+ Cesium.Rectangle,
+ Cesium.Ray,
+ ])
+ assert.ok(!(value instanceof type), `${label} leaks a Cesium ${type.name}`);
+};
+
+function setup() {
+ const viewer = createFakeViewer();
+ const { camera, destroy } = createCesiumCamera(viewer);
+ return { viewer, camera, destroy, cesium: viewer.camera };
+}
+
+test('pose returns fresh geo values and wraps 2π angles to 0', () => {
+ const { camera, cesium } = setup();
+ const pose = camera.pose();
+ for (const name of ['position', 'cartographic', 'direction', 'up', 'right'])
+ notCesium(pose[name], name);
+ assert.deepEqual(
+ [pose.position.x, pose.position.y, pose.position.z],
+ [7e6, 1e5, 2e5],
+ );
+ assert.notEqual(pose.position, cesium.positionWC);
+ const expected = geo.cartographic.fromCartesian(
+ pose.position,
+ geo.ellipsoid.WGS84,
+ );
+ assert.equal(pose.cartographic.longitude, expected.longitude);
+ assert.equal(pose.cartographic.height, expected.height);
+ assert.equal(pose.heading, 0);
+ assert.equal(pose.roll, 0);
+ assert.equal(pose.pitch, -Math.PI / 2);
+ assert.equal(pose.fovy, cesium.frustum.fovy);
+ assert.deepEqual([pose.right.x, pose.right.y, pose.right.z], [0, 1, 0]);
+ cesium.heading = 1.25;
+ cesium.roll = 6;
+ assert.equal(camera.pose().heading, 1.25);
+ assert.equal(camera.pose().roll, 6);
+ pose.position.x = 0;
+ assert.equal(cesium.positionWC.x, 7e6, 'pose values are copies');
+});
+
+test('setView converts the destination and passes the orientation through', () => {
+ const { viewer, camera } = setup();
+ const destination = geo.cartesian3.fromDegrees(10, 20, 3000);
+ camera.setView({
+ destination,
+ orientation: { heading: 1, pitch: -0.5, roll: 0.1 },
+ });
+ const [options] = viewer.callsOf('camera.setView')[0].args;
+ assert.ok(options.destination instanceof Cesium.Cartesian3);
+ assert.equal(options.destination.x, destination.x);
+ assert.deepEqual(options.orientation, { heading: 1, pitch: -0.5, roll: 0.1 });
+
+ const direction = geo.cartesian3.create(-1, 0, 0);
+ const up = geo.cartesian3.create(0, 0, 1);
+ camera.setView({ destination, orientation: { direction, up } });
+ const [vectors] = viewer.callsOf('camera.setView')[1].args;
+ assert.ok(vectors.orientation.direction instanceof Cesium.Cartesian3);
+ assert.ok(vectors.orientation.up instanceof Cesium.Cartesian3);
+
+ camera.setView({ destination });
+ const [bare] = viewer.callsOf('camera.setView')[2].args;
+ assert.equal(bare.orientation, undefined, "Cesium's own defaults apply");
+ assert.throws(() => camera.setView({}), TypeError);
+});
+
+test('flyTo maps easing, duration and callbacks and tracks the flight', () => {
+ const { viewer, camera, cesium } = setup();
+ const outcome = [];
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
+ orientation: { heading: 0.5, pitch: -0.8, roll: 0 },
+ duration: 4,
+ easing: 'linear',
+ complete: () => outcome.push('complete'),
+ cancel: () => outcome.push('cancel'),
+ });
+ const [options] = viewer.callsOf('camera.flyTo')[0].args;
+ assert.ok(options.destination instanceof Cesium.Cartesian3);
+ assert.equal(options.duration, 4);
+ assert.equal(options.easingFunction, Cesium.EasingFunction.LINEAR_NONE);
+ assert.deepEqual(options.orientation, { heading: 0.5, pitch: -0.8, roll: 0 });
+ assert.equal(camera.flightActive(), true);
+ cesium.completeFlight();
+ assert.equal(camera.flightActive(), false);
+ assert.deepEqual(outcome, ['complete']);
+
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
+ easing: 'sinusoidalInOut',
+ cancel: () => outcome.push('cancel'),
+ });
+ assert.equal(
+ viewer.callsOf('camera.flyTo')[1].args[0].easingFunction,
+ Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
+ );
+ camera.cancelFlight();
+ assert.equal(camera.flightActive(), false);
+ assert.deepEqual(outcome, ['complete', 'cancel']);
+ assert.equal(viewer.callsOf('camera.cancelFlight').length, 1);
+});
+
+test('flyTo with duration 0 completes before it returns and leaves no flight', () => {
+ const { camera } = setup();
+ let done = false;
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
+ duration: 0,
+ complete: () => (done = true),
+ });
+ assert.equal(done, true);
+ assert.equal(camera.flightActive(), false);
+});
+
+test('a newer flight cancels the previous one and stays active', () => {
+ const { viewer, camera } = setup();
+ const outcome = [];
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
+ duration: 5,
+ cancel: () => outcome.push('first cancel'),
+ });
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(1, 0, 1e6),
+ duration: 5,
+ });
+ assert.deepEqual(outcome, ['first cancel']);
+ assert.equal(camera.flightActive(), true);
+ assert.equal(
+ viewer.callsOf('camera.flyTo')[1].args[0].easingFunction,
+ undefined,
+ "without an easing Cesium's own flight easing applies",
+ );
+});
+
+test('flyToSphere builds a BoundingSphere and an offset with Cesium defaults', () => {
+ const { viewer, camera, cesium } = setup();
+ const center = geo.cartesian3.fromDegrees(2.35, 48.85, 100);
+ let done = false;
+ camera.flyToSphere(
+ { center, radius: 1000 },
+ { duration: 0, complete: () => (done = true) },
+ );
+ assert.equal(done, true);
+ assert.equal(camera.flightActive(), false);
+ const [sphere, options] = viewer.callsOf('camera.flyToBoundingSphere')[0]
+ .args;
+ assert.ok(sphere instanceof Cesium.BoundingSphere);
+ assert.equal(sphere.center.x, center.x);
+ assert.equal(sphere.radius, 1000);
+ assert.ok(options.offset instanceof Cesium.HeadingPitchRange);
+ assert.equal(options.offset.heading, 0);
+ assert.equal(options.offset.pitch, -Math.PI / 4);
+ const tanPhi = Math.tan(cesium.frustum.fovy / 2);
+ const tanTheta = cesium.frustum.aspectRatio * tanPhi;
+ assert.equal(
+ options.offset.range,
+ Math.max(100, 1000 / tanTheta, 1000 / tanPhi),
+ );
+ assert.equal(options.duration, 0);
+
+ camera.flyToSphere({ center, radius: 0 }, { duration: 0 });
+ assert.equal(
+ viewer.callsOf('camera.flyToBoundingSphere')[1].args[1].offset.range,
+ 100,
+ );
+ camera.flyToSphere(
+ { center, radius: 10 },
+ { heading: 0.3, pitch: -0.5, range: 5000, duration: 2 },
+ );
+ const explicit = viewer.callsOf('camera.flyToBoundingSphere')[2].args[1];
+ assert.deepEqual(
+ [explicit.offset.heading, explicit.offset.pitch, explicit.offset.range],
+ [0.3, -0.5, 5000],
+ );
+ assert.equal(camera.flightActive(), true);
+});
+
+test('lookAt, lookAtTransform, transform and release cross the boundary', () => {
+ const { viewer, camera, cesium } = setup();
+ const target = geo.cartesian3.fromDegrees(-0.12, 51.5, 0);
+ camera.lookAt(target, { heading: 0.4, pitch: -0.7, range: 2000 });
+ const [cesiumTarget, offset] = viewer.callsOf('camera.lookAt')[0].args;
+ assert.ok(cesiumTarget instanceof Cesium.Cartesian3);
+ assert.equal(cesiumTarget.y, target.y);
+ assert.ok(offset instanceof Cesium.HeadingPitchRange);
+ assert.deepEqual(
+ [offset.heading, offset.pitch, offset.range],
+ [0.4, -0.7, 2000],
+ );
+
+ const frame = geo.transforms.eastNorthUpToFixedFrame(
+ target,
+ geo.ellipsoid.WGS84,
+ );
+ camera.lookAtTransform(frame, { heading: 1, pitch: -1, range: 10 });
+ let [matrix, hpr] = viewer.callsOf('camera.lookAtTransform')[0].args;
+ assert.ok(matrix instanceof Cesium.Matrix4);
+ assert.deepEqual(Cesium.Matrix4.toArray(matrix), [...frame]);
+ assert.ok(hpr instanceof Cesium.HeadingPitchRange);
+
+ camera.lookAtTransform(frame, geo.cartesian3.create(0, -1000, 1000));
+ [matrix, hpr] = viewer.callsOf('camera.lookAtTransform')[1].args;
+ assert.ok(hpr instanceof Cesium.Cartesian3);
+ assert.deepEqual([hpr.x, hpr.y, hpr.z], [0, -1000, 1000]);
+
+ camera.lookAtTransform(frame);
+ assert.equal(viewer.callsOf('camera.lookAtTransform')[2].args.length, 1);
+
+ const held = camera.transform();
+ assert.ok(Array.isArray(held));
+ notCesium(held, 'transform');
+ assert.deepEqual(held, [...frame]);
+ held[0] = 99;
+ assert.notEqual(cesium.transform[0], 99, 'transform returns a copy');
+
+ camera.release();
+ const [identity] = viewer.callsOf('camera.lookAtTransform')[3].args;
+ assert.equal(identity, Cesium.Matrix4.IDENTITY);
+});
+
+test('zoomIn and zoomOut default to Cesium amount', () => {
+ const { viewer, camera } = setup();
+ camera.zoomIn(2000);
+ camera.zoomOut();
+ assert.deepEqual(viewer.callsOf('camera.zoomIn')[0].args, [2000]);
+ assert.deepEqual(viewer.callsOf('camera.zoomOut')[0].args, [100000]);
+});
+
+test('viewRectangle, pickEllipsoid and pickRay return geo values', () => {
+ const { viewer, camera, cesium } = setup();
+ const rectangle = camera.viewRectangle();
+ notCesium(rectangle, 'viewRectangle');
+ assert.deepEqual(rectangle, { west: 0.1, south: 0.2, east: 0.3, north: 0.4 });
+ assert.equal(
+ viewer.callsOf('camera.computeViewRectangle')[0].args[0],
+ Cesium.Ellipsoid.WGS84,
+ );
+ cesium.computeViewRectangle = () => undefined;
+ assert.equal(camera.viewRectangle(), undefined);
+
+ const hit = camera.pickEllipsoid(geo.cartesian2.create(10, 20));
+ notCesium(hit, 'pickEllipsoid');
+ assert.equal(hit.x, 6378137);
+ const [screen, ellipsoid] = viewer.callsOf('camera.pickEllipsoid')[0].args;
+ assert.ok(screen instanceof Cesium.Cartesian2);
+ assert.deepEqual([screen.x, screen.y], [10, 20]);
+ assert.equal(ellipsoid, Cesium.Ellipsoid.WGS84);
+ cesium.pickEllipsoid = () => undefined;
+ assert.equal(camera.pickEllipsoid(geo.cartesian2.create(0, 0)), undefined);
+
+ const ray = camera.pickRay(geo.cartesian2.create(5, 6));
+ notCesium(ray.origin, 'pickRay origin');
+ notCesium(ray.direction, 'pickRay direction');
+ assert.equal(ray.origin.x, 7e6);
+ assert.equal(ray.direction.x, -1);
+ assert.ok(
+ viewer.callsOf('camera.getPickRay')[0].args[0] instanceof Cesium.Cartesian2,
+ );
+});
+
+test('camera events are bridged and percentageChanged reads through', () => {
+ const { camera, cesium } = setup();
+ for (const name of ['changed', 'moveStart', 'moveEnd']) {
+ assert.ok(isEngineEvent(camera[name]), name);
+ assert.equal(cesium[name].numberOfListeners, 0, `${name} subscribed early`);
+ }
+ const seen = [];
+ const remove = camera.changed.addEventListener((amount) => seen.push(amount));
+ cesium.changed.raiseEvent(0.3);
+ cesium.moveEnd.raiseEvent();
+ assert.deepEqual(seen, [0.3]);
+ remove();
+ assert.equal(cesium.changed.numberOfListeners, 0);
+ assert.equal(camera.percentageChanged, 0.5);
+ camera.percentageChanged = 0.1;
+ assert.equal(cesium.percentageChanged, 0.1);
+});
+
+test('destroy cancels the engine flight and drops Cesium subscriptions', () => {
+ const { camera, destroy, cesium } = setup();
+ const outcome = [];
+ camera.changed.addEventListener(() => {});
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
+ duration: 5,
+ cancel: () => outcome.push('cancel'),
+ });
+ destroy();
+ assert.deepEqual(outcome, ['cancel']);
+ assert.equal(cesium.changed.numberOfListeners, 0);
+});
diff --git a/src/globe/cesium/convert.js b/src/globe/cesium/convert.js
new file mode 100644
index 000000000..ddf101376
--- /dev/null
+++ b/src/globe/cesium/convert.js
@@ -0,0 +1,23 @@
+// The geo ↔ Cesium boundary of the Cesium engine: Cesium values in, fresh
+// src/geo values out, so no Cesium instance reaches the shell.
+
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+
+/** A fresh geo Cartesian3 from any {x, y, z}, or undefined. */
+export const toGeo3 = (value) =>
+ value ? geo.cartesian3.create(value.x, value.y, value.z) : undefined;
+
+/** A fresh geo Cartesian2 from any {x, y}, or undefined. */
+export const toGeo2 = (value) =>
+ value ? geo.cartesian2.create(value.x, value.y) : undefined;
+
+/** A new Cesium.Cartesian3 for a Cesium call. */
+export const toCesium3 = (value) =>
+ new Cesium.Cartesian3(value.x, value.y, value.z);
+
+/**
+ * A new Cesium.Cartesian2 for a window position. Always a new instance: Cesium
+ * caches pickPosition by windowPosition.toString() (phase 1a rule 9).
+ */
+export const toCesium2 = (value) => new Cesium.Cartesian2(value.x, value.y);
diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js
new file mode 100644
index 000000000..34ff8d8e5
--- /dev/null
+++ b/src/globe/cesium/createCesiumEngine.js
@@ -0,0 +1,66 @@
+// The GlobeEngine on an existing Cesium Viewer. Creating it changes nothing
+// in the viewer: Cesium subscriptions, the input handler and render holds
+// appear only when the shell uses them, and destroy() removes them again
+// (the viewer itself stays owned by the app).
+
+import { createCesiumCamera } from './camera.js';
+import { createCesiumCredits } from './credits.js';
+import { createCesiumFrames } from './frames.js';
+import { createCesiumInput } from './input.js';
+import { createCesiumMaps } from './maps.js';
+import { createCesiumPicker } from './picker.js';
+import { createCesiumProjector } from './projector.js';
+import { createCesiumRender } from './render.js';
+import { createCesiumSurface } from './surface.js';
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @param {{
+ * tilesetRef?: { current: object | null } | (() => object | null),
+ * requestRender?: (reason: string) => void,
+ * createEventHandler?: Function,
+ * createImageryLayer?: Function,
+ * loadPhotorealTileset?: Function,
+ * }} [options] `requestRender` defaults to the render governor; the
+ * `create*`/`load*` options are test seams.
+ * @returns {import('../contract.js').GlobeEngine}
+ */
+export function createCesiumEngine(viewer, options = {}) {
+ if (!viewer?.scene) throw new TypeError('createCesiumEngine needs a viewer');
+ const { scene } = viewer;
+ const render = createCesiumRender(viewer, options);
+ const parts = {
+ camera: createCesiumCamera(viewer),
+ picker: createCesiumPicker(viewer),
+ projector: createCesiumProjector(viewer),
+ frames: createCesiumFrames(viewer),
+ render,
+ surface: createCesiumSurface(viewer),
+ input: createCesiumInput(viewer, options),
+ credits: createCesiumCredits(viewer),
+ maps: createCesiumMaps(viewer, {
+ ...options,
+ requestRender: () => render.render.request(),
+ }),
+ };
+ let destroyed = false;
+ return {
+ kind: 'cesium',
+ host: {
+ container: viewer.container,
+ canvas: scene.canvas,
+ size: () => ({
+ width: scene.canvas.clientWidth,
+ height: scene.canvas.clientHeight,
+ }),
+ },
+ ...Object.fromEntries(
+ Object.entries(parts).map(([name, part]) => [name, part[name]]),
+ ),
+ destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ for (const part of Object.values(parts)) part.destroy();
+ },
+ };
+}
diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs
new file mode 100644
index 000000000..d93935c4d
--- /dev/null
+++ b/src/globe/cesium/createCesiumEngine.test.mjs
@@ -0,0 +1,87 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { assertGlobeEngine } from '../contract.js';
+import {
+ _resetRenderGovernorForTest,
+ getRenderGovernorDiagnostics,
+ installRenderGovernor,
+} from '../../renderGovernor.js';
+import { createCesiumEngine } from './createCesiumEngine.js';
+import {
+ createFakeEventHandlers,
+ createFakeViewer,
+} from './testing/fakeViewer.mjs';
+
+test.afterEach(() => _resetRenderGovernorForTest());
+
+function snapshot(viewer) {
+ const { scene } = viewer;
+ return {
+ requestRenderMode: scene.requestRenderMode,
+ maximumRenderTimeChange: scene.maximumRenderTimeChange,
+ globeShow: scene.globe.show,
+ enableInputs: scene.screenSpaceCameraController.enableInputs,
+ percentageChanged: scene.camera.percentageChanged,
+ listeners: [
+ scene.camera.changed,
+ scene.camera.moveStart,
+ scene.camera.moveEnd,
+ scene.preUpdate,
+ scene.preRender,
+ scene.postRender,
+ ].map((event) => event.numberOfListeners),
+ };
+}
+
+test('the engine satisfies the contract shape and changes nothing at creation', () => {
+ const viewer = createFakeViewer({ width: 800, height: 600 });
+ const handlers = createFakeEventHandlers();
+ const before = snapshot(viewer);
+ const engine = createCesiumEngine(viewer, {
+ createEventHandler: handlers.create,
+ });
+ assertGlobeEngine(engine);
+ assert.equal(engine.kind, 'cesium');
+ assert.deepEqual(viewer.calls, []);
+ assert.deepEqual(snapshot(viewer), before);
+ assert.equal(handlers.handlers.length, 0);
+ assert.equal(engine.host.container, viewer.container);
+ assert.equal(engine.host.canvas, viewer.scene.canvas);
+ assert.deepEqual(engine.host.size(), { width: 800, height: 600 });
+ viewer.scene.canvas.clientWidth = 640;
+ assert.deepEqual(engine.host.size(), { width: 640, height: 600 });
+});
+
+test('the render option reaches render.request', () => {
+ const viewer = createFakeViewer();
+ const reasons = [];
+ const engine = createCesiumEngine(viewer, {
+ requestRender: (reason) => reasons.push(reason),
+ });
+ engine.render.request();
+ assert.equal(reasons.length, 1);
+});
+
+test('destroy releases holds, listeners, handlers and engine-owned map state', async () => {
+ const viewer = createFakeViewer();
+ installRenderGovernor(viewer);
+ const handlers = createFakeEventHandlers();
+ const engine = createCesiumEngine(viewer, {
+ createEventHandler: handlers.create,
+ createImageryLayer: (provider) => ({ provider }),
+ });
+ engine.camera.changed.addEventListener(() => {});
+ engine.frames.postRender.addEventListener(() => {});
+ engine.input.on('click', () => {});
+ engine.render.setContinuous(true);
+ engine.credits.addStatic('credit');
+ await engine.maps.setBaseImagery({ kind: 'osm' });
+ engine.destroy();
+ engine.destroy();
+ assert.equal(viewer.scene.camera.changed.numberOfListeners, 0);
+ assert.equal(viewer.scene.postRender.numberOfListeners, 0);
+ assert.equal(handlers.handlers[0].destroyed, true);
+ assert.deepEqual(getRenderGovernorDiagnostics().holds, []);
+ assert.deepEqual(viewer.staticCredits, []);
+ assert.deepEqual(viewer.imageryList, []);
+});
diff --git a/src/globe/cesium/credits.js b/src/globe/cesium/credits.js
new file mode 100644
index 000000000..364247b64
--- /dev/null
+++ b/src/globe/cesium/credits.js
@@ -0,0 +1,29 @@
+// Static credits on the viewer's credit display.
+
+import * as Cesium from 'cesium';
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @returns {{ credits: { addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }, destroy: () => void }}
+ */
+export function createCesiumCredits(viewer) {
+ const added = new Set();
+ const display = () => viewer.creditDisplay;
+ const remove = (credit) => {
+ if (!added.delete(credit)) return;
+ display()?.removeStaticCredit(credit);
+ };
+ return {
+ credits: {
+ addStatic(html, { onScreen = false } = {}) {
+ const credit = new Cesium.Credit(html, Boolean(onScreen));
+ display().addStaticCredit(credit);
+ added.add(credit);
+ return () => remove(credit);
+ },
+ },
+ destroy() {
+ for (const credit of [...added]) remove(credit);
+ },
+ };
+}
diff --git a/src/globe/cesium/credits.test.mjs b/src/globe/cesium/credits.test.mjs
new file mode 100644
index 000000000..ffb48fdf0
--- /dev/null
+++ b/src/globe/cesium/credits.test.mjs
@@ -0,0 +1,33 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { createCesiumCredits } from './credits.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+test('addStatic adds a Cesium credit and returns an idempotent remover', () => {
+ const viewer = createFakeViewer();
+ const { credits } = createCesiumCredits(viewer);
+ const remove = credits.addStatic('Source', { onScreen: true });
+ assert.equal(viewer.staticCredits.length, 1);
+ const [credit] = viewer.staticCredits;
+ assert.ok(credit instanceof Cesium.Credit);
+ assert.equal(credit.html, 'Source');
+ assert.equal(credit.showOnScreen, true);
+ remove();
+ remove();
+ assert.equal(viewer.staticCredits.length, 0);
+ assert.equal(viewer.callsOf('creditDisplay.removeStaticCredit').length, 1);
+ credits.addStatic('Off screen');
+ assert.equal(viewer.staticCredits[0].showOnScreen, false);
+});
+
+test('destroy removes the credits the engine added', () => {
+ const viewer = createFakeViewer();
+ const { credits, destroy } = createCesiumCredits(viewer);
+ credits.addStatic('a');
+ const removeB = credits.addStatic('b');
+ removeB();
+ destroy();
+ assert.equal(viewer.staticCredits.length, 0);
+ assert.equal(viewer.callsOf('creditDisplay.removeStaticCredit').length, 2);
+});
diff --git a/src/globe/cesium/events.js b/src/globe/cesium/events.js
new file mode 100644
index 000000000..755f0ac90
--- /dev/null
+++ b/src/globe/cesium/events.js
@@ -0,0 +1,49 @@
+// Contract events in front of Cesium events. The Cesium subscription exists
+// only while the contract event has listeners, so creating the engine adds
+// nothing to the scene (Cesium does per-frame work for camera.changed only
+// while someone listens).
+
+import { createEngineEvent } from '../contract.js';
+
+/**
+ * @param {{ addEventListener: Function, removeEventListener: Function }} source A Cesium.Event.
+ * @param {(...args: any[]) => any[]} [mapArgs] Cesium arguments → contract arguments.
+ * @returns {{ event: import('../contract.js').EngineEvent, detach: () => void }}
+ */
+export function bridgeEvent(source, mapArgs) {
+ const event = createEngineEvent();
+ const forward = mapArgs
+ ? (...args) => event.raiseEvent(...mapArgs(...args))
+ : (...args) => event.raiseEvent(...args);
+ let attached = false;
+ 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);
+ };
+ const add = event.addEventListener;
+ const remove = event.removeEventListener;
+ event.addEventListener = (listener) => {
+ const removeListener = add(listener);
+ sync();
+ return () => {
+ removeListener();
+ sync();
+ };
+ };
+ event.removeEventListener = (listener) => {
+ const removed = remove(listener);
+ sync();
+ return removed;
+ };
+ return {
+ event,
+ detach() {
+ detached = true;
+ sync();
+ },
+ };
+}
diff --git a/src/globe/cesium/events.test.mjs b/src/globe/cesium/events.test.mjs
new file mode 100644
index 000000000..9ac5892c2
--- /dev/null
+++ b/src/globe/cesium/events.test.mjs
@@ -0,0 +1,63 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { isEngineEvent } from '../contract.js';
+import { bridgeEvent } from './events.js';
+
+test('a bridged event is a contract event that subscribes to Cesium lazily', () => {
+ const source = new Cesium.Event();
+ const { event, detach } = bridgeEvent(source);
+ assert.ok(isEngineEvent(event));
+ assert.equal(source.numberOfListeners, 0, 'creation must not subscribe');
+ const seen = [];
+ const remove = event.addEventListener((value) => seen.push(value));
+ assert.equal(source.numberOfListeners, 1);
+ source.raiseEvent(0.25);
+ assert.deepEqual(seen, [0.25]);
+ remove();
+ assert.equal(source.numberOfListeners, 0, 'last removal unsubscribes');
+ source.raiseEvent(0.5);
+ assert.deepEqual(seen, [0.25]);
+ detach();
+});
+
+test('a bridged event maps Cesium arguments and keeps contract semantics', () => {
+ const source = new Cesium.Event();
+ const { event } = bridgeEvent(source, (scene, time) => [time * 2]);
+ const seen = [];
+ const listener = (value) => seen.push(value);
+ event.addEventListener(listener);
+ event.addEventListener(listener);
+ assert.equal(source.numberOfListeners, 1);
+ source.raiseEvent({}, 3);
+ assert.deepEqual(seen, [6], 'a listener added twice fires once');
+ assert.equal(event.removeEventListener(listener), true);
+ assert.equal(source.numberOfListeners, 0);
+ assert.equal(event.removeEventListener(listener), false);
+});
+
+test('a listener removed during dispatch unsubscribes after the dispatch', () => {
+ const source = new Cesium.Event();
+ const { event } = bridgeEvent(source);
+ let calls = 0;
+ const once = () => {
+ calls++;
+ event.removeEventListener(once);
+ };
+ event.addEventListener(once);
+ source.raiseEvent();
+ source.raiseEvent();
+ assert.equal(calls, 1);
+ assert.equal(source.numberOfListeners, 0);
+});
+
+test('detach drops the Cesium subscription for good', () => {
+ const source = new Cesium.Event();
+ const { event, detach } = bridgeEvent(source);
+ event.addEventListener(() => {});
+ detach();
+ assert.equal(source.numberOfListeners, 0);
+ event.addEventListener(() => assert.fail('detached event fired'));
+ assert.equal(source.numberOfListeners, 0);
+ source.raiseEvent();
+});
diff --git a/src/globe/cesium/frames.js b/src/globe/cesium/frames.js
new file mode 100644
index 000000000..de6f5554c
--- /dev/null
+++ b/src/globe/cesium/frames.js
@@ -0,0 +1,29 @@
+// Frame events on a Cesium scene. Listeners receive the frame's scene time
+// as Unix milliseconds.
+
+import * as Cesium from 'cesium';
+import { bridgeEvent } from './events.js';
+
+const toMilliseconds = (scene, time) => [
+ Cesium.JulianDate.toDate(time).getTime(),
+];
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @returns {{ frames: object, destroy: () => void }}
+ */
+export function createCesiumFrames(viewer) {
+ const { scene } = viewer;
+ const bridges = ['preUpdate', 'preRender', 'postRender'].map((name) => [
+ name,
+ bridgeEvent(scene[name], toMilliseconds),
+ ]);
+ return {
+ frames: Object.fromEntries(
+ bridges.map(([name, bridge]) => [name, bridge.event]),
+ ),
+ destroy() {
+ for (const [, bridge] of bridges) bridge.detach();
+ },
+ };
+}
diff --git a/src/globe/cesium/frames.test.mjs b/src/globe/cesium/frames.test.mjs
new file mode 100644
index 000000000..c8852d5bd
--- /dev/null
+++ b/src/globe/cesium/frames.test.mjs
@@ -0,0 +1,31 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { isEngineEvent } from '../contract.js';
+import { createCesiumFrames } from './frames.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+test('frame events bridge the scene events and pass a millisecond timestamp', () => {
+ const viewer = createFakeViewer();
+ const { frames, destroy } = createCesiumFrames(viewer);
+ const { scene } = viewer;
+ const order = [];
+ for (const name of ['preUpdate', 'preRender', 'postRender']) {
+ assert.ok(isEngineEvent(frames[name]), name);
+ assert.equal(scene[name].numberOfListeners, 0, `${name} subscribed early`);
+ frames[name].addEventListener((time) => order.push([name, time]));
+ }
+ const date = new Date(Date.UTC(2026, 9, 15, 12, 0, 0, 250));
+ const time = Cesium.JulianDate.fromDate(date);
+ scene.preUpdate.raiseEvent(scene, time);
+ scene.preRender.raiseEvent(scene, time);
+ scene.postRender.raiseEvent(scene, time);
+ assert.deepEqual(order, [
+ ['preUpdate', date.getTime()],
+ ['preRender', date.getTime()],
+ ['postRender', date.getTime()],
+ ]);
+ destroy();
+ for (const name of ['preUpdate', 'preRender', 'postRender'])
+ assert.equal(scene[name].numberOfListeners, 0);
+});
diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js
new file mode 100644
index 000000000..d9ada81c5
--- /dev/null
+++ b/src/globe/cesium/input.js
@@ -0,0 +1,86 @@
+// Screen input on one lazily created ScreenSpaceEventHandler. Cesium keeps
+// one action per (type, modifier); the engine fans it out to its listeners.
+
+import * as Cesium from 'cesium';
+import { INPUT_TYPES, MODIFIERS } from '../contract.js';
+import { toGeo2 } from './convert.js';
+
+const TYPES = {
+ click: Cesium.ScreenSpaceEventType.LEFT_CLICK,
+ doubleClick: Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
+ move: Cesium.ScreenSpaceEventType.MOUSE_MOVE,
+ down: Cesium.ScreenSpaceEventType.LEFT_DOWN,
+ up: Cesium.ScreenSpaceEventType.LEFT_UP,
+};
+const KEYS = {
+ ctrl: Cesium.KeyboardEventModifier.CTRL,
+ shift: Cesium.KeyboardEventModifier.SHIFT,
+ alt: Cesium.KeyboardEventModifier.ALT,
+};
+
+// Cesium reuses its event objects; listeners get geo copies.
+const toEvent = (event) => {
+ const result = {};
+ for (const name of ['position', 'startPosition', 'endPosition'])
+ if (event?.[name]) result[name] = toGeo2(event[name]);
+ return result;
+};
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @param {{ createEventHandler?: (canvas: HTMLCanvasElement) => Cesium.ScreenSpaceEventHandler }} [options]
+ * @returns {{ input: object, destroy: () => void }}
+ */
+export function createCesiumInput(
+ viewer,
+ {
+ createEventHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
+ } = {},
+) {
+ const { scene } = viewer;
+ let handler = null;
+ const groups = new Map();
+
+ const input = {
+ on(type, listener, { modifier } = {}) {
+ if (!INPUT_TYPES.includes(type))
+ throw new TypeError(`Unknown input type: ${type}`);
+ if (modifier !== undefined && !MODIFIERS.includes(modifier))
+ throw new TypeError(`Unknown input modifier: ${modifier}`);
+ handler ??= createEventHandler(scene.canvas);
+ const key = `${type}:${modifier}`;
+ let listeners = groups.get(key);
+ if (!listeners) {
+ listeners = new Set();
+ groups.set(key, listeners);
+ handler.setInputAction(
+ (event) => {
+ const converted = toEvent(event);
+ for (const entry of [...listeners]) entry.listener(converted);
+ },
+ TYPES[type],
+ KEYS[modifier],
+ );
+ }
+ const entry = { listener };
+ listeners.add(entry);
+ return () => {
+ if (!listeners.delete(entry) || listeners.size) return;
+ groups.delete(key);
+ if (handler && !handler.isDestroyed?.())
+ handler.removeInputAction(TYPES[type], KEYS[modifier]);
+ };
+ },
+ setCameraInputs(enabled) {
+ scene.screenSpaceCameraController.enableInputs = Boolean(enabled);
+ },
+ };
+ return {
+ input,
+ destroy() {
+ groups.clear();
+ handler?.destroy();
+ handler = null;
+ },
+ };
+}
diff --git a/src/globe/cesium/input.test.mjs b/src/globe/cesium/input.test.mjs
new file mode 100644
index 000000000..dfabf2821
--- /dev/null
+++ b/src/globe/cesium/input.test.mjs
@@ -0,0 +1,113 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { createCesiumInput } from './input.js';
+import {
+ createFakeEventHandlers,
+ createFakeViewer,
+} from './testing/fakeViewer.mjs';
+
+const { ScreenSpaceEventType: Type, KeyboardEventModifier: Key } = Cesium;
+
+function setup() {
+ const viewer = createFakeViewer();
+ const handlers = createFakeEventHandlers();
+ const { input, destroy } = createCesiumInput(viewer, {
+ createEventHandler: handlers.create,
+ });
+ return { viewer, input, destroy, handlers: handlers.handlers };
+}
+
+test('no handler is created until the first subscription', () => {
+ const { handlers, input, viewer } = setup();
+ assert.equal(handlers.length, 0);
+ input.on('click', () => {});
+ assert.equal(handlers.length, 1);
+ assert.equal(handlers[0].canvas, viewer.scene.canvas);
+ input.on('move', () => {});
+ assert.equal(handlers.length, 1, 'one shared handler');
+});
+
+test('types map to Cesium events and positions arrive as geo copies', () => {
+ const { handlers, input } = setup();
+ const seen = [];
+ input.on('click', (event) => seen.push(['click', event]));
+ input.on('doubleClick', (event) => seen.push(['doubleClick', event]));
+ input.on('down', (event) => seen.push(['down', event]));
+ input.on('up', (event) => seen.push(['up', event]));
+ input.on('move', (event) => seen.push(['move', event]));
+ const [handler] = handlers;
+ const position = new Cesium.Cartesian2(1, 2);
+ handler.fire(Type.LEFT_CLICK, { position });
+ handler.fire(Type.LEFT_DOUBLE_CLICK, { position });
+ handler.fire(Type.LEFT_DOWN, { position });
+ handler.fire(Type.LEFT_UP, { position });
+ handler.fire(Type.MOUSE_MOVE, {
+ startPosition: new Cesium.Cartesian2(3, 4),
+ endPosition: new Cesium.Cartesian2(5, 6),
+ });
+ assert.deepEqual(
+ seen.map(([type]) => type),
+ ['click', 'doubleClick', 'down', 'up', 'move'],
+ );
+ const click = seen[0][1];
+ assert.ok(!(click.position instanceof Cesium.Cartesian2));
+ assert.deepEqual([click.position.x, click.position.y], [1, 2]);
+ const move = seen[4][1];
+ assert.deepEqual(
+ [move.startPosition.x, move.startPosition.y, move.endPosition.x],
+ [3, 4, 5],
+ );
+ assert.ok(!(move.endPosition instanceof Cesium.Cartesian2));
+});
+
+test('modifiers register separate Cesium actions', () => {
+ const { handlers, input } = setup();
+ const seen = [];
+ input.on('click', () => seen.push('plain'));
+ input.on('click', () => seen.push('ctrl'), { modifier: 'ctrl' });
+ input.on('click', () => seen.push('shift'), { modifier: 'shift' });
+ input.on('click', () => seen.push('alt'), { modifier: 'alt' });
+ const [handler] = handlers;
+ const position = new Cesium.Cartesian2(0, 0);
+ handler.fire(Type.LEFT_CLICK, { position }, Key.CTRL);
+ handler.fire(Type.LEFT_CLICK, { position }, Key.SHIFT);
+ handler.fire(Type.LEFT_CLICK, { position }, Key.ALT);
+ handler.fire(Type.LEFT_CLICK, { position });
+ assert.deepEqual(seen, ['ctrl', 'shift', 'alt', 'plain']);
+ assert.throws(() => input.on('click', () => {}, { modifier: 'meta' }));
+ assert.throws(() => input.on('wheel', () => {}));
+});
+
+test('listeners share an action, removers are idempotent and the action goes with the last', () => {
+ const { handlers, input } = setup();
+ const seen = [];
+ const offA = input.on('click', () => seen.push('a'));
+ const offB = input.on('click', () => seen.push('b'));
+ const [handler] = handlers;
+ const key = `${Type.LEFT_CLICK}:undefined`;
+ handler.fire(Type.LEFT_CLICK, { position: new Cesium.Cartesian2() });
+ assert.deepEqual(seen, ['a', 'b']);
+ offA();
+ offA();
+ assert.ok(handler.actions.has(key));
+ handler.fire(Type.LEFT_CLICK, { position: new Cesium.Cartesian2() });
+ assert.deepEqual(seen, ['a', 'b', 'b']);
+ offB();
+ assert.ok(!handler.actions.has(key));
+});
+
+test('setCameraInputs toggles the camera controller', () => {
+ const { viewer, input } = setup();
+ input.setCameraInputs(false);
+ assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, false);
+ input.setCameraInputs(true);
+ assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, true);
+});
+
+test('destroy destroys the handler', () => {
+ const { handlers, input, destroy } = setup();
+ input.on('click', () => {});
+ destroy();
+ assert.equal(handlers[0].destroyed, true);
+});
diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js
new file mode 100644
index 000000000..0e4a17110
--- /dev/null
+++ b/src/globe/cesium/maps.js
@@ -0,0 +1,176 @@
+// Map sources on a Cesium viewer. Sources are maps/registry.js entries
+// ({ descriptor, imagery(), credit } and terrain { id, create() }) or plain
+// descriptions ({ kind, style?, accessToken? }); providers come from the
+// shared factories in src/maps, as MapSourceController builds them.
+
+import * as Cesium from 'cesium';
+import { loadPhotorealisticTileset } from '../../maps/google3d.js';
+import { createMapCredits } from '../../maps/credits.js';
+import {
+ ESRI_ATTRIBUTION_HTML,
+ imageryFactoryFor,
+} from '../../maps/imagery.js';
+import { applyTerrain, terrainSourceFor } from '../../maps/terrain.js';
+
+const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'ion']);
+
+function imageryCreator(source) {
+ if (typeof source?.imagery === 'function') return source.imagery;
+ const descriptor = source?.descriptor ?? source;
+ if (!IMAGERY_KINDS.has(descriptor?.kind))
+ throw new TypeError(`Unknown imagery source: ${descriptor?.kind}`);
+ return imageryFactoryFor(descriptor, source.accessToken);
+}
+
+function imageryCredit(source) {
+ if (source?.credit !== undefined) return source.credit;
+ const kind = (source?.descriptor ?? source)?.kind;
+ return kind === 'esri-imagery' ? ESRI_ATTRIBUTION_HTML : null;
+}
+
+function terrainCreator(source) {
+ if (typeof source?.create === 'function') return source.create;
+ switch (source?.kind) {
+ case 'ellipsoid':
+ return () => ({ provider: new Cesium.EllipsoidTerrainProvider() });
+ case 'keyless':
+ return terrainSourceFor('').create;
+ case 'world': {
+ const token = String(source.accessToken || '').trim();
+ if (!token)
+ return () => {
+ throw new Error('World terrain requires an explicit ion token');
+ };
+ return terrainSourceFor(token).create;
+ }
+ default:
+ throw new TypeError(`Unknown terrain source: ${source?.kind}`);
+ }
+}
+
+const dispose = (value) => {
+ if (typeof value?.destroy === 'function' && !value.isDestroyed?.())
+ value.destroy();
+};
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @param {{
+ * tilesetRef?: { current: object | null } | (() => object | null),
+ * requestRender?: (reason: string) => void,
+ * createImageryLayer?: (provider: object) => object,
+ * loadPhotorealTileset?: (credentials: object) => Promise<{ tileset: object | null, errors: Error[] }>,
+ * }} [options] `tilesetRef` names the startup Google tileset the app added.
+ * @returns {{ maps: object, destroy: () => void }}
+ */
+export function createCesiumMaps(
+ viewer,
+ {
+ tilesetRef = null,
+ requestRender = () => viewer.scene.requestRender?.(),
+ createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
+ loadPhotorealTileset = (credentials) =>
+ loadPhotorealisticTileset(Cesium, credentials),
+ } = {},
+) {
+ const abort = new AbortController();
+ let credits = null;
+ let layer = null;
+ let ownedTileset = null;
+ let shownTileset = null;
+ const generations = { imagery: 0, terrain: 0, photoreal: 0 };
+ let destroyed = false;
+
+ const startupTileset = () =>
+ (typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ??
+ null;
+ const tilesets = () =>
+ [startupTileset(), shownTileset, ownedTileset].filter(
+ (tileset, index, all) => tileset && all.indexOf(tileset) === index,
+ );
+ const showCredit = (html) => {
+ credits ??= createMapCredits(viewer);
+ credits.show(html);
+ };
+
+ const maps = {
+ async setBaseImagery(source) {
+ const generation = ++generations.imagery;
+ const create = imageryCreator(source);
+ const provider = await create({ signal: abort.signal });
+ if (destroyed || generation !== generations.imagery) {
+ dispose(provider);
+ return;
+ }
+ const next = createImageryLayer(provider);
+ viewer.imageryLayers.add(next, 0);
+ if (layer) viewer.imageryLayers.remove(layer, true);
+ layer = next;
+ showCredit(imageryCredit(source));
+ requestRender('map-imagery');
+ },
+ async setTerrain(source) {
+ const generation = ++generations.terrain;
+ const result = await terrainCreator(source)({ signal: abort.signal });
+ if (destroyed || generation !== generations.terrain) return;
+ applyTerrain(viewer, result);
+ requestRender('map-terrain');
+ },
+ async setPhotorealTiles(source) {
+ const generation = ++generations.photoreal;
+ const { scene } = viewer;
+ if (source == null) {
+ for (const tileset of tilesets()) tileset.show = false;
+ shownTileset = null;
+ scene.globe.show = true;
+ requestRender('map-photoreal');
+ return;
+ }
+ let tileset = source.tileset ?? null;
+ let owned = false;
+ if (!tileset) {
+ const { tileset: loaded, errors = [] } =
+ await loadPhotorealTileset(source);
+ if (!loaded)
+ throw errors.at(-1) ?? new Error('Google 3D Tiles are unavailable');
+ tileset = loaded;
+ owned = true;
+ }
+ if (destroyed || generation !== generations.photoreal) {
+ if (owned) dispose(tileset);
+ return;
+ }
+ if (owned && ownedTileset !== tileset) {
+ if (ownedTileset) {
+ scene.primitives.remove(ownedTileset);
+ dispose(ownedTileset);
+ }
+ ownedTileset = tileset;
+ }
+ if (!scene.primitives.contains(tileset)) scene.primitives.add(tileset);
+ for (const other of tilesets()) other.show = other === tileset;
+ tileset.show = true;
+ shownTileset = tileset;
+ scene.globe.show = false;
+ requestRender('map-photoreal');
+ },
+ photorealActive: () => tilesets().some((tileset) => tileset.show === true),
+ };
+
+ return {
+ maps,
+ destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ abort.abort();
+ if (layer) viewer.imageryLayers.remove(layer, true);
+ layer = null;
+ credits?.destroy();
+ if (ownedTileset) {
+ viewer.scene.primitives.remove(ownedTileset);
+ dispose(ownedTileset);
+ ownedTileset = null;
+ }
+ },
+ };
+}
diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs
new file mode 100644
index 000000000..3485199f9
--- /dev/null
+++ b/src/globe/cesium/maps.test.mjs
@@ -0,0 +1,211 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { MAP_STACKS } from '../../maps/catalog.js';
+import { createDefaultMapSources } from '../../maps/defaultSources.js';
+import { ESRI_ATTRIBUTION_HTML } from '../../maps/imagery.js';
+import { createCesiumMaps } from './maps.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+function setup(options = {}) {
+ const viewer = createFakeViewer();
+ const renders = [];
+ const { maps, destroy } = createCesiumMaps(viewer, {
+ requestRender: (reason) => renders.push(reason),
+ createImageryLayer: (provider) => ({ provider }),
+ ...options,
+ });
+ return { viewer, maps, destroy, renders };
+}
+
+const tileset = (show = false) => ({
+ show,
+ destroyed: false,
+ isDestroyed() {
+ return this.destroyed;
+ },
+ destroy() {
+ this.destroyed = true;
+ },
+});
+
+test('creation leaves the viewer untouched', () => {
+ const { viewer, maps } = setup({ tilesetRef: { current: tileset(true) } });
+ assert.deepEqual(viewer.calls, []);
+ assert.equal(viewer.scene.globe.show, true);
+ assert.equal(maps.photorealActive(), true, 'reads the shown tileset');
+});
+
+test('setBaseImagery takes a registry source and puts its layer at the bottom', async () => {
+ const { viewer, maps, renders } = setup();
+ const provider = { name: 'esri' };
+ const source = {
+ descriptor: { id: 'esri-imagery', kind: 'esri-imagery' },
+ imagery: async () => provider,
+ credit: ESRI_ATTRIBUTION_HTML,
+ };
+ viewer.imageryList.push({ name: 'weather overlay' });
+ await maps.setBaseImagery(source);
+ assert.deepEqual(viewer.imageryList[0], { provider });
+ assert.equal(viewer.callsOf('imageryLayers.add')[0].args[1], 0);
+ assert.deepEqual(
+ viewer.staticCredits.map((credit) => credit.html),
+ [ESRI_ATTRIBUTION_HTML],
+ );
+ assert.ok(renders.length > 0);
+
+ const osmProvider = { name: 'osm' };
+ await maps.setBaseImagery({
+ descriptor: { id: 'osm', kind: 'osm' },
+ imagery: () => osmProvider,
+ });
+ const [removed, destroyLayer] = viewer.callsOf('imageryLayers.remove')[0]
+ .args;
+ assert.deepEqual(removed, { provider });
+ assert.equal(destroyLayer, true);
+ assert.deepEqual(
+ viewer.imageryList.map((layer) => layer.provider?.name ?? layer.name),
+ ['osm', 'weather overlay'],
+ );
+ assert.deepEqual(viewer.staticCredits, [], 'the Esri credit leaves with it');
+});
+
+test('setBaseImagery builds providers from plain descriptions', async () => {
+ const { viewer, maps } = setup();
+ await maps.setBaseImagery({ kind: 'osm' });
+ assert.ok(
+ viewer.imageryList[0].provider instanceof
+ Cesium.OpenStreetMapImageryProvider,
+ );
+ await assert.rejects(
+ maps.setBaseImagery({ kind: 'ion', style: 2, accessToken: '' }),
+ /explicit token/,
+ );
+ assert.ok(
+ viewer.imageryList[0].provider instanceof
+ Cesium.OpenStreetMapImageryProvider,
+ 'a failed switch keeps the current layer',
+ );
+});
+
+test('every catalog globe stack works as a registry source', async () => {
+ const { viewer, maps } = setup();
+ const registry = createDefaultMapSources();
+ const osm = registry.sources.find(
+ ({ descriptor }) => descriptor.id === 'osm',
+ );
+ await maps.setBaseImagery(osm);
+ assert.ok(
+ viewer.imageryList[0].provider instanceof
+ Cesium.OpenStreetMapImageryProvider,
+ );
+ assert.ok(MAP_STACKS.some(({ id }) => id === 'osm'));
+});
+
+test('a superseded imagery switch is dropped', async () => {
+ const { viewer, maps } = setup();
+ let release;
+ const slow = new Promise((resolve) => (release = resolve));
+ const first = maps.setBaseImagery({
+ descriptor: { id: 'slow', kind: 'esri-imagery' },
+ imagery: () => slow,
+ });
+ await maps.setBaseImagery({
+ descriptor: { id: 'fast', kind: 'osm' },
+ imagery: () => ({ name: 'fast' }),
+ });
+ const stale = {
+ name: 'slow',
+ destroyed: false,
+ destroy() {
+ this.destroyed = true;
+ },
+ };
+ release(stale);
+ await first;
+ assert.deepEqual(
+ viewer.imageryList.map((layer) => layer.provider.name),
+ ['fast'],
+ );
+ assert.equal(stale.destroyed, true, 'the stale provider is disposed');
+});
+
+test('setTerrain applies provider and terrain results', async () => {
+ const { viewer, maps } = setup();
+ await maps.setTerrain({ kind: 'ellipsoid' });
+ assert.ok(viewer.terrainProvider instanceof Cesium.EllipsoidTerrainProvider);
+ const provider = { name: 'keyless' };
+ await maps.setTerrain({ id: 'keyless', create: async () => ({ provider }) });
+ assert.equal(viewer.terrainProvider, provider);
+ const terrain = { name: 'terrain object' };
+ await maps.setTerrain({ id: 'world', create: async () => ({ terrain }) });
+ assert.equal(viewer.callsOf('scene.setTerrain')[0].args[0], terrain);
+ await assert.rejects(
+ maps.setTerrain({ kind: 'world', accessToken: ' ' }),
+ /explicit ion token/,
+ );
+});
+
+test('setPhotorealTiles shows a given tileset and hides the globe; null reverses it', async () => {
+ const { viewer, maps } = setup();
+ const google = tileset();
+ await maps.setPhotorealTiles({ tileset: google });
+ assert.equal(viewer.primitiveList.includes(google), true);
+ assert.equal(google.show, true);
+ assert.equal(viewer.scene.globe.show, false);
+ assert.equal(maps.photorealActive(), true);
+ await maps.setPhotorealTiles(null);
+ assert.equal(google.show, false);
+ assert.equal(viewer.scene.globe.show, true);
+ assert.equal(maps.photorealActive(), false);
+ await maps.setPhotorealTiles({ tileset: google });
+ assert.equal(viewer.callsOf('primitives.add').length, 1, 'added once');
+});
+
+test('setPhotorealTiles(null) also hides the startup tileset', async () => {
+ const startup = tileset(true);
+ const { viewer, maps } = setup({ tilesetRef: () => startup });
+ viewer.scene.globe.show = false;
+ await maps.setPhotorealTiles(null);
+ assert.equal(startup.show, false);
+ assert.equal(viewer.scene.globe.show, true);
+ assert.equal(maps.photorealActive(), false);
+ await maps.setPhotorealTiles({ tileset: startup });
+ assert.equal(startup.show, true);
+ assert.equal(maps.photorealActive(), true);
+});
+
+test('setPhotorealTiles loads from credentials and owns the result', async () => {
+ const loaded = tileset();
+ const requests = [];
+ const { viewer, maps, destroy } = setup({
+ loadPhotorealTileset: async (credentials) => {
+ requests.push(credentials);
+ return {
+ tileset: requests.length === 1 ? loaded : null,
+ errors: [new Error('refused')],
+ };
+ },
+ });
+ await maps.setPhotorealTiles({ googleApiKey: 'browser-key' });
+ assert.deepEqual(requests[0], { googleApiKey: 'browser-key' });
+ assert.equal(loaded.show, true);
+ assert.equal(maps.photorealActive(), true);
+ await assert.rejects(maps.setPhotorealTiles({ cesiumToken: 'x' }), /refused/);
+ assert.equal(loaded.show, true, 'a failed load keeps the current tiles');
+ destroy();
+ assert.equal(viewer.primitiveList.includes(loaded), false);
+ assert.equal(loaded.destroyed, true);
+});
+
+test('destroy removes the engine imagery layer and its credit', async () => {
+ const { viewer, maps, destroy } = setup();
+ await maps.setBaseImagery({
+ descriptor: { id: 'esri-imagery', kind: 'esri-imagery' },
+ imagery: () => ({ name: 'esri' }),
+ credit: ESRI_ATTRIBUTION_HTML,
+ });
+ destroy();
+ assert.deepEqual(viewer.imageryList, []);
+ assert.deepEqual(viewer.staticCredits, []);
+});
diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js
new file mode 100644
index 000000000..ce0ed9bd0
--- /dev/null
+++ b/src/globe/cesium/picker.js
@@ -0,0 +1,39 @@
+// GlobePicker on a Cesium scene. Pick results keep Cesium's `id` and
+// `primitive`; the untouched Cesium result rides along as `object` for
+// Cesium-only callers.
+
+import { toCesium2, toGeo3 } from './convert.js';
+
+const normalise = (picked) =>
+ picked == null
+ ? undefined
+ : { id: picked.id, primitive: picked.primitive, object: picked };
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @returns {{ picker: object, destroy: () => void }}
+ */
+export function createCesiumPicker(viewer) {
+ const { scene } = viewer;
+ const picker = {
+ pick(screen, width, height) {
+ return normalise(scene.pick(toCesium2(screen), width, height));
+ },
+ drillPick(screen, limit) {
+ return scene.drillPick(toCesium2(screen), limit).map(normalise);
+ },
+ pickPosition(screen) {
+ if (!scene.pickPositionSupported) return undefined;
+ try {
+ // A new Cesium.Cartesian2 per call: Cesium caches by its toString().
+ return toGeo3(scene.pickPosition(toCesium2(screen)));
+ } catch {
+ return undefined;
+ }
+ },
+ get pickPositionSupported() {
+ return Boolean(scene.pickPositionSupported);
+ },
+ };
+ return { picker, destroy() {} };
+}
diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs
new file mode 100644
index 000000000..000f3e0b2
--- /dev/null
+++ b/src/globe/cesium/picker.test.mjs
@@ -0,0 +1,80 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+import { createCesiumPicker } from './picker.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+function setup() {
+ const viewer = createFakeViewer();
+ const { picker } = createCesiumPicker(viewer);
+ return { viewer, picker, scene: viewer.scene };
+}
+
+test('pick passes a Cesium window position and keeps id and primitive', () => {
+ const { viewer, picker, scene } = setup();
+ assert.equal(picker.pick(geo.cartesian2.create(1, 2)), undefined);
+ const entity = { name: 'entity' };
+ const primitive = { name: 'billboard' };
+ const raw = { id: entity, primitive };
+ scene.pick = (...args) => {
+ viewer.calls.push({ name: 'scene.pick', args });
+ return raw;
+ };
+ const picked = picker.pick(geo.cartesian2.create(3, 4), 5, 6);
+ assert.equal(picked.id, entity);
+ assert.equal(picked.primitive, primitive);
+ assert.equal(picked.object, raw, 'the Cesium pick result rides along');
+ const [screen, width, height] = viewer.callsOf('scene.pick')[1].args;
+ assert.ok(screen instanceof Cesium.Cartesian2);
+ assert.deepEqual([screen.x, screen.y, width, height], [3, 4, 5, 6]);
+});
+
+test('drillPick passes the limit and normalises every result', () => {
+ const { viewer, picker, scene } = setup();
+ scene.drillPick = (...args) => {
+ viewer.calls.push({ name: 'scene.drillPick', args });
+ return [{ id: 'a', primitive: 1 }, { primitive: 2 }];
+ };
+ const drilled = picker.drillPick(geo.cartesian2.create(1, 1), 3);
+ assert.deepEqual(
+ drilled.map(({ id, primitive }) => [id, primitive]),
+ [
+ ['a', 1],
+ [undefined, 2],
+ ],
+ );
+ assert.ok('id' in drilled[1]);
+ const [screen, limit] = viewer.callsOf('scene.drillPick')[0].args;
+ assert.ok(screen instanceof Cesium.Cartesian2);
+ assert.equal(limit, 3);
+});
+
+test('pickPosition passes a new Cesium.Cartesian2 per call and returns geo', () => {
+ const { viewer, picker, scene } = setup();
+ const position = picker.pickPosition(geo.cartesian2.create(7, 8));
+ assert.ok(!(position instanceof Cesium.Cartesian3));
+ assert.deepEqual([position.x, position.y, position.z], [6378137, 1, 2]);
+ picker.pickPosition(geo.cartesian2.create(9, 10));
+ const [first, second] = viewer
+ .callsOf('scene.pickPosition')
+ .map((call) => call.args[0]);
+ // Cesium caches pickPosition by windowPosition.toString().
+ assert.ok(first instanceof Cesium.Cartesian2);
+ assert.notEqual(first, second);
+ assert.equal(second.toString(), '(9, 10)');
+ scene.pickPosition = () => undefined;
+ assert.equal(picker.pickPosition(geo.cartesian2.create(1, 1)), undefined);
+ scene.pickPosition = () => {
+ throw new Error('depth texture missing');
+ };
+ assert.equal(picker.pickPosition(geo.cartesian2.create(1, 1)), undefined);
+});
+
+test('pickPositionSupported follows the scene', () => {
+ const { picker, scene } = setup();
+ assert.equal(picker.pickPositionSupported, true);
+ scene.pickPositionSupported = false;
+ assert.equal(picker.pickPositionSupported, false);
+ assert.equal(picker.pickPosition(geo.cartesian2.create(1, 1)), undefined);
+});
diff --git a/src/globe/cesium/projector.js b/src/globe/cesium/projector.js
new file mode 100644
index 000000000..0345c12c7
--- /dev/null
+++ b/src/globe/cesium/projector.js
@@ -0,0 +1,52 @@
+// GlobeProjector on a Cesium scene.
+
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @param {{ worldToWindowCoordinates?: Function }} [options] Test seam for
+ * Cesium.SceneTransforms.worldToWindowCoordinates.
+ * @returns {{ projector: import('../contract.js').GlobeProjector, destroy: () => void }}
+ */
+export function createCesiumProjector(
+ viewer,
+ {
+ worldToWindowCoordinates = Cesium.SceneTransforms.worldToWindowCoordinates,
+ } = {},
+) {
+ const { scene } = viewer;
+ const scratchWindow = new Cesium.Cartesian2();
+ const scratchMatrix = new Cesium.Matrix4();
+ const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
+
+ const projector = {
+ worldToWindow(position, result) {
+ // Cesium returns undefined in front of the near plane or behind the camera.
+ const window = worldToWindowCoordinates(scene, position, scratchWindow);
+ if (!window) return undefined;
+ if (!result) return geo.cartesian2.create(window.x, window.y);
+ result.x = window.x;
+ result.y = window.y;
+ return result;
+ },
+ // 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,
+ scratchMatrix,
+ );
+ for (let index = 0; index < 16; index++)
+ result[index] = scratchMatrix[index];
+ return result;
+ },
+ horizonVisible(position) {
+ occluder.cameraPosition = scene.camera.positionWC;
+ return occluder.isPointVisible(position);
+ },
+ };
+ return { projector, destroy() {} };
+}
diff --git a/src/globe/cesium/projector.test.mjs b/src/globe/cesium/projector.test.mjs
new file mode 100644
index 000000000..e3b7fa6e9
--- /dev/null
+++ b/src/globe/cesium/projector.test.mjs
@@ -0,0 +1,88 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+import { createCesiumProjector } from './projector.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+function setup(transform) {
+ const viewer = createFakeViewer();
+ const calls = [];
+ const { projector } = createCesiumProjector(viewer, {
+ worldToWindowCoordinates(scene, position, result) {
+ calls.push({ scene, position, result });
+ return transform ? transform(position, result) : undefined;
+ },
+ });
+ return { viewer, projector, calls };
+}
+
+test('worldToWindow returns a fresh geo value or fills result', () => {
+ const { viewer, projector, calls } = setup((position, result) => {
+ result.x = position.x / 10;
+ result.y = 7;
+ return result;
+ });
+ const point = geo.cartesian3.create(100, 0, 0);
+ const window = projector.worldToWindow(point);
+ assert.ok(!(window instanceof Cesium.Cartesian2), 'leaks a Cesium value');
+ assert.deepEqual([window.x, window.y], [10, 7]);
+ assert.equal(calls[0].scene, viewer.scene);
+ assert.equal(calls[0].position, point);
+ const result = geo.cartesian2.create();
+ assert.equal(projector.worldToWindow(point, result), result);
+ assert.deepEqual([result.x, result.y], [10, 7]);
+ assert.notEqual(window, projector.worldToWindow(point), 'fresh per call');
+});
+
+test('worldToWindow is undefined when Cesium finds no window position', () => {
+ const { projector } = setup(() => undefined);
+ const result = geo.cartesian2.create(3, 4);
+ assert.equal(
+ projector.worldToWindow(geo.cartesian3.create(1, 2, 3), result),
+ undefined,
+ );
+ assert.deepEqual([result.x, result.y], [3, 4]);
+});
+
+test('viewProjection is the frustum projection times the view matrix', () => {
+ const { viewer, projector } = setup();
+ const { camera } = viewer.scene;
+ const expected = Cesium.Matrix4.toArray(
+ Cesium.Matrix4.multiply(
+ camera.frustum.projectionMatrix,
+ camera.viewMatrix,
+ new Cesium.Matrix4(),
+ ),
+ );
+ const result = new Array(16).fill(0);
+ assert.equal(projector.viewProjection(result), result);
+ assert.deepEqual(result, expected);
+ const fresh = projector.viewProjection();
+ assert.ok(Array.isArray(fresh), 'a plain column-major array');
+ assert.ok(!(fresh instanceof Cesium.Matrix4));
+ assert.deepEqual(fresh, expected);
+});
+
+test('horizonVisible uses the WGS84 occluder from the camera position', () => {
+ const { viewer, projector } = setup();
+ viewer.scene.camera.positionWC = Cesium.Cartesian3.fromDegrees(20, 10, 1e6);
+ assert.equal(
+ projector.horizonVisible(geo.cartesian3.fromDegrees(20, 10, 0)),
+ true,
+ );
+ assert.equal(
+ projector.horizonVisible(geo.cartesian3.fromDegrees(-160, -10, 0)),
+ false,
+ );
+ viewer.scene.camera.positionWC = Cesium.Cartesian3.fromDegrees(
+ -160,
+ -10,
+ 1e6,
+ );
+ assert.equal(
+ projector.horizonVisible(geo.cartesian3.fromDegrees(-160, -10, 0)),
+ true,
+ 'follows the camera',
+ );
+});
diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js
new file mode 100644
index 000000000..2f219396f
--- /dev/null
+++ b/src/globe/cesium/render.js
@@ -0,0 +1,41 @@
+// GlobeRender on the app's render governor, which owns Cesium's
+// requestRenderMode and maximumRenderTimeChange; the engine only takes a
+// hold under its own owner id.
+
+import {
+ governorRequestRender,
+ holdContinuousRender,
+ releaseContinuousRender,
+} from '../../renderGovernor.js';
+
+export const RENDER_OWNER = 'globe-engine';
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @param {{ requestRender?: (reason: string) => void }} [options]
+ * @returns {{ render: import('../contract.js').GlobeRender, destroy: () => void }}
+ */
+export function createCesiumRender(
+ viewer,
+ { requestRender = governorRequestRender } = {},
+) {
+ let held = false;
+ const render = {
+ request() {
+ requestRender(RENDER_OWNER);
+ },
+ setContinuous(enabled) {
+ held = Boolean(enabled);
+ if (held) holdContinuousRender(RENDER_OWNER);
+ else releaseContinuousRender(RENDER_OWNER);
+ },
+ // The scene's real mode: another owner's hold keeps it continuous.
+ isContinuous: () => viewer.scene.requestRenderMode === false,
+ };
+ return {
+ render,
+ destroy() {
+ if (held) render.setContinuous(false);
+ },
+ };
+}
diff --git a/src/globe/cesium/render.test.mjs b/src/globe/cesium/render.test.mjs
new file mode 100644
index 000000000..50d7df9dd
--- /dev/null
+++ b/src/globe/cesium/render.test.mjs
@@ -0,0 +1,78 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ _resetRenderGovernorForTest,
+ getRenderGovernorDiagnostics,
+ holdContinuousRender,
+ installRenderGovernor,
+} from '../../renderGovernor.js';
+import { createCesiumRender, RENDER_OWNER } from './render.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+test.beforeEach(() => _resetRenderGovernorForTest());
+test.after(() => _resetRenderGovernorForTest());
+
+test('request goes through the injected requestRender with a reason', () => {
+ const viewer = createFakeViewer();
+ const reasons = [];
+ const { render } = createCesiumRender(viewer, {
+ requestRender: (reason) => reasons.push(reason),
+ });
+ render.request();
+ assert.deepEqual(reasons, [RENDER_OWNER]);
+ assert.equal(viewer.callsOf('scene.requestRender').length, 0);
+});
+
+test('request defaults to the render governor', () => {
+ const viewer = createFakeViewer();
+ installRenderGovernor(viewer);
+ const { render } = createCesiumRender(viewer);
+ render.request();
+ assert.equal(viewer.callsOf('scene.requestRender').length, 1);
+ assert.deepEqual(
+ getRenderGovernorDiagnostics().recentRequests.map(({ reason }) => reason),
+ [RENDER_OWNER],
+ );
+});
+
+test('creation leaves the scene and the governor alone', () => {
+ const viewer = createFakeViewer();
+ viewer.scene.maximumRenderTimeChange = 0.5;
+ createCesiumRender(viewer);
+ assert.equal(viewer.scene.requestRenderMode, true);
+ assert.equal(viewer.scene.maximumRenderTimeChange, 0.5);
+ assert.equal(getRenderGovernorDiagnostics().installed, false);
+ assert.deepEqual(getRenderGovernorDiagnostics().holds, []);
+});
+
+test('setContinuous holds and releases the governor under its own owner', () => {
+ const viewer = createFakeViewer();
+ installRenderGovernor(viewer);
+ const { render } = createCesiumRender(viewer);
+ assert.equal(render.isContinuous(), false);
+ render.setContinuous(true);
+ assert.deepEqual(getRenderGovernorDiagnostics().holds, [RENDER_OWNER]);
+ assert.equal(viewer.scene.requestRenderMode, false);
+ assert.equal(render.isContinuous(), true);
+ render.setContinuous(false);
+ assert.deepEqual(getRenderGovernorDiagnostics().holds, []);
+ assert.equal(render.isContinuous(), false);
+});
+
+test("isContinuous reports the scene's mode, including other owners' holds", () => {
+ const viewer = createFakeViewer();
+ installRenderGovernor(viewer);
+ const { render } = createCesiumRender(viewer);
+ holdContinuousRender('flights');
+ render.setContinuous(false);
+ assert.equal(render.isContinuous(), true);
+});
+
+test('destroy releases the engine hold', () => {
+ const viewer = createFakeViewer();
+ installRenderGovernor(viewer);
+ const { render, destroy } = createCesiumRender(viewer);
+ render.setContinuous(true);
+ destroy();
+ assert.deepEqual(getRenderGovernorDiagnostics().holds, []);
+});
diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js
new file mode 100644
index 000000000..b44added3
--- /dev/null
+++ b/src/globe/cesium/surface.js
@@ -0,0 +1,54 @@
+// GlobeSurface on a Cesium scene: the rendered-surface queries that
+// services/groundSnap and services/meshFloorSampler make today.
+
+import { firstVisibleTileset } from '../../services/groundSnap.js';
+import { toGeo3 } from './convert.js';
+
+const finite = (value) => (Number.isFinite(value) ? value : undefined);
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @returns {{ surface: import('../contract.js').GlobeSurface, destroy: () => void }}
+ */
+export function createCesiumSurface(viewer) {
+ const { scene } = viewer;
+ const surface = {
+ sampleHeight(cartographic, exclude) {
+ if (!scene.sampleHeightSupported) return undefined;
+ try {
+ // Throws when unsupported (no depth textures); that is a miss.
+ return finite(scene.sampleHeight(cartographic, exclude));
+ } catch {
+ return undefined;
+ }
+ },
+ get supported() {
+ return Boolean(scene.sampleHeightSupported);
+ },
+ terrainHeight(cartographic) {
+ return scene.globe
+ ? finite(scene.globe.getHeight(cartographic))
+ : undefined;
+ },
+ get terrainHeightSupported() {
+ return Boolean(scene.globe);
+ },
+ clampToHeight(position, exclude) {
+ if (!scene.clampToHeightSupported) return undefined;
+ try {
+ return toGeo3(scene.clampToHeight(position, exclude));
+ } catch {
+ return undefined;
+ }
+ },
+ get clampToHeightSupported() {
+ return Boolean(scene.clampToHeightSupported);
+ },
+ tilesLoaded() {
+ const tileset = firstVisibleTileset(scene.primitives);
+ if (tileset) return tileset.tilesLoaded === true;
+ return scene.globe?.show ? scene.globe.tilesLoaded === true : true;
+ },
+ };
+ return { surface, destroy() {} };
+}
diff --git a/src/globe/cesium/surface.test.mjs b/src/globe/cesium/surface.test.mjs
new file mode 100644
index 000000000..2ff89be20
--- /dev/null
+++ b/src/globe/cesium/surface.test.mjs
@@ -0,0 +1,86 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+import { createCesiumSurface } from './surface.js';
+import { createFakeViewer } from './testing/fakeViewer.mjs';
+
+function setup() {
+ const viewer = createFakeViewer();
+ const { surface } = createCesiumSurface(viewer);
+ return { viewer, surface, scene: viewer.scene };
+}
+
+test('sampleHeight forwards the point and exclusions to the scene', () => {
+ const { viewer, surface, scene } = setup();
+ const place = geo.cartographic.fromDegrees(20, 10, 0);
+ const exclude = [{ primitive: 1 }];
+ assert.equal(surface.sampleHeight(place, exclude), 42);
+ const [position, excluded] = viewer.callsOf('scene.sampleHeight')[0].args;
+ assert.equal(position.longitude, place.longitude);
+ assert.equal(position.latitude, place.latitude);
+ assert.equal(excluded, exclude);
+ scene.sampleHeight = () => undefined;
+ assert.equal(surface.sampleHeight(place), undefined);
+ scene.sampleHeight = () => NaN;
+ assert.equal(surface.sampleHeight(place), undefined);
+ scene.sampleHeight = () => {
+ throw new Error('sampleHeight unsupported');
+ };
+ assert.equal(surface.sampleHeight(place), undefined, 'a throw is a miss');
+});
+
+test('supported flags follow the scene', () => {
+ const { surface, scene } = setup();
+ assert.equal(surface.supported, true);
+ assert.equal(surface.clampToHeightSupported, true);
+ assert.equal(surface.terrainHeightSupported, true);
+ scene.sampleHeightSupported = false;
+ scene.clampToHeightSupported = false;
+ scene.globe = undefined;
+ assert.equal(surface.supported, false);
+ assert.equal(surface.clampToHeightSupported, false);
+ assert.equal(surface.terrainHeightSupported, false);
+ const place = geo.cartographic.fromDegrees(20, 10, 0);
+ assert.equal(surface.sampleHeight(place), undefined);
+ assert.equal(surface.terrainHeight(place), undefined);
+ assert.equal(
+ surface.clampToHeight(geo.cartesian3.fromDegrees(20, 10, 10)),
+ undefined,
+ );
+});
+
+test('terrainHeight asks the globe', () => {
+ const { viewer, surface, scene } = setup();
+ const place = geo.cartographic.fromDegrees(20, 10, 0);
+ assert.equal(surface.terrainHeight(place), 12.5);
+ assert.equal(viewer.callsOf('globe.getHeight')[0].args[0], place);
+ scene.globe.getHeight = () => undefined;
+ assert.equal(surface.terrainHeight(place), undefined);
+});
+
+test('clampToHeight returns a geo value', () => {
+ const { viewer, surface } = setup();
+ const exclude = [];
+ const clamped = surface.clampToHeight(
+ geo.cartesian3.fromDegrees(20, 10, 5000),
+ exclude,
+ );
+ assert.ok(!(clamped instanceof Cesium.Cartesian3));
+ assert.equal(clamped.x, 6378200);
+ assert.equal(viewer.callsOf('scene.clampToHeight')[0].args[1], exclude);
+});
+
+test('tilesLoaded follows the first visible tileset, else the shown globe', () => {
+ const { viewer, surface, scene } = setup();
+ assert.equal(surface.tilesLoaded(), true, 'globe loaded');
+ scene.globe.tilesLoaded = false;
+ assert.equal(surface.tilesLoaded(), false, 'globe streaming');
+ scene.globe.show = false;
+ assert.equal(surface.tilesLoaded(), true, 'nothing streams');
+ const tileset = { show: true, tilesLoaded: false };
+ viewer.primitiveList.push({ show: true }, tileset);
+ assert.equal(surface.tilesLoaded(), false);
+ tileset.tilesLoaded = true;
+ assert.equal(surface.tilesLoaded(), true);
+});
diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs
new file mode 100644
index 000000000..18fbd5048
--- /dev/null
+++ b/src/globe/cesium/testing/fakeViewer.mjs
@@ -0,0 +1,208 @@
+// A recording stand-in for a Cesium Viewer, for the Cesium engine's unit tests.
+// It carries real Cesium value types and events (no WebGL) and records every
+// call into `viewer.calls` so tests can check what reached which Cesium member.
+
+import * as Cesium from 'cesium';
+
+/** A fake viewer whose members record their calls. */
+export function createFakeViewer({ width = 1024, height = 768 } = {}) {
+ const calls = [];
+ const record =
+ (name, value) =>
+ (...args) => {
+ calls.push({ name, args });
+ return typeof value === 'function' ? value(...args) : value;
+ };
+ const primitiveList = [];
+ const imageryList = [];
+ const staticCredits = [];
+ const creditDisplay = {
+ addStaticCredit: record('creditDisplay.addStaticCredit', (credit) =>
+ staticCredits.push(credit),
+ ),
+ removeStaticCredit: record('creditDisplay.removeStaticCredit', (credit) => {
+ const index = staticCredits.indexOf(credit);
+ if (index >= 0) staticCredits.splice(index, 1);
+ }),
+ };
+ const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width));
+ const camera = {
+ positionWC: new Cesium.Cartesian3(7e6, 1e5, 2e5),
+ directionWC: new Cesium.Cartesian3(-1, 0, 0),
+ upWC: new Cesium.Cartesian3(0, 0, 1),
+ rightWC: new Cesium.Cartesian3(0, 1, 0),
+ heading: Cesium.Math.TWO_PI,
+ pitch: -Cesium.Math.PI_OVER_TWO,
+ roll: Cesium.Math.TWO_PI,
+ frustum: {
+ fovy,
+ aspectRatio: width / height,
+ projectionMatrix: Cesium.Matrix4.fromArray([
+ 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, -1, -1, 0, 0, -2, 0,
+ ]),
+ },
+ viewMatrix: Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(1, 2, 3)),
+ transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
+ defaultZoomAmount: 100000,
+ percentageChanged: 0.5,
+ changed: new Cesium.Event(),
+ moveStart: new Cesium.Event(),
+ moveEnd: new Cesium.Event(),
+ flights: [],
+ setView: record('camera.setView'),
+ flyTo: record('camera.flyTo', (options) => {
+ // Cesium cancels a running flight before starting the next one.
+ const previous = camera.flights.at(-1);
+ if (previous && !previous.done) {
+ previous.done = true;
+ previous.options.cancel?.();
+ }
+ const flight = { options, done: false };
+ camera.flights.push(flight);
+ if (options.duration !== undefined && options.duration <= 0) {
+ flight.done = true;
+ options.complete?.();
+ }
+ }),
+ flyToBoundingSphere: record(
+ 'camera.flyToBoundingSphere',
+ (sphere, options) => camera.flyTo({ ...options, sphere }),
+ ),
+ completeFlight() {
+ const flight = camera.flights.at(-1);
+ if (!flight || flight.done) return;
+ flight.done = true;
+ flight.options.complete?.();
+ },
+ cancelFlight: record('camera.cancelFlight', () => {
+ const flight = camera.flights.at(-1);
+ if (!flight || flight.done) return;
+ flight.done = true;
+ flight.options.cancel?.();
+ }),
+ lookAt: record('camera.lookAt'),
+ lookAtTransform: record('camera.lookAtTransform', (transform) =>
+ Cesium.Matrix4.clone(transform, camera.transform),
+ ),
+ zoomIn: record('camera.zoomIn'),
+ zoomOut: record('camera.zoomOut'),
+ computeViewRectangle: record(
+ 'camera.computeViewRectangle',
+ () => new Cesium.Rectangle(0.1, 0.2, 0.3, 0.4),
+ ),
+ pickEllipsoid: record(
+ 'camera.pickEllipsoid',
+ () => new Cesium.Cartesian3(6378137, 0, 0),
+ ),
+ getPickRay: record(
+ 'camera.getPickRay',
+ () =>
+ new Cesium.Ray(
+ new Cesium.Cartesian3(7e6, 0, 0),
+ new Cesium.Cartesian3(-1, 0, 0),
+ ),
+ ),
+ };
+ const globe = {
+ show: true,
+ tilesLoaded: true,
+ getHeight: record('globe.getHeight', () => 12.5),
+ };
+ const scene = {
+ camera,
+ canvas: { clientWidth: width, clientHeight: height },
+ globe,
+ preUpdate: new Cesium.Event(),
+ preRender: new Cesium.Event(),
+ postRender: new Cesium.Event(),
+ requestRenderMode: true,
+ maximumRenderTimeChange: Infinity,
+ requestRender: record('scene.requestRender'),
+ pick: record('scene.pick', () => undefined),
+ drillPick: record('scene.drillPick', () => []),
+ pickPositionSupported: true,
+ pickPosition: record(
+ 'scene.pickPosition',
+ () => new Cesium.Cartesian3(6378137, 1, 2),
+ ),
+ sampleHeightSupported: true,
+ sampleHeight: record('scene.sampleHeight', () => 42),
+ clampToHeightSupported: true,
+ clampToHeight: record(
+ 'scene.clampToHeight',
+ () => new Cesium.Cartesian3(6378200, 0, 0),
+ ),
+ screenSpaceCameraController: { enableInputs: true },
+ frameState: { creditDisplay },
+ primitives: {
+ get length() {
+ return primitiveList.length;
+ },
+ get: (index) => primitiveList[index],
+ add: record('primitives.add', (value) => {
+ primitiveList.push(value);
+ return value;
+ }),
+ remove: record('primitives.remove', (value) => {
+ const index = primitiveList.indexOf(value);
+ if (index < 0) return false;
+ primitiveList.splice(index, 1);
+ return true;
+ }),
+ contains: (value) => primitiveList.includes(value),
+ },
+ setTerrain: record('scene.setTerrain'),
+ };
+ const viewer = {
+ calls,
+ callsOf: (name) => calls.filter((call) => call.name === name),
+ scene,
+ camera,
+ container: { kind: 'container' },
+ canvas: scene.canvas,
+ creditDisplay,
+ staticCredits,
+ primitiveList,
+ imageryList,
+ terrainProvider: undefined,
+ imageryLayers: {
+ add: record('imageryLayers.add', (layer, index) =>
+ imageryList.splice(index ?? imageryList.length, 0, layer),
+ ),
+ remove: record('imageryLayers.remove', (layer) => {
+ const index = imageryList.indexOf(layer);
+ if (index >= 0) imageryList.splice(index, 1);
+ return index >= 0;
+ }),
+ },
+ };
+ return viewer;
+}
+
+/** A fake ScreenSpaceEventHandler factory that records its actions. */
+export function createFakeEventHandlers() {
+ const handlers = [];
+ const create = (canvas) => {
+ const actions = new Map();
+ const handler = {
+ canvas,
+ destroyed: false,
+ actions,
+ setInputAction(action, type, modifier) {
+ actions.set(`${type}:${modifier}`, action);
+ },
+ removeInputAction(type, modifier) {
+ actions.delete(`${type}:${modifier}`);
+ },
+ fire(type, event, modifier) {
+ actions.get(`${type}:${modifier}`)?.(event);
+ },
+ destroy() {
+ handler.destroyed = true;
+ },
+ };
+ handlers.push(handler);
+ return handler;
+ };
+ return { handlers, create };
+}
diff --git a/src/globe/contract.js b/src/globe/contract.js
index 9537b9bfc..e911b3c43 100644
--- a/src/globe/contract.js
+++ b/src/globe/contract.js
@@ -95,8 +95,10 @@
* @property {(screen: Cartesian2) => (Cartesian3 | undefined)} pickEllipsoid
* @property {(screen: Cartesian2) => { origin: Cartesian3, direction: Cartesian3 }} pickRay
* @property {EngineEvent} changed Listener receives the change fraction.
- * @property {EngineEvent} moveStart
- * @property {EngineEvent} moveEnd
+ * @property {EngineEvent} moveStart Fires when the camera starts moving from rest.
+ * @property {EngineEvent} moveEnd Fires once the camera has been still for a
+ * while (Cesium: scene.cameraEventWaitTime, 500 ms), so a move that follows
+ * within that time continues the same movement and raises no new moveStart.
* @property {number} percentageChanged
*
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs
index 7beb28df6..a77a095b0 100644
--- a/src/globe/testing/contractSuite.mjs
+++ b/src/globe/testing/contractSuite.mjs
@@ -233,7 +233,16 @@ export function createContractSuite(
[
'camera events: changed, moveStart and moveEnd follow a move',
async () => {
- await nadirView();
+ // moveStart marks a move from rest: let the nadir move end first
+ // (Cesium raises moveEnd only after cameraEventWaitTime, 500 ms).
+ let rested = 0;
+ const removeRest = camera.moveEnd.addEventListener(() => rested++);
+ try {
+ await nadirView();
+ await until(() => rested > 0, 'moveEnd after the nadir view');
+ } finally {
+ removeRest();
+ }
const previous = camera.percentageChanged;
const seen = { changed: 0, moveStart: 0, moveEnd: 0 };
const removers = Object.keys(seen).map((name) =>
diff --git a/src/maps/controller.js b/src/maps/controller.js
index e595ede26..1dad01ccf 100644
--- a/src/maps/controller.js
+++ b/src/maps/controller.js
@@ -1,6 +1,7 @@
import { indexMapSources } from './registry.js';
import * as Cesium from 'cesium';
import { createMapCredits } from './credits.js';
+import { applyTerrain } from './terrain.js';
import { acquireImageryComparison } from './imageryComparison.js';
/**
@@ -290,8 +291,7 @@ export class MapSourceController {
() => terrain.create({ signal: this._abort.signal }),
);
if (gen !== this._switchGen) return;
- if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
- else this.viewer.terrainProvider = result.provider;
+ applyTerrain(this.viewer, result);
this._terrainMode = terrain.id;
}
return resolution;
diff --git a/src/maps/defaultSources.js b/src/maps/defaultSources.js
index 99e767418..4062e7a80 100644
--- a/src/maps/defaultSources.js
+++ b/src/maps/defaultSources.js
@@ -1,13 +1,8 @@
import { MAP_STACKS } from './catalog.js';
import { photorealUnavailableReason } from './availability.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
-import {
- createOsmImagery,
- createEsriImagery,
- createIonImagery,
- ESRI_ATTRIBUTION_HTML,
-} from './imagery.js';
-import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
+import { imageryFactoryFor, ESRI_ATTRIBUTION_HTML } from './imagery.js';
+import { terrainSourceFor } from './terrain.js';
/** Select sources and setup guidance without putting provider branches in the controller. */
export function createDefaultMapSources({
@@ -18,12 +13,7 @@ export function createDefaultMapSources({
const ionToken = String(cesiumToken || '').trim();
const hasIon = Boolean(ionToken);
const hasGoogle = Boolean(String(googleApiKey || '').trim());
- const terrain = {
- id: hasIon ? 'world' : 'keyless',
- create: hasIon
- ? (request) => createWorldTerrain(ionToken, request)
- : createKeylessTerrain,
- };
+ const terrain = terrainSourceFor(ionToken);
return {
defaultId: googleTileset ? 'photoreal' : 'esri-imagery',
unknownId: 'photoreal',
@@ -44,12 +34,7 @@ export function createDefaultMapSources({
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
tileset: googleTileset,
};
- const imagery =
- descriptor.kind === 'ion'
- ? () => createIonImagery(descriptor.style, ionToken)
- : descriptor.id === 'osm'
- ? createOsmImagery
- : createEsriImagery;
+ const imagery = imageryFactoryFor(descriptor, ionToken);
return {
...common,
imagery,
diff --git a/src/maps/imagery.js b/src/maps/imagery.js
index 32fd3f375..82fe9342f 100644
--- a/src/maps/imagery.js
+++ b/src/maps/imagery.js
@@ -27,3 +27,16 @@ export function createIonImagery(style, accessToken) {
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
}
+
+/**
+ * The imagery factory a globe stack descriptor names: ion styles, OSM, or
+ * Esri. Shared by the default map sources and the Cesium globe engine.
+ * @param {{ kind: string, style?: number }} descriptor
+ * @param {string} [ionToken]
+ * @returns {(request?: { signal?: AbortSignal }) => object | Promise