mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 21:32:23 +00:00
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:
1 parent
4c28cc0212
commit
dcb8de9d2c
2 files changed
+1424
-327
No files matched your search
+798
-327
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user