feat(globe): camera.commanded, picker.pickGlobe, pose.aspectRatio and isDestroyed

Shell navigation needs to know when a new owner takes the camera (the
arrival ground guard), where the globe surface is under a window position
(orientation pivot, view-centred place search), the frustum aspect ratio
(landmark framing) and whether the engine is gone. Each member has a
contract suite check and is implemented by the Cesium engine, the Navara
engine and the fake. On Cesium, commanded hooks the viewer camera's
command methods and trackedEntityChanged only while it has listeners, so
direct Cesium calls from layers still announce a new owner.
This commit is contained in:
German Khokhlov committed 2026-10-15 19:19:41 +04:00
1 parent 840e34a881
commit a3e929870d
16 files changed
+459 -6

No files matched your search

+84
View File
@@ -3,6 +3,7 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js';
import { bridgeEvent } from './events.js';
import { toCesium2, toCesium3, toGeo3 } from './convert.js';
@@ -49,6 +50,86 @@ const cesiumOffset = (offset) =>
? new Cesium.HeadingPitchRange(offset.heading, offset.pitch, offset.range)
: toCesium3(offset);
// The Cesium camera entry points that hand the camera to a new owner. The
// viewer's camera is shared with code that still calls Cesium directly
// (layers, cockpit), so `commanded` hooks these methods themselves.
const COMMANDS = Object.freeze([
'flyTo',
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
]);
/**
* camera.commanded: raised before any COMMANDS call on the Cesium camera and
* when the viewer's tracked entity changes. The hooks exist only while the
* event has listeners and then restore each method exactly (unless something
* newer replaced it), so an idle engine leaves the camera untouched.
*/
function commandedEvent(viewer, cesium) {
const event = createEngineEvent();
let unhook = null;
const hook = () => {
const removers = [];
for (const name of COMMANDS) {
const original = cesium[name];
if (typeof original !== 'function') continue;
const descriptor = Object.getOwnPropertyDescriptor(cesium, name);
const wrapped = function (...args) {
event.raiseEvent();
return original.apply(this, args);
};
cesium[name] = wrapped;
removers.push(() => {
if (cesium[name] !== wrapped) return;
if (descriptor) Object.defineProperty(cesium, name, descriptor);
else delete cesium[name];
});
}
const removeTracking = viewer.trackedEntityChanged?.addEventListener(() =>
event.raiseEvent(),
);
if (removeTracking) removers.push(removeTracking);
return () => {
for (const remove of removers) remove();
};
};
let detached = false;
const sync = () => {
const wanted = !detached && event.numberOfListeners > 0;
if (wanted === (unhook !== null)) return;
if (wanted) unhook = hook();
else {
const remove = unhook;
unhook = null;
remove();
}
};
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();
},
};
}
/**
* @param {Cesium.Viewer} viewer
* @returns {{ camera: import('../contract.js').GlobeCamera, destroy: () => void }}
@@ -60,6 +141,7 @@ export function createCesiumCamera(viewer) {
changed: bridgeEvent(cesium.changed),
moveStart: bridgeEvent(cesium.moveStart),
moveEnd: bridgeEvent(cesium.moveEnd),
commanded: commandedEvent(viewer, cesium),
};
// The engine's own flight, so flightActive is exact even for flights that
@@ -109,6 +191,7 @@ export function createCesiumCamera(viewer) {
up: toGeo3(cesium.upWC),
right: toGeo3(cesium.rightWC),
fovy: cesium.frustum.fovy,
aspectRatio: cesium.frustum.aspectRatio,
};
},
// Cesium updates positionWC in place; it is the live object.
@@ -203,6 +286,7 @@ export function createCesiumCamera(viewer) {
changed: bridges.changed.event,
moveStart: bridges.moveStart.event,
moveEnd: bridges.moveEnd.event,
commanded: bridges.commanded.event,
get percentageChanged() {
return cesium.percentageChanged;
},
+66
View File
@@ -360,3 +360,69 @@ test('the camera is the viewer camera (Viewer.camera is scene.camera)', () => {
const { camera } = createCesiumCamera({ ...viewer, camera: own });
assert.equal(camera.livePosition, own.positionWC);
});
test('pose carries the frustum aspect ratio', () => {
const { camera, cesium } = setup();
assert.equal(camera.pose().aspectRatio, cesium.frustum.aspectRatio);
});
test('commanded hooks the Cesium camera and tracking only while it has listeners', () => {
const { viewer, camera, cesium, destroy } = setup();
const names = [
'flyTo',
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
];
const originals = Object.fromEntries(
names.map((name) => [name, cesium[name]]),
);
const own = Object.fromEntries(
names.map((name) => [name, Object.getOwnPropertyDescriptor(cesium, name)]),
);
let raised = 0;
const listener = () => raised++;
const remove = camera.commanded.addEventListener(listener);
for (const name of names)
assert.notEqual(cesium[name], originals[name], name);
// Direct calls on the viewer camera (layers, cockpit) announce themselves.
cesium.setView({});
assert.equal(raised, 1);
cesium.cancelFlight();
assert.equal(raised, 2);
// So do engine commands, once per Cesium entry point they reach.
camera.lookAt(geo.cartesian3.fromDegrees(0, 0, 0), {
heading: 0,
pitch: -1,
range: 10,
});
assert.equal(raised, 3);
assert.equal(viewer.callsOf('camera.lookAt').length, 1);
// A new tracked entity takes the camera too.
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
assert.equal(raised, 4);
camera.release();
assert.equal(raised, 4, 'release is not a new owner');
remove();
for (const name of names) {
assert.equal(cesium[name], originals[name], `${name} restored`);
assert.deepEqual(Object.getOwnPropertyDescriptor(cesium, name), own[name]);
}
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
cesium.setView({});
assert.equal(raised, 4);
camera.commanded.addEventListener(listener);
destroy();
assert.equal(cesium.setView, originals.setView, 'destroy unhooks');
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
});
test('commanded leaves a newer hook on the camera in place', () => {
const { camera, cesium } = setup();
const remove = camera.commanded.addEventListener(() => {});
const newer = () => {};
cesium.flyTo = newer;
remove();
assert.equal(cesium.flyTo, newer);
});
+1
View File
@@ -69,5 +69,6 @@ export function createCesiumEngine(viewer, options = {}) {
destroyed = true;
for (const part of Object.values(parts)) part.destroy();
},
isDestroyed: () => destroyed,
};
}
@@ -77,8 +77,10 @@ test('destroy releases holds, listeners, handlers and engine-owned map state', a
engine.render.setContinuous(true);
engine.credits.addStatic('credit');
await engine.maps.setBaseImagery({ kind: 'osm' });
assert.equal(engine.isDestroyed(), false);
engine.destroy();
engine.destroy();
assert.equal(engine.isDestroyed(), true);
assert.equal(viewer.scene.camera.changed.numberOfListeners, 0);
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
assert.equal(handlers.handlers[0].destroyed, true);
+10
View File
@@ -41,6 +41,16 @@ export function createCesiumPicker(viewer) {
get pickPositionSupported() {
return Boolean(scene.pickPositionSupported);
},
pickGlobe(screen) {
if (!scene.globe) return undefined;
try {
const camera = viewer.camera ?? scene.camera;
const ray = camera.getPickRay(toCesium2(screen));
return ray ? toGeo3(scene.globe.pick(ray, scene)) : undefined;
} catch {
return undefined;
}
},
};
return { picker, destroy() {} };
}
+21
View File
@@ -92,3 +92,24 @@ test('pick results carry 3D tile content for surface checks', () => {
scene.pick = () => ({ id: 'entity', primitive: {} });
assert.equal(picker.pick(geo.cartesian2.create(1, 1)).content, undefined);
});
test('pickGlobe asks the globe along the camera pick ray and returns geo', () => {
const { viewer, picker, scene } = setup();
const hit = picker.pickGlobe(geo.cartesian2.create(3, 4));
assert.ok(!(hit instanceof Cesium.Cartesian3));
assert.deepEqual([hit.x, hit.y, hit.z], [6378137, 3, 4]);
const [screen] = viewer.callsOf('camera.getPickRay')[0].args;
assert.ok(screen instanceof Cesium.Cartesian2);
assert.equal(screen.toString(), '(3, 4)');
const [ray, pickedScene] = viewer.callsOf('globe.pick')[0].args;
assert.ok(ray instanceof Cesium.Ray);
assert.equal(pickedScene, scene);
scene.globe.pick = () => undefined;
assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined);
scene.globe.pick = () => {
throw new Error('no ray');
};
assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined);
scene.globe = undefined;
assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined);
});
+2
View File
@@ -107,6 +107,7 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
show: true,
tilesLoaded: true,
getHeight: record('globe.getHeight', () => 12.5),
pick: record('globe.pick', () => new Cesium.Cartesian3(6378137, 3, 4)),
};
const scene = {
camera,
@@ -163,6 +164,7 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
creditDisplay,
staticCredits,
primitiveList,
trackedEntityChanged: new Cesium.Event(),
imageryList,
terrainProvider: undefined,
imageryLayers: {
+16 -1
View File
@@ -40,6 +40,8 @@
* @property {Cartesian3} up
* @property {Cartesian3} right direction × up
* @property {number} fovy Vertical field of view.
* @property {number} aspectRatio The view's width over its height (Cesium:
* the frustum's, from the drawing buffer).
*
* @typedef {{ heading: number, pitch: number, roll: number }
* | { direction: Cartesian3, up: Cartesian3 }} Orientation
@@ -117,6 +119,11 @@
* @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 {EngineEvent} commanded Fires (no arguments) when a command, not a
* user gesture, is about to take the camera: setView, flyTo, flyToSphere,
* lookAt and cancelFlight. Cesium also raises it for those calls made on the
* viewer's own camera and when the viewer's tracked entity changes, so a
* pending correction yields to every new camera owner.
* @property {number} percentageChanged
*
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
@@ -196,7 +203,10 @@
* @property {'cesium'|'navara'} kind
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
* @property {GlobeCamera} camera
* @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => (PickResult | undefined), drillPick: (screen: Cartesian2, limit?: number, width?: number, height?: number) => PickResult[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean }} picker
* @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => (PickResult | undefined), drillPick: (screen: Cartesian2, limit?: number, width?: number, height?: number) => PickResult[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean, pickGlobe: (screen: Cartesian2) => (Cartesian3 | undefined) }} picker
* pickGlobe: where the ray through a window position meets the globe
* surface (terrain, not tiles or models; Cesium's globe.pick of the camera's
* pick ray); an engine without a terrain surface answers the ellipsoid.
* @property {GlobeProjector} projector
* @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms.
* @property {GlobeRender} render
@@ -207,6 +217,7 @@
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
* @property {GlobeMaps} maps
* @property {() => void} destroy
* @property {() => boolean} isDestroyed True once destroy() has run.
*/
export const ENGINE_KINDS = Object.freeze(['cesium', 'navara']);
@@ -345,6 +356,7 @@ const SHAPE = {
changed: isEngineEvent,
moveStart: isEngineEvent,
moveEnd: isEngineEvent,
commanded: isEngineEvent,
percentageChanged: isNumber,
},
picker: {
@@ -352,6 +364,7 @@ const SHAPE = {
drillPick: isFunction,
pickPosition: isFunction,
pickPositionSupported: isBoolean,
pickGlobe: isFunction,
},
projector: {
worldToWindow: isFunction,
@@ -396,6 +409,7 @@ export const GLOBE_ENGINE_MEMBERS = Object.freeze([
Object.keys(members).map((member) => `${capability}.${member}`),
),
'destroy',
'isDestroyed',
]);
/**
@@ -425,6 +439,7 @@ export function assertGlobeEngine(engine) {
if (!valid(value[member])) problems.push(`${capability}.${member}`);
}
if (!isFunction(engine.destroy)) problems.push('destroy');
if (!isFunction(engine.isDestroyed)) problems.push('isDestroyed');
if (problems.length)
throw new TypeError(
`GlobeEngine is missing or mistypes: ${problems.join(', ')}`,
+16
View File
@@ -206,6 +206,22 @@ test('the contract carries what the shell infrastructure needs', () => {
);
});
test('the contract carries what shell navigation needs', () => {
for (const member of ['camera.commanded', 'picker.pickGlobe', 'isDestroyed'])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
const broken = {
...engine,
camera: { ...engine.camera, commanded: {} },
picker: { ...engine.picker, pickGlobe: undefined },
isDestroyed: false,
};
assert.throws(
() => assertGlobeEngine(broken),
/camera\.commanded.*picker\.pickGlobe.*isDestroyed/,
);
});
test('the fake engine passes the engine contract suite', async (t) => {
const engine = createFakeEngine();
const checks = createContractSuite(engine, {
+10
View File
@@ -97,7 +97,10 @@ export function createCameraModel({
changed: createEngineEvent(),
moveStart: createEngineEvent(),
moveEnd: createEngineEvent(),
commanded: createEngineEvent(),
};
// A camera command takes the camera from whoever held it; say so first.
const announce = () => events.commanded.raiseEvent();
const initial = port.read();
let state = initial
? copyPose(initial)
@@ -295,19 +298,23 @@ export function createCameraModel({
cartesian3.create(),
),
fovy: current.fovy,
aspectRatio: current.width / current.height,
};
},
setView(options) {
if (!options?.destination)
throw new TypeError('setView needs a destination');
announce();
place(viewFor(options.destination, options.orientation));
},
flyTo(options) {
if (!options?.destination)
throw new TypeError('flyTo needs a destination');
announce();
startFlight(options, viewFor(options.destination, options.orientation));
},
flyToSphere(sphere, options = {}) {
announce();
transform = [...matrix4.IDENTITY];
const { width, height } = port.size();
const range =
@@ -328,6 +335,7 @@ export function createCameraModel({
},
flightActive: () => flight != null,
lookAt(target, offset) {
announce();
camera.lookAtTransform(
geo.transforms.eastNorthUpToFixedFrame(target, WGS84),
offset,
@@ -342,6 +350,7 @@ export function createCameraModel({
transform = [...matrix4.IDENTITY];
},
cancelFlight() {
announce();
cancelCurrentFlight();
},
zoomIn(amount = DEFAULT_ZOOM_AMOUNT) {
@@ -367,6 +376,7 @@ export function createCameraModel({
changed: events.changed,
moveStart: events.moveStart,
moveEnd: events.moveEnd,
commanded: events.commanded,
get percentageChanged() {
return percentageChanged;
},
+30
View File
@@ -457,3 +457,33 @@ test('livePosition is one engine-owned object holding the current position; heig
const pose = camera.pose();
assert.notEqual(pose.position, live, 'pose shares the live object');
});
test('commanded fires before each camera command and never for reads', () => {
const { camera } = setup();
let raised = 0;
const remove = camera.commanded.addEventListener(() => raised++);
assert.ok(isEngineEvent(camera.commanded));
camera.pose();
camera.height();
camera.viewRectangle();
assert.equal(raised, 0);
const target = cartesian3.fromDegrees(10, 10, 0);
camera.setView({ destination: cartesian3.fromDegrees(10, 10, 1e6) });
camera.flyTo({ destination: cartesian3.fromDegrees(11, 10, 1e6), duration: 0 });
camera.flyToSphere({ center: target, radius: 10 }, { duration: 0 });
camera.lookAt(target, { heading: 0, pitch: -1, range: 500 });
camera.cancelFlight();
assert.equal(raised, 5);
camera.release();
camera.zoomIn(10);
assert.equal(raised, 5, 'release and zoom are not new owners');
remove();
camera.setView({ destination: cartesian3.fromDegrees(10, 10, 1e6) });
assert.equal(raised, 5);
});
test('pose carries the view aspect ratio', () => {
const { camera, port } = setup();
const { width, height } = port.size();
assert.equal(camera.pose().aspectRatio, width / height);
});
+46
View File
@@ -31,6 +31,31 @@ function readCamera(raw) {
};
}
// Nearest hit of a ray with the WGS84 ellipsoid raised by `height` metres
// (each radius grown by it), or undefined.
function rayRaisedEllipsoid({ origin, direction }, height) {
const radii = geo.ellipsoid.WGS84.radii;
const rx = radii.x + height;
const ry = radii.y + height;
const rz = radii.z + height;
const o = [origin.x / rx, origin.y / ry, origin.z / rz];
const d = [direction.x / rx, direction.y / ry, direction.z / rz];
const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]);
const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1;
const discriminant = b * b - 4 * a * c;
if (discriminant < 0) return undefined;
const root = Math.sqrt(discriminant);
const near = (-b - root) / (2 * a);
const t = near > 0 ? near : (-b + root) / (2 * a);
if (!(t > 0)) return undefined;
return cartesian3.add(
origin,
cartesian3.multiplyByScalar(direction, t, cartesian3.create()),
cartesian3.create(),
);
}
/**
* @param {object} view An initialised Navara ThreeView.
* @param {{
@@ -115,6 +140,26 @@ export function createNavaraEngine(
return hit ? cartesian3.create(hit.x, hit.y, hit.z) : undefined;
},
pickPositionSupported: true,
// The globe under a window position: the pick ray's ellipsoid hit, moved
// onto Navara's terrain (a few fixed-point steps; terrain varies slowly
// over the distance the hit moves).
pickGlobe(screen) {
let hit = model.camera.pickEllipsoid(screen);
if (!hit) return undefined;
const ray = model.camera.pickRay(screen);
for (let step = 0; step < 4; step++) {
const height = surface.terrainHeight(
geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84),
);
if (!Number.isFinite(height)) break;
const next = rayRaisedEllipsoid(ray, height);
if (!next) break;
const moved = cartesian3.distance(next, hit);
hit = next;
if (moved < 0.01) break;
}
return hit;
},
};
let destroyed = false;
@@ -151,5 +196,6 @@ export function createNavaraEngine(
credits.destroy();
maps.destroy();
},
isDestroyed: () => destroyed,
};
}
@@ -205,3 +205,24 @@ function near(actual, expected, tolerance, label) {
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
);
}
test('isDestroyed follows destroy', () => {
const { engine } = setup();
assert.equal(engine.isDestroyed(), false);
engine.destroy();
assert.equal(engine.isDestroyed(), true);
});
test('pickGlobe meets the terrain along the pick ray, or the ellipsoid', () => {
const { engine, view } = setup();
const centre = cartesian2.create(640, 400);
const bare = engine.picker.pickGlobe(centre);
const ellipsoid = engine.camera.pickEllipsoid(centre);
near(cartesian3.distance(bare, ellipsoid), 0, 1e-6, 'no terrain: ellipsoid');
view.sampleTerrainHeight = () => 2000;
const raised = engine.picker.pickGlobe(centre);
const place = geo.cartographic.fromCartesian(raised);
near(place.height, 2000, 0.5, 'terrain height (m)');
near(math.toDegrees(place.longitude), 0, 1e-6, 'longitude');
assert.equal(engine.picker.pickGlobe(cartesian2.create(640, -1e7)), undefined);
});
+94
View File
@@ -1380,6 +1380,100 @@ export function createContractSuite(
);
},
],
[
'isDestroyed: false while the engine lives',
async () => {
ensure(engine.isDestroyed() === false, 'a live engine is destroyed');
},
],
[
'camera.pose: aspectRatio is the view width over its height',
async () => {
await nadirView();
const { width, height } = engine.host.size();
// Cesium's frustum uses the drawing buffer, rounded to device pixels.
near(camera.pose().aspectRatio, width / height, 0.01, 'aspectRatio');
},
],
[
'picker.pickGlobe: the nadir hits the globe and the sky misses',
async () => {
await nadirView();
const hit = engine.picker.pickGlobe(centre());
ensure(isCartesian3(hit), 'the nadir missed the globe');
const point = cartographic.fromCartesian(hit, WGS84);
// Terrain, when shown, stays within Earth's relief of the ellipsoid.
near(point.height, 0, 9000, 'globe height (m)');
near(math.toDegrees(point.longitude), 20, 0.05, 'longitude (deg)');
near(math.toDegrees(point.latitude), 10, 0.05, 'latitude (deg)');
camera.setView({
destination: cartesian3.fromDegrees(20, 10, 1e6),
orientation: { heading: 0, pitch: 0.6, roll: 0 },
});
await settle();
ensure(
engine.picker.pickGlobe(centre()) === undefined,
'the sky hit the globe',
);
},
],
[
'camera.commanded: fires for each camera command, never for reads',
async () => {
await nadirView();
let raised = 0;
const remove = camera.commanded.addEventListener(() => raised++);
const target = cartesian3.fromDegrees(20, 10, 0);
const commands = [
[
'setView',
() =>
camera.setView({
destination: cartesian3.fromDegrees(20, 10, 2e6),
}),
],
[
'flyTo',
() =>
camera.flyTo({
destination: cartesian3.fromDegrees(21, 10, 2e6),
duration: 0,
}),
],
[
'flyToSphere',
() =>
camera.flyToSphere(
{ center: target, radius: 100 },
{ duration: 0 },
),
],
[
'lookAt',
() => camera.lookAt(target, { heading: 0, pitch: -1, range: 5e3 }),
],
['cancelFlight', () => camera.cancelFlight()],
];
try {
camera.pose();
camera.height();
camera.viewRectangle();
camera.pickEllipsoid(centre());
ensure(raised === 0, `reads raised camera.commanded ${raised}x`);
for (const [name, command] of commands) {
const before = raised;
command();
ensure(raised > before, `${name} did not raise camera.commanded`);
}
camera.release();
} finally {
remove();
}
const count = raised;
camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) });
ensure(raised === count, 'a removed listener was called');
},
],
];
if (mapSources?.photoreal && mapSources.imagery?.length >= 2)
checks.push(...mapChecks(engine.maps, mapSources));
+21 -5
View File
@@ -35,6 +35,7 @@ const SCRIPTABLE = new Set([
'pick',
'drillPick',
'pickPosition',
'pickGlobe',
'pickEllipsoid',
'viewRectangle',
'sampleHeight',
@@ -235,6 +236,7 @@ export function createFakeEngine(options = {}) {
'camera.changed': createEngineEvent(),
'camera.moveStart': createEngineEvent(),
'camera.moveEnd': createEngineEvent(),
'camera.commanded': createEngineEvent(),
'frames.preUpdate': createEngineEvent(),
'frames.preRender': createEngineEvent(),
'frames.postRender': createEngineEvent(),
@@ -262,6 +264,11 @@ export function createFakeEngine(options = {}) {
destroyed: false,
};
const record = (method, args) => fake.calls.push({ method, args });
// A camera command: recorded, then announced before it runs.
const command = (method, args) => {
record(method, args);
events['camera.commanded'].raiseEvent();
};
const aspect = () => width / height;
const right = () => unit(cross(state.direction, state.up));
@@ -604,6 +611,7 @@ export function createFakeEngine(options = {}) {
up: copy3(state.up),
right: right(),
fovy,
aspectRatio: aspect(),
};
},
get livePosition() {
@@ -611,13 +619,13 @@ export function createFakeEngine(options = {}) {
},
height: () => cartographic.fromCartesian(state.position, WGS84).height,
setView(view) {
record('camera.setView', [view]);
command('camera.setView', [view]);
if (!view?.destination)
throw new TypeError('setView needs a destination');
apply(viewFor(view.destination, view.orientation, CESIUM_DEFAULTS));
},
flyTo(flightOptions) {
record('camera.flyTo', [flightOptions]);
command('camera.flyTo', [flightOptions]);
if (!flightOptions?.destination)
throw new TypeError('flyTo needs a destination');
startFlight(
@@ -630,7 +638,7 @@ export function createFakeEngine(options = {}) {
);
},
flyToSphere(sphere, sphereOptions = {}) {
record('camera.flyToSphere', [sphere, sphereOptions]);
command('camera.flyToSphere', [sphere, sphereOptions]);
setTransform(matrix4.IDENTITY);
let range = sphereOptions.range;
if (!range) {
@@ -669,7 +677,7 @@ export function createFakeEngine(options = {}) {
},
flightActive: () => flight != null,
lookAt(target, offset) {
record('camera.lookAt', [target, offset]);
command('camera.lookAt', [target, offset]);
const frame = geo.transforms.eastNorthUpToFixedFrame(target, WGS84);
setTransform(frame, target);
lookAtFrame(frame, offset);
@@ -685,7 +693,7 @@ export function createFakeEngine(options = {}) {
setTransform(matrix4.IDENTITY);
},
cancelFlight() {
record('camera.cancelFlight', []);
command('camera.cancelFlight', []);
cancelCurrentFlight();
},
zoomIn(amount = DEFAULT_ZOOM_AMOUNT) {
@@ -711,6 +719,7 @@ export function createFakeEngine(options = {}) {
changed: events['camera.changed'],
moveStart: events['camera.moveStart'],
moveEnd: events['camera.moveEnd'],
commanded: events['camera.commanded'],
get percentageChanged() {
return percentageChanged;
},
@@ -745,6 +754,12 @@ export function createFakeEngine(options = {}) {
);
},
pickPositionSupported: true,
pickGlobe(screen) {
record('picker.pickGlobe', [screen]);
return scripted('pickGlobe', [screen], () =>
camera.pickEllipsoid(screen),
);
},
},
projector: {
worldToWindow(position, result) {
@@ -857,6 +872,7 @@ export function createFakeEngine(options = {}) {
cancelCurrentFlight();
fake.destroyed = true;
},
isDestroyed: () => fake.destroyed,
fake,
};
+19
View File
@@ -378,3 +378,22 @@ test('fake host elements can be supplied', () => {
kind: 'fake-container',
});
});
test('fake isDestroyed, commanded and pickGlobe are inspectable', () => {
const engine = createFakeEngine();
let commanded = 0;
engine.camera.commanded.addEventListener(() => commanded++);
engine.fake.emit('camera.commanded');
engine.camera.cancelFlight();
assert.equal(commanded, 2);
engine.fake.script('pickGlobe', { x: 1, y: 2, z: 3 });
assert.deepEqual(engine.picker.pickGlobe({ x: 0, y: 0 }), {
x: 1,
y: 2,
z: 3,
});
assert.equal(engine.fake.callsOf('picker.pickGlobe').length, 1);
assert.equal(engine.isDestroyed(), false);
engine.destroy();
assert.equal(engine.isDestroyed(), true);
});