Files
gods-eye-view/src/cameraTilt.test.mjs
T
Sameh Khamis 4951393997 Keep the place in view when a late zoom out catches a tilt
From 400 km, a tilt to -5° that a zoom out x2.5 caught 70 frames in eased down from the camera's old pitch at the new range. That put the camera below the place's horizon: the first frame's crosshair hit the ground 186 km from London, and London stayed hidden for about 11 frames. The re-solve also left the pivot pitch at 0°, so a zoom in before the next frame computed an infinite clearance floor, was refused, and stayed at 1,000 km.

The re-solved pivot pitch now stops at the place's horizon limit, or where the pivot already was. A camera that the zoom leaves past that limit steps down to it with the zoom and holds it, and the zoom still reports that angle. A zoom in straight after brings the range back to 400 km.
2026-11-05 16:55:54 -08:00

774 lines
29 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
adjustOrbitRange,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
moveCamera,
} from './cameraVerbs.js';
import { createGevActionRunner } from './voice/gevActions.js';
const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
function harness(
t,
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
) {
const realPerformance = globalThis.performance;
let nowMs = 0;
globalThis.performance = { now: () => nowMs };
const canvas = Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
width: 1280,
height: 800,
});
const ellipsoid = Cesium.Ellipsoid.WGS84;
const scene = {
canvas,
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
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(-0.1276, 51.5072, height),
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
});
const viewer = {
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
// The app's view target: the ground point under the screen centre.
const viewTarget = () => {
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
};
const target = viewTarget();
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
t.after(() => {
interruptCameraMotion('test-cleanup');
globalThis.performance = realPerformance;
});
const tick = (seconds = 1 / 30) => {
nowMs += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock);
};
const finish = () => {
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
};
return { viewer, camera, target, tick, finish, viewTarget };
}
const pitchDeg = (camera) => degrees(camera.pitch);
/** Tick until done() holds; a regression fails here instead of hanging. */
const tickUntil = (h, done, frames = 600) => {
for (let i = 0; i < frames && !done(); i += 1) h.tick();
assert.ok(done(), `not reached in ${frames} frames`);
};
const headingDeg = (camera) =>
degrees(Cesium.Math.negativePiToPi(camera.heading));
/** Metres between the original centre and the ground now under the crosshair. */
function centreDrift(h) {
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
}
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(result.ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('continuous tilt up from top-down keeps moving', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
});
test('a requested angle tilts to that pitch', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.direction, 'up');
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a bare tilt settles on the default oblique angle', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt' });
assert.equal(result.ok, true);
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
test('a requested angle can tilt back down toward top-down', (t) => {
const h = harness(t, { pitch: -30 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('requested angles clamp to the tilt limits', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a positive angle is read as the same angle below the horizon', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
for (const pitch of ['', null, false, ' ', 'steep'])
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
// No angle and no direction: the default oblique view, never the horizon.
assert.equal(result.pitchDeg, -45);
});
test('a requested angle the camera already holds is a no-op', (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(getActiveCameraMotion(), null);
});
for (const pitch of [-90, -89]) {
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
// The tool description calls -89 top-down; the clamp must not tilt a
// -90° view up 1° and report it as a move up.
const h = harness(t);
const before = pitchDeg(h.camera);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(result.pitchDeg, Math.round(before));
assert.equal(getActiveCameraMotion(), null);
assert.equal(pitchDeg(h.camera), before);
});
}
test('tilt down from top-down still refuses honestly', (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'down' });
assert.equal(result.ok, false);
assert.match(result.error, /straight down/);
});
test('a tilt up nudge stops at the horizon limit', (t) => {
const h = harness(t, { pitch: -12 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
const h = harness(t, { pivot: false });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
});
for (const heading of [0, 30, -120])
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
const h = harness(t, { heading, height: 67000 });
// Top-down float noise must not pick a new heading for the pivot.
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(
Math.abs(headingDeg(h.camera) - heading) < 1,
`heading ${headingDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
});
test('an oblique tilt keeps its heading toward the pivot', (t) => {
const h = harness(t, { heading: 75, pitch: -60 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
h.finish();
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a pivoting tilt yields when tracking takes the camera', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.tick();
const entity = { id: 'tracked' };
h.viewer.trackedEntity = entity;
h.viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal(getActiveCameraMotion(), null);
});
/** Metres between the camera and the point it pivots about. */
const pivotRange = (h) =>
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
const heightM = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
h.tick();
const before = pivotRange(h);
assert.deepEqual(
adjustOrbitRange(0.5),
{ kind: 'tilt', changed: true },
'the tilt must take the zoom',
);
for (let i = 0; i < 5; i += 1) h.tick();
// The next frame's lookAt must not put the old range back.
assert.ok(
Math.abs(pivotRange(h) - before / 2) < 1,
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
);
});
test('a zoom with no pivot to hold is left to the camera', (t) => {
harness(t, { pivot: false });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
assert.equal(adjustOrbitRange(0.5), null);
});
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
const h = harness(t, { height: 250 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
let lowest = Infinity;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
lowest = Math.min(lowest, heightM(h.camera));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('a continuous tilt up from close in keeps its clearance', (t) => {
const h = harness(t, { height: 250 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', pitchDeg: -5 });
h.finish();
// 4 km of range clears the floor at every pitch: all pivot, no drift.
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('pitchDeg against a contradicting direction is refused', (t) => {
harness(t, { pitch: -45 });
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: 10,
});
assert.equal(result.ok, false);
assert.match(result.error, /absolute angle/);
assert.equal(getActiveCameraMotion(), null);
});
test('pitchDeg with a direction that agrees still tilts', (t) => {
const h = harness(t, { pitch: -45 });
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: -80,
});
assert.equal(result.ok, true);
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('the pitchDeg and direction check uses the camera pitch', (t) => {
// At regional range the Earth curves away and the pivot pitch is a degree
// or two shallower than the camera's (-30°); -29° is still up from it.
harness(t, { height: 150000, pitch: -30 });
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
assert.equal(down.ok, false);
assert.match(down.error, /tilts up, not down/);
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
assert.equal(up.ok, true);
assert.equal(up.direction, 'up');
});
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
const h = harness(t, { height: 1000 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -30);
adjustOrbitRange(1 / 2.4);
adjustOrbitRange(1 / 2.4);
h.tick();
const afterZoom = heightM(h.camera);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(
heightM(h.camera) > afterZoom - 1,
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
// 62 m of range, under the 80 m zoom floor.
const h = harness(t, { height: 40, pitch: -40 });
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
h.tick();
const before = pivotRange(h);
assert.ok(before < 80, `range ${before} m`);
adjustOrbitRange(1 / 1.6);
h.tick();
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
});
test('a zoom out during a far orbit never pulls the camera in', (t) => {
// 15,000 km up, beyond the 5,000 km zoom cap.
const h = harness(t, { height: 15_000_000 });
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
h.tick();
const before = pivotRange(h);
assert.ok(before > 5_000_000, `range ${before} m`);
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false });
h.tick();
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
});
test('an out-of-range pitchDeg is clamped to the tilt limits', (t) => {
// The schema carries no range; 120 reads as 120° below the horizon.
const h = harness(t, { pitch: -45 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 120 }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -89) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a zoom in during a pivot tilt stops at the clearance', (t) => {
// 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left
// the camera about 107 m up, then at the 80 m zoom floor.
const h = harness(t, { height: 400 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -40);
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
// The camera must take that range, not just report it.
h.tick();
const floor = 150 / Math.sin(radians(40));
assert.ok(
Math.abs(pivotRange(h) - floor) < floor * 0.02,
`range ${pivotRange(h)} m, wanted about ${floor} m`,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a voice zoom during a pivot tilt says what it changed', async (t) => {
const h = harness(t, { height: 400 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
h.tick();
const zoom = { direction: 'in', amount: 'lot' };
const first = await runner('adjust_camera_zoom', zoom);
assert.equal(first.ok, true);
assert.equal(first.tiltRangeAdjusted, true);
assert.equal('orbitRadiusAdjusted' in first, false);
// 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and
// says so.
assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true);
const third = await runner('adjust_camera_zoom', zoom);
assert.equal(third.ok, false);
assert.match(third.error, /tilt is already at its closest range/);
});
test('from globe range a tilt happens in place', (t) => {
// A pivot 2,000 km out would swing the camera thousands of km.
const h = harness(t, { height: 2_000_000 });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
});
for (const height of [100_000, 400_000])
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
// The pivot pitch is shallower than the camera's by up to 4° at this
// range; the camera reached only -5.9° and -8.6° when the swing stopped
// at a pivot pitch of -5°.
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.pitchDeg, -5);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
});
// From 550 km out the camera's -5° lies beyond the place's own horizon: the
// swing ran into its clearance, let go of the pivot and tilted the rest in
// place, which put the crosshair on the sky (28 to 63 km up), and a return
// to top-down then landed 600 to 900 km from the place.
for (const height of [600_000, 700_000, 900_000]) {
test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => {
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
// The reply names the angle the camera reached, not the one asked for.
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true);
h.finish();
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
// Back over the place at the range it began with (-89° is ~1° off nadir).
const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
assert.ok(
Math.abs(range - height) < height * 0.01,
`top-down ${range} m from the place`,
);
});
test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => {
const h = harness(t, { height });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
});
}
test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => {
const h = harness(t, { height: 900_000 });
moveCamera({ motion: 'tilt', pitchDeg: 5 });
h.finish();
const again = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(again.ok, false);
assert.match(again.error, /upper tilt limit/);
});
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
// still tilt up 2°.
const h = harness(t, { height: 20000, pitch: -7 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
const h = harness(t, { height: 20000, pitch: -5.2 });
h.viewer.trackedEntity = { id: 'tracked' };
// Voice releases the follow camera before the motion starts.
const release = (start) => {
h.viewer.trackedEntity = undefined;
return start();
};
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
assert.equal(result.ok, false);
assert.match(result.error, /upper tilt limit/);
});
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
const h = harness(t, { height: 900_000 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
h.tick();
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
let farthest = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
farthest = Math.max(farthest, pivotRange(h));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('an unchanged angle still stops a running motion through navigation', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
let handoffs = 0;
const navigate = (start) => {
handoffs += 1;
return start();
};
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(handoffs, 1, 'the request must take navigation');
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const held = pitchDeg(h.camera);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(pitchDeg(h.camera), held);
});
for (const direction of ['left', 'right'])
test(`a tilt ${direction} is refused`, (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', direction });
assert.equal(result.ok, false);
assert.match(result.error, /tilt needs up or down/);
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
assert.equal(angled.ok, false);
assert.match(angled.error, /pitchDeg alone/);
assert.equal(getActiveCameraMotion(), null);
});
// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the
// pivot range came back a hair over it, so the tilt ran in place and put the
// crosshair on the sky.
test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => {
const h = harness(t, { height: 1_000_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
});
// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the
// angle the tilt now ends at, and the camera ends there.
test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => {
const h = harness(t, { height: 400_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.equal(result.pitchDeg, -5);
assert.equal(result.note, undefined);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.kind, 'tilt');
assert.equal(zoom.changed, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => {
const h = harness(t, { height: 900_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(1 / 2.4);
assert.equal(zoom.changed, true);
h.finish();
assert.equal(zoom.pitchDeg, -5);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`camera at ${pitchDeg(h.camera)}°`,
);
});
test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => {
const h = harness(t, { height: 400_000 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = await runner('adjust_camera_zoom', {
direction: 'out',
amount: 'lot',
});
assert.equal(zoom.ok, true);
assert.equal(zoom.tiltRangeAdjusted, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Late in the tilt the camera is already past the angle the new range's
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it
// the place is behind the Earth, so the camera steps down to that limit with
// the zoom, then holds it with no further jump, and the zoom's reply names
// the angle it ends at, not the one it stood at.
test('a late zoom out during a tilt steps to the new limit and reports it', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
h.tick();
assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera)));
let last = pitchDeg(h.camera);
let maxStep = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
last = pitchDeg(h.camera);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`);
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
// Replies give whole degrees: the reported angle is the reached one rounded.
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Easing from the camera's old pitch at the zoomed-out range put the camera
// below the place's horizon: the first frame's crosshair hit the ground
// 186 km from London, and London stayed hidden for ~11 frames.
test('a late zoom out during a tilt keeps the place under the crosshair', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
const hit = h.viewTarget();
assert.ok(hit, `frame ${i}: the crosshair is on the sky`);
const drift = centreDrift(h);
assert.ok(
drift < 100,
`frame ${i}: crosshair ${Math.round(drift)} m off the place`,
);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(
Math.abs(pivotRange(h) - 1_000_000) < 1,
`range ${pivotRange(h)} m`,
);
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// The zoom out left the pivot pitch level with the place, so the zoom in's
// clearance floor came out infinite and it was refused at 1,000 km.
test('a zoom in right after a late zoom out brings the tilt back in', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const range = pivotRange(h);
assert.equal(adjustOrbitRange(2.5).changed, true);
const zoomIn = adjustOrbitRange(0.4);
assert.equal(zoomIn.changed, true, 'the zoom in must not be refused');
h.finish();
assert.ok(
Math.abs(pivotRange(h) - range) < 1,
`range ${pivotRange(h)} m, wanted ${range} m`,
);
assert.ok(
centreDrift(h) < 100,
`crosshair ${centreDrift(h)} m off the place`,
);
});