mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 20:34:02 +00:00
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:
1 parent
cd94a26b9f
commit
d831cbd23a
33 files changed
+2374
-32
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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() {} };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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() {} };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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() {} };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user