feat(globe): implement the engine on Cesium

createCesiumEngine wraps an existing viewer without changing it: camera, picker, projector, frames, render (through the render governor), surface, input, credits and maps, with geo values out and Cesium values in at the boundary. Map and surface code is shared with src/maps and services/groundSnap instead of copied. scripts/qa-globe-contract.mjs runs the contract suite against the engine in headless Chrome; the camera-events check now waits for the previous move to end, as Cesium's moveStart marks a move from rest.
This commit is contained in:
German Khokhlov committed 2026-10-15 17:43:05 +04:00
1 parent cd94a26b9f
commit d831cbd23a
33 files changed
+2374 -32

No files matched your search

+13
View File
@@ -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",
+208
View File
@@ -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();
},
};
}
+307
View File
@@ -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);
});
+23
View File
@@ -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);
+66
View File
@@ -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();
},
};
}
@@ -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, []);
});
+29
View File
@@ -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);
},
};
}
+33
View File
@@ -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('<b>Source</b>', { onScreen: true });
assert.equal(viewer.staticCredits.length, 1);
const [credit] = viewer.staticCredits;
assert.ok(credit instanceof Cesium.Credit);
assert.equal(credit.html, '<b>Source</b>');
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);
});
+49
View File
@@ -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();
},
};
}
+63
View File
@@ -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();
});
+29
View File
@@ -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();
},
};
}
+31
View File
@@ -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);
});
+86
View File
@@ -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;
},
};
}
+113
View File
@@ -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);
});
+176
View File
@@ -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;
}
},
};
}
+211
View File
@@ -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, []);
});
+39
View File
@@ -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() {} };
}
+80
View File
@@ -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);
});
+52
View File
@@ -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() {} };
}
+88
View File
@@ -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',
);
});
+41
View File
@@ -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);
},
};
}
+78
View File
@@ -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, []);
});
+54
View File
@@ -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() {} };
}
+86
View File
@@ -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);
});
+208
View File
@@ -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 };
}
+4 -2
View File
@@ -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
+10 -1
View File
@@ -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) =>
+2 -2
View File
@@ -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;
+4 -19
View File
@@ -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,
+13
View File
@@ -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<object>}
*/
export function imageryFactoryFor(descriptor, ionToken = '') {
if (descriptor.kind === 'ion')
return () => createIonImagery(descriptor.style, ionToken);
return descriptor.kind === 'osm' ? createOsmImagery : createEsriImagery;
}
+57 -2
View File
@@ -1,8 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createIonImagery } from './imagery.js';
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
import {
createEsriImagery,
createIonImagery,
createOsmImagery,
imageryFactoryFor,
} from './imagery.js';
import {
applyTerrain,
createWorldTerrain,
createKeylessTerrain,
terrainSourceFor,
} from './terrain.js';
import { createDefaultMapSources } from './defaultSources.js';
test('Esri uses Re:Earth without keys and preserves ion terrain when configured', async () => {
@@ -161,3 +171,48 @@ test('both Google 3D routes keep tiles drawing while draped imagery loads', asyn
assert.equal(calls[1][1].asynchronouslyLoadImagery, true);
assert.equal(calls[1][1].enableCollision, true);
});
test('imageryFactoryFor picks the factory a stack descriptor names', async () => {
assert.equal(imageryFactoryFor({ id: 'osm', kind: 'osm' }), createOsmImagery);
assert.equal(
imageryFactoryFor({ id: 'esri-imagery', kind: 'esri-imagery' }),
createEsriImagery,
);
const original = Cesium.IonImageryProvider.fromAssetId;
try {
Cesium.IonImageryProvider.fromAssetId = async (style, options) => ({
style,
token: options.accessToken,
});
const ion = imageryFactoryFor(
{ id: 'bing-aerial', kind: 'ion', style: 2 },
'ion-token',
);
assert.deepEqual(await ion(), { style: 2, token: 'ion-token' });
} finally {
Cesium.IonImageryProvider.fromAssetId = original;
}
});
test('terrainSourceFor chooses world terrain only with an ion token', () => {
assert.deepEqual(terrainSourceFor(''), {
id: 'keyless',
create: createKeylessTerrain,
});
const world = terrainSourceFor(' token ');
assert.equal(world.id, 'world');
assert.equal(typeof world.create, 'function');
});
test('applyTerrain installs a terrain object or a provider', () => {
const calls = [];
const viewer = {
terrainProvider: null,
scene: { setTerrain: (terrain) => calls.push(terrain) },
};
applyTerrain(viewer, { provider: 'provider' });
assert.equal(viewer.terrainProvider, 'provider');
applyTerrain(viewer, { terrain: 'terrain' });
assert.deepEqual(calls, ['terrain']);
assert.equal(viewer.terrainProvider, 'provider');
});
+19
View File
@@ -62,3 +62,22 @@ export async function createKeylessTerrain() {
return { provider: new Cesium.EllipsoidTerrainProvider() };
}
}
/**
* The terrain source for a session: Cesium World Terrain with an ion token,
* otherwise the keyless Re:Earth mesh.
* @param {string} [ionToken]
* @returns {{ id: 'world' | 'keyless', create: Function }}
*/
export function terrainSourceFor(ionToken = '') {
const token = String(ionToken || '').trim();
return token
? { id: 'world', create: (request) => createWorldTerrain(token, request) }
: { id: 'keyless', create: createKeylessTerrain };
}
/** Install a terrain factory result (`{ terrain }` or `{ provider }`) on a viewer. */
export function applyTerrain(viewer, result) {
if (result.terrain) viewer.scene.setTerrain(result.terrain);
else viewer.terrainProvider = result.provider;
}
+15 -6
View File
@@ -144,18 +144,27 @@ const SAMPLE_WINDOW_MS = 250;
function _tilesReady(viewer) {
const prims = viewer?.scene?.primitives;
if (!prims) return false;
for (let i = 0; i < prims.length; i++) {
const tileset = firstVisibleTileset(prims);
return tileset ? tileset.tilesLoaded === true : true;
}
/**
* The first shown primitive that reports a boolean `tilesLoaded` (a 3D
* tileset, duck-typed), or null.
* @param {{ length: number, get: (index: number) => any }} primitives
* @returns {object | null}
*/
export function firstVisibleTileset(primitives) {
for (let i = 0; i < primitives.length; i++) {
let p = null;
try {
p = prims.get(i);
p = primitives.get(i);
} catch {
continue;
}
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') {
return p.tilesLoaded === true;
}
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') return p;
}
return true;
return null;
}
/**