Run the camera verbs on the globe contract so orbit, pan, tilt, rotate, globe rotation and the route dolly move a Navara camera as they move Cesium's

A motion drives one camera through a rig: a Cesium viewer's own camera, or any globe engine through camera.pose/setView/lookAt/release/transform/flightActive/following and frames.preRender. An engine rig binds its frame, canvas-input and follow listeners only while its motion runs. A finished once orbit now releases its lookAt lock, and an orbit armed through a flight keeps its nudge budget until it starts.
This commit is contained in:
German Khokhlov committed 2026-11-03 21:11:30 +04:00
1 parent 4c28cc0212
commit dcb8de9d2c
2 files changed
+1424 -327

No files matched your search

+798 -327
View File
File diff suppressed because it is too large. Load diff
+626
View File
@@ -0,0 +1,626 @@
// The camera verbs on the engine-neutral globe contract (the engine rig), on
// the fake engine: pose deltas and durations against a real Cesium camera
// driven through the viewer rig, cancellation, supersession and the release
// an engine switch makes.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from './geo/index.js';
import { GLOBE_VIEW } from './locations.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
import {
activeCameraMotionId,
cameraVerbsSupported,
flyRoute,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
moveCamera,
releaseCameraVerbs,
resumeRouteFlight,
suspendRouteFlight,
} from './cameraVerbs.js';
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
const WIDTH = 1280;
const HEIGHT = 800;
const radians = geo.math.toRadians;
const hasRenderHold = () =>
getRenderGovernorDiagnostics().holds.includes('camera-verb');
/** Wall clock the verbs read (performance.now), moved only by the tests. */
function installClock(t) {
const real = globalThis.performance;
const clock = { ms: 0 };
globalThis.performance = { now: () => clock.ms };
t.after(() => {
interruptCameraMotion('test-cleanup');
globalThis.performance = real;
});
return clock;
}
const START = {
lonDeg: 10,
latDeg: 30,
height: 5000,
headingDeg: 30,
pitchDeg: -45,
};
/** The fake engine at START (or `pose`), with a DOM-like canvas. */
function engineHarness(t, pose = START, clock = installClock(t)) {
const engine = createFakeEngine({
kind: 'navara',
width: WIDTH,
height: HEIGHT,
canvas: new EventTarget(),
});
engine.fake.setPose({
longitude: radians(pose.lonDeg),
latitude: radians(pose.latDeg),
height: pose.height,
heading: radians(pose.headingDeg),
pitch: radians(pose.pitchDeg),
roll: 0,
});
const frame = (seconds = 0.1) => {
clock.ms += seconds * 1000;
engine.fake.frame(seconds * 1000);
};
const run = (seconds, step = 0.1) => {
for (let s = 0; s < seconds - 1e-9; s += step) frame(step);
};
return { engine, clock, frame, run, camera: () => engine.camera.pose() };
}
/** A real Cesium camera on a stub scene (no renderer), driven as a viewer. */
function viewerHarness(t, pose = START, clock = installClock(t)) {
const canvas = Object.assign(new EventTarget(), {
clientWidth: WIDTH,
clientHeight: HEIGHT,
width: WIDTH,
height: HEIGHT,
});
const ellipsoid = Cesium.Ellipsoid.WGS84;
const scene = {
canvas,
drawingBufferWidth: WIDTH,
drawingBufferHeight: HEIGHT,
frameState: { mode: Cesium.SceneMode.SCENE3D },
mapProjection: new Cesium.GeographicProjection(ellipsoid),
globe: { ellipsoid },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
tweens: [],
requestRender() {},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
pose.lonDeg,
pose.latDeg,
pose.height,
),
orientation: {
heading: radians(pose.headingDeg),
pitch: radians(pose.pitchDeg),
roll: 0,
},
});
const viewer = {
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
const frame = (seconds = 0.1) => {
clock.ms += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock);
};
const run = (seconds, step = 0.1) => {
for (let s = 0; s < seconds - 1e-9; s += step) frame(step);
};
const cameraPose = () => ({
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
up: Cesium.Cartesian3.clone(camera.upWC),
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
});
return { viewer, camera: cameraPose, frame, run, cesium: camera };
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected}, received ${actual}`,
);
}
function samePose(actual, expected, label, metres = 1e-3, unit = 1e-9) {
near(
geo.cartesian3.distance(actual.position, expected.position),
0,
metres,
`${label} position`,
);
near(
geo.cartesian3.distance(actual.direction, expected.direction),
0,
unit,
`${label} direction`,
);
near(geo.cartesian3.distance(actual.up, expected.up), 0, unit, `${label} up`);
}
const ORBIT_TARGET = geo.cartesian3.fromDegrees(10.02, 30.03, 0);
/**
* Run one verb on the Cesium viewer rig and on the engine rig from the same
* pose with the same frames; return both camera poses before and after.
*/
function runOnBoth(t, args, seconds, { pose = START, target = null } = {}) {
const clock = installClock(t);
const cesium = viewerHarness(t, pose, clock);
initCameraVerbs(cesium.viewer, () => target);
const cesiumBefore = cesium.camera();
const cesiumResult = moveCamera(args);
assert.equal(cesiumResult.ok, true, JSON.stringify(cesiumResult));
cesium.run(seconds);
const cesiumAfter = cesium.camera();
interruptCameraMotion('parity');
releaseCameraVerbs(cesium.viewer);
const fake = engineHarness(t, pose, clock);
initCameraVerbs(fake.engine, () => target);
const engineBefore = fake.camera();
const engineResult = moveCamera(args);
assert.equal(engineResult.ok, true, JSON.stringify(engineResult));
fake.run(seconds);
const engineAfter = fake.camera();
return {
cesiumBefore,
cesiumAfter,
engineBefore,
engineAfter,
cesiumResult,
engineResult,
fake,
};
}
for (const [label, args, seconds] of [
['pan left once', { motion: 'pan', direction: 'left' }, 1.2],
['pan up once', { motion: 'pan', direction: 'up' }, 1.2],
[
'pan right continuous',
{ motion: 'pan', direction: 'right', mode: 'continuous', speed: 'fast' },
2,
],
['tilt up once', { motion: 'tilt', direction: 'up' }, 1.2],
['tilt down once', { motion: 'tilt', direction: 'down' }, 1.2],
['rotate left once', { motion: 'rotate', direction: 'left' }, 1.2],
[
'rotate right continuous',
{ motion: 'rotate', direction: 'right', mode: 'continuous' },
1.5,
],
])
test(`engine rig ${label} lands where the Cesium camera does`, (t) => {
const run = runOnBoth(t, args, seconds);
samePose(run.engineBefore, run.cesiumBefore, `${label} start`, 1e-6, 1e-9);
samePose(run.engineAfter, run.cesiumAfter, label, 1e-3, 1e-8);
assert.deepEqual(run.engineResult, run.cesiumResult);
});
test('engine rig orbit revolves around the ground target as Cesium lookAt does', (t) => {
for (const mode of ['once', 'continuous']) {
const run = runOnBoth(
t,
{ motion: 'orbit', direction: 'left', mode },
1.5,
{
target: ORBIT_TARGET,
},
);
samePose(run.engineAfter, run.cesiumAfter, `orbit ${mode}`, 1e-3, 1e-8);
// Range to the target is held while the heading turns.
near(
geo.cartesian3.distance(run.engineAfter.position, ORBIT_TARGET),
geo.cartesian3.distance(run.engineBefore.position, ORBIT_TARGET),
1e-3,
`orbit ${mode} range`,
);
interruptCameraMotion('next-mode');
}
});
test('engine rig globe rotation turns the camera about Earth like Cesium camera.rotate', (t) => {
const globe = {
lonDeg: 0,
latDeg: 15,
height: GLOBE_VIEW.heightM,
headingDeg: 0,
pitchDeg: -90,
};
for (const direction of ['left', 'right']) {
const run = runOnBoth(
t,
{ motion: 'rotate_globe', direction, mode: 'continuous' },
1,
{ pose: globe },
);
samePose(
run.engineAfter,
run.cesiumAfter,
`globe ${direction}`,
1e-3,
1e-8,
);
// Radius preserved; normal speed is 10 degrees a second about Earth.
near(
geo.cartesian3.magnitude(run.engineAfter.position),
geo.cartesian3.magnitude(run.engineBefore.position),
1e-6,
'radius',
);
const turned = geo.cartesian3.angleBetween(
run.engineAfter.position,
run.engineBefore.position,
);
// Ten frames of 0.1 s: the first one takes the minimum step (1 ms).
near(geo.math.toDegrees(turned), 10 * 0.901, 1e-4, 'rate');
interruptCameraMotion('next-direction');
}
});
test('a once nudge spends its budget in 0.9 s and then lets go', (t) => {
const { engine, run, camera } = engineHarness(t);
initCameraVerbs(engine, () => ORBIT_TARGET);
const before = camera();
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
assert.equal(hasRenderHold(), true);
run(0.8);
assert.equal(getActiveCameraMotion()?.kind, 'tilt', 'still easing');
run(0.2);
assert.equal(getActiveCameraMotion(), null, 'finished after 0.9 s');
assert.equal(hasRenderHold(), false);
near(
geo.math.toDegrees(camera().pitch - before.pitch),
15,
1e-6,
'tilt nudge',
);
samePose(
{ ...camera(), direction: before.direction, up: before.up },
before,
'tilt keeps the position',
);
});
test('a finished once orbit releases its lookAt lock', (t) => {
const { engine, run } = engineHarness(t);
initCameraVerbs(engine, () => ORBIT_TARGET);
assert.equal(moveCamera({ motion: 'orbit' }).ok, true);
run(0.5);
assert.notDeepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]);
run(1);
assert.equal(getActiveCameraMotion(), null);
assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]);
});
test('an orbit started during a flight arms and captures once it settles', (t) => {
const { engine, run } = engineHarness(t);
initCameraVerbs(engine, () => ORBIT_TARGET);
engine.camera.flyTo({
destination: geo.cartesian3.fromDegrees(10.01, 30, 4000),
duration: 1,
});
const started = moveCamera({ motion: 'orbit', mode: 'continuous' });
assert.equal(started.armed, 'waiting-for-arrival');
run(0.5);
assert.equal(engine.camera.flightActive(), true, 'still flying');
assert.equal(engine.fake.callsOf('camera.lookAt').length, 0, 'armed');
run(0.6);
assert.equal(engine.camera.flightActive(), false, 'flight landed');
assert.equal(engine.fake.callsOf('camera.lookAt').length, 0, 'still armed');
run(1.2);
assert.ok(engine.fake.callsOf('camera.lookAt').length > 0, 'orbiting');
assert.equal(getActiveCameraMotion()?.kind, 'orbit');
});
test('manual input on the canvas stops the motion and releases the hold', (t) => {
const { engine, run } = engineHarness(t);
initCameraVerbs(engine, () => ORBIT_TARGET);
for (const event of ['pointerdown', 'touchstart', 'wheel']) {
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
run(0.3);
engine.host.canvas.dispatchEvent(new Event(event));
assert.equal(getActiveCameraMotion(), null, event);
assert.equal(hasRenderHold(), false, event);
assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]);
engine.fake.resetCalls();
run(0.3);
assert.equal(engine.fake.callsOf('camera.lookAt').length, 0, event);
}
});
test('a new verb supersedes the running one and stop ends it', (t) => {
const { engine, run } = engineHarness(t);
initCameraVerbs(engine, () => ORBIT_TARGET);
moveCamera({ motion: 'orbit', mode: 'continuous' });
const orbitId = activeCameraMotionId();
run(0.3);
moveCamera({ motion: 'pan', direction: 'left', mode: 'continuous' });
assert.notEqual(activeCameraMotionId(), orbitId);
assert.equal(getActiveCameraMotion().kind, 'pan');
assert.equal(getActiveCameraMotion().rig, 'engine');
assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]);
engine.fake.resetCalls();
run(0.3);
assert.equal(engine.fake.callsOf('camera.lookAt').length, 0);
assert.ok(engine.fake.callsOf('camera.setView').length > 0);
const stop = moveCamera({ motion: 'stop' });
assert.deepEqual(stop, {
ok: true,
action: 'move_camera',
motion: 'stop',
stopped: true,
});
assert.equal(hasRenderHold(), false);
});
test('one frame listener and three input listeners exist only while a motion runs', (t) => {
const { engine, run } = engineHarness(t);
const canvas = engine.host.canvas;
const added = [];
const removed = [];
const add = canvas.addEventListener.bind(canvas);
const remove = canvas.removeEventListener.bind(canvas);
canvas.addEventListener = (type, ...rest) => {
added.push(type);
add(type, ...rest);
};
canvas.removeEventListener = (type, ...rest) => {
removed.push(type);
remove(type, ...rest);
};
const preRender = engine.frames.preRender;
const baseline = preRender.numberOfListeners;
initCameraVerbs(engine, () => ORBIT_TARGET);
assert.equal(preRender.numberOfListeners, baseline, 'init binds nothing');
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' });
assert.equal(preRender.numberOfListeners, baseline + 1);
moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' });
assert.equal(preRender.numberOfListeners, baseline + 1, 'rebound, not added');
run(0.2);
interruptCameraMotion('test');
assert.equal(preRender.numberOfListeners, baseline);
assert.deepEqual(added.sort(), removed.sort());
});
test('an engine switch releases the verb: the parked engine is never driven again', (t) => {
const first = engineHarness(t);
initCameraVerbs(first.engine, () => ORBIT_TARGET);
moveCamera({ motion: 'pan', direction: 'right', mode: 'continuous' });
first.run(0.3);
// What the Navara scene's suspend does (src/app/scene.js).
releaseCameraVerbs(first.engine);
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
first.engine.fake.resetCalls();
first.run(0.5);
assert.equal(first.engine.fake.callsOf('camera.setView').length, 0);
// Nothing new starts on the released engine.
const refused = moveCamera({ motion: 'pan', direction: 'left' });
assert.equal(refused.ok, false);
assert.equal(refused.reason, 'no-camera');
// The next engine's voice runner installs it; verbs run there only.
const next = engineHarness(t, START, first.clock);
initCameraVerbs(next.engine, () => ORBIT_TARGET);
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
next.run(1);
first.run(0.5);
assert.equal(first.engine.fake.callsOf('camera.setView').length, 0);
});
test('installing another camera stops a motion running on the previous one', (t) => {
const first = engineHarness(t);
initCameraVerbs(first.engine, () => ORBIT_TARGET);
moveCamera({ motion: 'orbit', mode: 'continuous' });
first.run(0.3);
const cesium = viewerHarness(t, START, first.clock);
initCameraVerbs(cesium.viewer, () => ORBIT_TARGET);
assert.equal(getActiveCameraMotion(), null);
assert.deepEqual(first.engine.camera.transform(), [...geo.matrix4.IDENTITY]);
releaseCameraVerbs(cesium.viewer);
});
test('globe rotation yields when a follow takes the engine camera', (t) => {
const { engine, run } = engineHarness(t, {
lonDeg: 0,
latDeg: 15,
height: GLOBE_VIEW.heightM,
headingDeg: 0,
pitchDeg: -90,
});
initCameraVerbs(engine, () => ORBIT_TARGET);
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true);
run(0.3);
const target = geo.cartesian3.fromDegrees(0, 15, 10000);
engine.camera.follow({
position: () => target,
viewFrom: geo.cartesian3.create(0, -2000, 1000),
});
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
// And a request while following is refused before any handoff.
let handoffs = 0;
const refused = moveCamera({ motion: 'rotate_globe' }, () => {
handoffs += 1;
});
assert.equal(refused.ok, false);
assert.match(refused.error, /Already following/);
assert.equal(handoffs, 0);
});
test('globe rotation from a close-up offers the Globe View reset on the engine', (t) => {
const { engine } = engineHarness(t);
initCameraVerbs(engine);
const before = engine.camera.pose();
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false);
assert.equal(result.reason, 'globe-view-required');
samePose(engine.camera.pose(), before, 'refusal keeps the camera');
});
test('tilt limits are read from the engine pose', (t) => {
const { engine } = engineHarness(t, { ...START, pitchDeg: -89 });
initCameraVerbs(engine);
const result = moveCamera({ motion: 'tilt', direction: 'down' });
assert.equal(result.ok, false);
assert.match(result.error, /straight down/);
});
const ROUTE = [
{
type: 'route',
label: 'walk',
path: [
{ lat: 30, lon: 10 },
{ lat: 30.01, lon: 10 },
{ lat: 30.01, lon: 10.01 },
],
},
];
test('fly_route flies the installed engine camera and lands level', (t) => {
const { engine, run } = engineHarness(t);
initCameraVerbs(engine, () => null);
const result = flyRoute(
ROUTE,
{ speed: 'fast' },
() => 0,
null,
() => {},
);
assert.equal(result.ok, true);
assert.equal(getActiveCameraMotion().rig, 'engine');
run(3);
const mid = getActiveCameraMotion();
assert.ok(mid.traveledM > 0, 'departed');
assert.ok(engine.fake.callsOf('camera.setView').length > 0);
run(result.durationS + 2);
assert.equal(getActiveCameraMotion(), null, 'landed');
near(geo.math.negativePiToPi(engine.camera.pose().roll), 0, 1e-5, 'roll');
assert.equal(hasRenderHold(), false);
});
test('a parked route flight resumes on another engine without jumping', (t) => {
const first = engineHarness(t);
initCameraVerbs(first.engine, () => null);
const { motionId } = flyRoute(
ROUTE,
{},
() => 0,
null,
() => {},
);
first.run(4);
const parkedAt = getActiveCameraMotion().traveledM;
const state = suspendRouteFlight(motionId);
assert.ok(state);
assert.equal(getActiveCameraMotion(), null);
const next = engineHarness(t, START, first.clock);
next.clock.ms += 60_000; // parked for a minute
assert.equal(resumeRouteFlight(state, next.engine), true);
next.frame(0.1);
const resumed = getActiveCameraMotion();
assert.equal(resumed.motionId, motionId);
assert.ok(resumed.traveledM - parkedAt < 10, 'no jump across the park');
first.engine.fake.resetCalls();
first.run(0.5);
assert.equal(first.engine.fake.callsOf('camera.setView').length, 0);
});
test('verbs without an installed camera refuse instead of throwing', (t) => {
installClock(t);
const engine = createFakeEngine();
initCameraVerbs(engine);
releaseCameraVerbs(engine);
assert.equal(moveCamera({ motion: 'orbit' }).reason, 'no-camera');
assert.equal(flyRoute(ROUTE).reason, 'no-camera');
});
test('cameraVerbsSupported names the targets the verbs can drive', () => {
const engine = createFakeEngine();
assert.equal(cameraVerbsSupported(engine), true);
assert.equal(cameraVerbsSupported(null), false);
assert.equal(
cameraVerbsSupported({
camera: {},
scene: {},
clock: { onTick: new Cesium.Event() },
}),
true,
'a Cesium viewer',
);
const partial = createFakeEngine();
partial.camera = { ...partial.camera, following: undefined };
assert.equal(cameraVerbsSupported(partial), false);
engine.destroy();
assert.equal(cameraVerbsSupported(engine), false);
});
test('a once orbit armed through a flight still turns its full nudge', (t) => {
for (const kind of ['viewer', 'engine']) {
const clock = installClock(t);
const target = ORBIT_TARGET;
let h;
let flying;
if (kind === 'engine') {
h = engineHarness(t, START, clock);
initCameraVerbs(h.engine, () => target);
const here = h.camera();
h.engine.camera.flyTo({
destination: here.position,
orientation: { direction: here.direction, up: here.up },
duration: 1.5,
});
flying = true;
} else {
h = viewerHarness(t, START, clock);
initCameraVerbs(h.viewer, () => target);
h.viewer.scene.tweens.push({});
flying = true;
}
const before = h.camera();
assert.equal(moveCamera({ motion: 'orbit' }).armed, 'waiting-for-arrival');
h.run(1.6);
if (kind === 'viewer' && flying) h.viewer.scene.tweens.length = 0;
h.run(1);
const start = getActiveCameraMotion();
assert.equal(start?.kind, 'orbit', `${kind}: the arm outlived 0.9 s`);
h.run(1.2);
assert.equal(getActiveCameraMotion(), null, `${kind}: nudge finished`);
const after = h.camera();
near(
geo.cartesian3.distance(after.position, target),
geo.cartesian3.distance(before.position, target),
1e-3,
`${kind}: range`,
);
const turn = geo.math.toDegrees(
geo.math.negativePiToPi(after.heading - before.heading),
);
near(turn, 30, 0.05, `${kind}: heading (camera frame vs target frame)`);
if (kind === 'viewer') releaseCameraVerbs(h.viewer);
}
});