Merge branch 'main' into pr/wildlife-sightings

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-05 17:24:06 -08:00
commit 93d495b0e5
10 files changed
+2078 -18

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned
the camera in place, so a few drags from the whole-globe view left only
stars, and the pose stayed in the share link. Free look now works only below
1,000 km, and above it a drag that starts on the stars orbits the globe
instead of turning the camera; a camera move that ends above it with no
planet surface in view (inside the scope keyhole when the scope is on) turns
straight down; and voice tilt and turns from orbit stop while the planet is
still on screen and say so instead of reporting a turn they could not make.
Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that
closed the drag path and kept authored scenes framed (Manjunath,
@manjunath22466, #1532).
+53
View File
@@ -65,6 +65,59 @@ settling. Oblique views below 300 km are left alone.
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
the real browser controller at desktop and phone widths with keyless fixtures.
Cesium's free look (Shift+drag, which turns the camera in place) works only
below 1,000 km. Above that height a few drags could turn the whole planet out of
frame and leave only stars, so Shift+drag does nothing there. Cesium also turns
the camera in place for a drag that starts on the stars below its trackball
height (7,500 km by default); that height is lowered to 1,000 km, so such a drag
orbits the globe above it, as it does higher up, and a tilt (drag or touch)
while the screen centre is on the stars there tilts about the planet's edge or
does nothing, wherever it starts. Rotation drags that start on the planet, and
zoom, are unchanged. How much of the frame shows the planet's surface is
measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope
mask is on (the default) only the rays inside its keyhole count, since the mask
hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above
1,000 km stop when about a tenth of the frame still shows the surface (or about
as much as it showed when it started), never less than about 1% of the visible
frame, and a view that already shows less only keeps or gains what it has. A
turn that cannot go half a degree further without crossing that limit is refused
with a reason instead of reported as done, and a turn it accepts turns at least
that half degree. A voice tilt to an angle (or a bare tilt, to -45°) that the
limit stops short of is aimed at the angle where it stops, and its result names
that angle (`pitchDeg`) with a `note`. A turn that keeps or gains surface is not refused, even after
the window has been resized, except that near the limit, where the planet's edge
crosses the sample grid, sampling wobble can occasionally refuse one (most often
above about 15,000 km and on phone or ultrawide frames); nothing then turns away
from the planet. A turn that starts where the last voice turn left the camera
keeps that turn's limit, or a higher one if the planet now fills more, so
repeating the request does not take any more; once anything else has moved or
turned the camera, a turn is measured from what is on screen. When a camera move
ends above 1,000 km with no ray on the surface, the camera turns straight down
at the same position and heading. The end of a move is taken from Cesium's
move-end event and from a check of its own that samples the pose every 250 ms
after a move starts and acts once it has held for half a second, because at some
of these poses the camera drifts by a rounding error every frame and Cesium
never sees it stop. That check keeps watching until move-end arrives, recovering
once after each move that comes to rest, and gives up five minutes after the
camera last moved. Limb glow, and a surface sliver up to about one grid cell
thick (some 50 px on a 900 px tall frame), can count as out of view. The
zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the
other ways a move can end looking away, such as a saved or shared view that did.
Views with more surface than that, such as the oblique orbital Director shots,
are left as they are, and so are tracked or locked cameras, a camera flight in
progress, a Director scene while it plays (holds included), a pointer held
down on the globe (a drag held still; with several fingers down, until the
last is released) and the 2D and Columbus modes; a check a flight, scene or
held pointer defers runs once it ends. A release outside the window that the
page never sees counts as given once the window loses focus, the page is
hidden, the canvas loses the pointer's capture, or the pointer moves with no
button down. A Director
shot loaded or replayed outside a run is not a playing scene. A view that loses
its surface without a camera move (a resize, or turning the scope on) is left as
it is until the next move. A voice turn requested while the camera is tracked is
judged by the pose voice's release of the follow camera will leave, before that
release, so a refused turn keeps the tracking.
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
+5
View File
@@ -164,6 +164,7 @@
"modules": [
"src/app/viewer.js",
"src/app/atmosphereCompat.js",
"src/globeInView.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/data/bundledJson.js",
@@ -2363,6 +2364,7 @@
"src/camera.js",
"src/cameraGroundGuard.js",
"src/cameraVerbs.js",
"src/globeInView.js",
"src/cctvFocusPolicy.js",
"src/cctvFocusRequest.js",
"src/celestialRing.js",
@@ -3719,6 +3721,9 @@
"src/annotations/worldAnnotationRenderer.js",
"src/cameraGroundGuard.js",
"src/cameraVerbs.js",
"src/globeInView.js",
"src/scopeMaskPolicy.js",
"src/scopeMask.js",
"src/services/groundFloor.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
+663 -1
View File
@@ -2,7 +2,20 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import * as Cesium from 'cesium';
import {
installFreeLookHeightLimit,
installGlobeInViewRecovery,
installTrackpadPinchZoom,
} from './viewer.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeFrameShare,
globeOutOfFrame,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from '../globeInView.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
@@ -343,3 +356,652 @@ test('trackpad pinch falls back to the original event when relay construction fa
assert.equal(event.propagationStopped, false);
assert.equal(f.relayed.length, 0);
});
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
const listeners = new Set();
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
const camera = { positionCartographic: { height } };
const viewer = {
camera,
scene: {
screenSpaceCameraController: controller,
preUpdate: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
};
const frame = (nextHeight) => {
camera.positionCartographic = { height: nextHeight };
for (const listener of listeners) listener();
};
return { viewer, controller, listeners, frame };
}
test('free look follows camera height and is restored on dispose', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
assert.equal(f.controller.enableLook, false);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
assert.equal(f.controller.enableLook, true);
f.frame(Number.NaN);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
dispose();
dispose();
assert.equal(f.controller.enableLook, true);
assert.equal(f.listeners.size, 0);
});
test('drags that start on the stars orbit above the free-look height', () => {
// Below Cesium's trackball height such a drag turns the camera in place.
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(
f.controller.minimumTrackBallHeight,
FREE_LOOK_MAX_HEIGHT_METERS,
);
dispose();
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
const lower = freeLookFixture({ height: 500 });
lower.controller.minimumTrackBallHeight = 500_000;
const disposeLower = installFreeLookHeightLimit(lower.viewer);
assert.equal(
lower.controller.minimumTrackBallHeight,
500_000,
'never raised',
);
disposeLower();
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
const later = freeLookFixture({ height: 500 });
const disposeLater = installFreeLookHeightLimit(later.viewer);
later.controller.minimumTrackBallHeight = 2_000_000;
disposeLater();
assert.equal(
later.controller.minimumTrackBallHeight,
2_000_000,
'dispose keeps a value set after install',
);
});
test('free look keeps a setting other code makes after it is installed', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
f.controller.enableLook = false;
f.frame(10);
assert.equal(f.controller.enableLook, false, 'turned off stays off');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
f.frame(10);
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
f.controller.enableLook = true;
f.frame(10);
assert.equal(f.controller.enableLook, true, 'turned back on');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
f.frame(10);
f.controller.enableLook = false;
dispose();
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
});
test('free look stays off when the viewer had it disabled', () => {
const f = freeLookFixture({ enableLook: false, height: 500 });
installFreeLookHeightLimit(f.viewer);
f.frame(10);
assert.equal(f.controller.enableLook, false);
});
// A real Cesium camera on the smallest scene its constructor reads, so the
// frustum test runs on Cesium's own view math rather than a stub.
function realCamera({ width = 1600, height = 900, mode } = {}) {
const scene = {
canvas: { clientWidth: width, clientHeight: height },
drawingBufferWidth: width,
drawingBufferHeight: height,
mapProjection: new Cesium.GeographicProjection(),
mode: mode ?? Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
tweens: new Cesium.TweenCollection(),
renders: 0,
requestRender() {
this.renders += 1;
},
};
const camera = new Cesium.Camera(scene);
camera.frustum.aspectRatio = width / height;
return { camera, scene, viewer: { camera, scene } };
}
function aim(
camera,
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
) {
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
orientation: {
heading: Cesium.Math.toRadians(heading),
pitch: Cesium.Math.toRadians(pitch),
roll: Cesium.Math.toRadians(roll),
},
});
}
const reportedStuckView = {
alt: 17_996_811,
heading: 180,
pitch: -42,
roll: 180,
};
// Oblique poses that still show part of the planet: Director recipe shots
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
const partialGlobeViews = [
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
];
// Stars and a few pixels of limb, no surface: where a frame limit measured
// against a sphere of the planet's largest radius used to stop a voice tilt.
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
test('the globe is out of frame only when no surface is on screen', () => {
const cases = [
[reportedStuckView, 1225, 923, true],
[limbOnlyView, 1600, 900, true],
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
];
for (const [view, width, height, out] of cases) {
const { camera } = realCamera({ width, height });
aim(camera, view);
const label = JSON.stringify(view);
assert.equal(globeOutOfFrame(camera), out, label);
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
assert.ok(
Math.abs(
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
) < 0.03,
`${label}: the frame share matches the pick-ray oracle`,
);
}
});
test('a move that ends with the globe out of frame turns back to nadir', () => {
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(viewer);
aim(camera, reportedStuckView);
const position = Cesium.Cartesian3.clone(camera.positionWC);
const heading = camera.heading;
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
assert.ok(
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(scene.renders, 1);
// Looking up near the ground, then zoomed out past the limit.
aim(camera, { alt: 3_834_000, pitch: 10 });
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Only the limb on a 16:9 screen still counts as lost.
const wide = realCamera({ width: 1600, height: 900 });
const disposeWide = installGlobeInViewRecovery(wide.viewer);
aim(wide.camera, limbOnlyView);
wide.camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
disposeWide();
dispose();
dispose();
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
});
// True when a moveEnd leaves the camera's view direction as it was.
function moveEndKeepsView(camera) {
const direction = Cesium.Cartesian3.clone(camera.directionWC);
camera.moveEnd.raiseEvent();
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
}
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
for (const view of partialGlobeViews) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer);
aim(camera, view);
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
}
const low = realCamera();
installGlobeInViewRecovery(low.viewer);
aim(low.camera, { alt: 900_000, pitch: 85 });
assert.equal(globeOutOfFrame(low.camera), true);
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
// Locked on a point in space, between it and the planet, facing away.
const locked = realCamera();
installGlobeInViewRecovery(locked.viewer);
locked.camera.lookAtTransform(
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
new Cesium.Cartesian3(-1_000_000, 0, 0),
);
assert.equal(globeOutOfFrame(locked.camera), true);
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
// Tracking an entity that has no position yet keeps an identity transform.
const tracked = realCamera();
tracked.viewer.trackedEntity = { id: 'pending-fix' };
installGlobeInViewRecovery(tracked.viewer);
aim(tracked.camera, reportedStuckView);
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
installGlobeInViewRecovery(flat.viewer);
flat.scene.mode = Cesium.SceneMode.SCENE3D;
aim(flat.camera, reportedStuckView);
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
});
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
const radians = Cesium.Math.toRadians;
// About 2% of a 16:9 frame on the planet, below the 10% limit.
const sliver = { alt: 18_000_000, pitch: -60 };
const { camera } = realCamera();
aim(camera, sliver);
const start = surfacePixelShare(camera, 1600, 900);
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
const startShare = globeFrameShare(camera);
for (let i = 0; i < 20; i += 1) {
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
startShare,
});
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
camera.lookLeft(step);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
// Toward the planet is always allowed.
aim(camera, sliver);
assert.equal(
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
radians(10),
);
// Away from it stops short of losing it.
aim(camera, sliver);
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
assert.ok(away < radians(30));
camera.lookUp(away);
assert.ok(globeFrameShare(camera) > 0);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('the look limit leaves a view with no surface to the move-end recovery', () => {
const { camera } = realCamera({ width: 1225, height: 923 });
aim(camera, reportedStuckView);
assert.equal(
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
Cesium.Math.toRadians(5),
);
});
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
// keyhole: the full frame shows some planet, the scope shows none.
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
const { camera } = realCamera();
aim(camera, cornerOnlyView);
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(globeOutOfFrame(camera, keyhole), true);
aim(camera, { alt: 18_000_000, pitch: -90 });
assert.ok(
Math.abs(
globeFrameShare(camera, keyhole) -
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
) < 0.03,
'the keyhole share matches the pick-ray oracle',
);
});
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
for (const [scopeOn, expectRecovery] of [
[true, true],
[false, false],
]) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer, {
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
});
aim(camera, cornerOnlyView);
camera.moveEnd.raiseEvent();
assert.equal(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
expectRecovery,
`scope ${scopeOn ? 'on' : 'off'}`,
);
}
});
test('the recovery leaves a camera flight and an authored scene alone', () => {
let playing = true;
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
scene.camera = camera;
scene.screenSpaceCameraController = { enableInputs: true };
installGlobeInViewRecovery(viewer, { paused: () => playing });
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
playing = false;
// A flight that pauses looking away is left alone until it ends.
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
duration: 5,
});
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
camera.cancelFlight();
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
});
test('a check a scene or flight defers runs once it ends', () => {
for (const viaMoveEnd of [false, true]) {
let playing = true;
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, {
paused: () => playing,
...timers,
});
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
for (let i = 0; i < 4; i += 1) timers.tick();
if (viaMoveEnd) camera.moveEnd.raiseEvent();
// Longer than the settle check's five-minute bound.
for (let i = 0; i < 1500; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
assert.equal(timers.pending(), 1, 'still watching');
playing = false;
timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
);
}
});
// Holding a drag still for half a second on a view that already showed no
// surface looked like a settled camera, and the view was turned down under
// the user's hand.
test('the recovery waits while a pointer is held on the globe', () => {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const document = new EventTarget();
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
document.dispatchEvent(new Event('pointerup'));
for (let i = 0; i < 3; i += 1) timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once the pointer is released',
);
dispose();
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
document.dispatchEvent(new Event('pointerup'));
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
});
/** A held-pointer recovery over a canvas in a document in a window. */
function heldPointerRecovery() {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const window = new EventTarget();
const document = Object.assign(new EventTarget(), {
defaultView: window,
visibilityState: 'visible',
});
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
const pointer = (target, type, pointerId, init = {}) =>
target.dispatchEvent(
Object.assign(new Event(type), { pointerId, ...init }),
);
/** Press, move and hold still: the check defers while a pointer is held. */
const holdStill = () => {
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
};
const settle = () => {
for (let i = 0; i < 3; i += 1) timers.tick();
};
const recovered = () =>
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
return {
camera,
document,
window,
dispose,
pointer,
holdStill,
settle,
recovered,
canvas: scene.canvas,
};
}
// Releasing one of two fingers cleared the held flag, and the recovery
// turned the view down under the finger still on the glass.
test('the recovery waits until every held pointer is released', () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.pointer(h.canvas, 'pointerdown', 2);
h.holdStill();
h.pointer(h.document, 'pointerup', 1);
h.settle();
assert.equal(h.recovered(), false, 'left alone under the second finger');
assert.equal(globeOutOfFrame(h.camera), true);
h.pointer(h.document, 'pointercancel', 2);
h.settle();
assert.equal(h.recovered(), true, 'recovered once both are released');
h.dispose();
});
// A release the page never sees (the pointer let go outside the window)
// must not hold the recovery off for good.
for (const [label, lose] of [
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
[
'the page is hidden',
(h) => {
h.document.visibilityState = 'hidden';
h.document.dispatchEvent(new Event('visibilitychange'));
},
],
[
'the canvas loses the pointer capture',
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
],
[
'the pointer moves with no button down',
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
],
]) {
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.holdStill();
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
h.settle();
assert.equal(h.recovered(), false, 'left alone while held');
lose(h);
h.settle();
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
h.dispose();
});
}
function fakeTimers() {
let next = 1;
const pending = new Map();
return {
setTimeout: (callback) => {
const id = next++;
pending.set(id, callback);
return id;
},
clearTimeout: (id) => pending.delete(id),
pending: () => pending.size,
/** Run the earliest pending callback, as 250 ms passing would. */
tick() {
const [id, callback] = pending.entries().next().value ?? [];
if (id === undefined) return false;
pending.delete(id);
callback();
return true;
},
};
}
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
// At this pose the camera can drift by a rounding error every frame, so
// Cesium never sees it stop and never raises moveEnd for it.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
// A rounding-error drift between samples still counts as at rest.
camera.lookUp(1e-15);
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Under that drift Cesium raises no new moveStart, so a later move that
// ends looking away is caught by the same check.
aim(camera, reportedStuckView);
timers.tick();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
camera.moveEnd.raiseEvent();
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
});
test('the settle check follows a long move and gives up five minutes after the last one', () => {
// A drag of half a minute that stops at a stuck pose is still recovered.
const long = fakeTimers();
const dragged = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(dragged.viewer, long);
aim(dragged.camera, reportedStuckView);
dragged.camera.moveStart.raiseEvent();
for (let i = 0; i < 120; i += 1) {
dragged.camera.moveLeft(0.01);
long.tick();
}
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
while (long.tick());
assert.ok(
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
// A camera that keeps moving is followed, and left alone, for as long as it
// moves; once it rests the check gives up five minutes later.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 2000; i += 1) {
camera.moveLeft(0.01);
timers.tick();
}
assert.equal(timers.pending(), 1, 'still following after eight minutes');
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
let idleSamples = 0;
while (timers.tick()) idleSamples += 1;
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
assert.ok(
idleSamples >= 1200 && idleSamples <= 1205,
`gave up ${idleSamples} samples after it stopped`,
);
const disposed = fakeTimers();
const other = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
aim(other.camera, reportedStuckView);
other.camera.moveStart.raiseEvent();
dispose();
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
});
+2
View File
@@ -2,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import {
createApplicationViewer,
installFreeLookHeightLimit,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
@@ -58,6 +59,7 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(installFreeLookHeightLimit(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+15 -1
View File
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installScopeMask,
destroyScopeMask,
isScopeMaskEnabled,
} from '../scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import { installGlobeInViewRecovery } from './viewer.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
@@ -44,6 +50,14 @@ export function createApplicationTools({
.get('bhote-koshi-2026')
?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy());
defer(
installGlobeInViewRecovery(viewer, {
// The scope mask hides everything outside its keyhole.
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
// An authored scene frames its own shots, holds included.
paused: () => sceneDirector.running,
}),
);
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({
viewer,
+230
View File
@@ -1,6 +1,11 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeOutOfFrame,
isFreeGlobeCamera,
} from '../globeInView.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -103,6 +108,231 @@ export function installTrackpadPinchZoom(
};
}
/**
* Allow Cesium's free look only below `maxHeight` and return its disposer.
* Orbit, tilt and zoom stay available at every height. A value other code
* sets later is kept as the one to allow below that height, and restored on
* dispose; a `false` written while the camera is above `maxHeight` cannot be
* told apart from this limit's own write, so dispose restores the earlier
* value then. `enableLook` gates the Shift+drag binding only: below its trackball
* height Cesium also turns the camera in place for a drag that starts on the
* stars. That height is lowered to `maxHeight` too, so above it such a drag
* orbits the globe, as it does above Cesium's own 7,500 km.
*/
export function installFreeLookHeightLimit(
viewer,
maxHeight = FREE_LOOK_MAX_HEIGHT_METERS,
) {
const scene = viewer?.scene;
const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera;
if (!controller || !camera || !scene?.preUpdate)
throw new TypeError('A complete Cesium viewer is required');
const originalTrackBallHeight = controller.minimumTrackBallHeight;
const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight);
controller.minimumTrackBallHeight = trackBallHeight;
let wanted = controller.enableLook;
let written;
const sync = () => {
if (written !== undefined && controller.enableLook !== written)
wanted = controller.enableLook;
const height = camera.positionCartographic?.height;
written = wanted && !(Number.isFinite(height) && height > maxHeight);
controller.enableLook = written;
};
sync();
const removeListener = scene.preUpdate.addEventListener(sync);
let active = true;
return () => {
if (!active) return;
active = false;
removeListener();
if (controller.enableLook === written) controller.enableLook = wanted;
if (controller.minimumTrackBallHeight === trackBallHeight)
controller.minimumTrackBallHeight = originalTrackBallHeight;
};
}
// How often a moving camera is sampled to see whether it has come to rest, how
// far a pose may drift between samples and still count as at rest, and how
// long after the camera last moved the check gives up if Cesium never raises
// moveEnd.
const SETTLE_SAMPLE_MS = 250;
const SETTLE_POSITION_TOLERANCE_M = 1e-3;
const SETTLE_DIRECTION_TOLERANCE = 1e-9;
const SETTLE_GIVE_UP_MS = 5 * 60_000;
/**
* Turn the camera back to the planet when a move ends above `minHeight` with
* no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about
* one sample cell thick can count as none), and return the disposer. Views with
* more surface, such as an oblique orbital shot, are left as they are. This
* catches a saved or shared view that looked away, or any other move that ends
* looking away from the planet.
* Heading and position are kept. Tracked cameras, locked ones (non-identity
* transform), a camera flight in progress, a pointer held down on the canvas
* (a drag held still), the 2D and Columbus modes, and any time `paused`
* returns true (an authored scene playing) are left alone; a check they defer
* runs once they end, however long they last.
* `keyholeRadius`
* returns the radius of the visible circle when a scope mask hides the rest of
* the frame (see `globeFrameShare`), or 0; it is read at every check.
*
* Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the
* last frame's to the last rounding error, and at some of these poses the
* camera drifts by a rounding error every frame, so Cesium never sees it stop.
* So each `moveStart` also starts a check of its own that samples the pose
* every 250 ms, with a tolerance for that drift, and once the pose has held
* for half a second after moving runs the same recovery. It keeps watching
* until Cesium raises `moveEnd`, since under that drift Cesium raises no new
* `moveStart` for a later move, and gives up five minutes after the camera
* last moved.
* `setTimeout` and `clearTimeout` can be injected for tests.
*/
export function installGlobeInViewRecovery(
viewer,
{
minHeight = FREE_LOOK_MAX_HEIGHT_METERS,
keyholeRadius = () => 0,
paused = () => false,
setTimeout: schedule = globalThis.setTimeout,
clearTimeout: unschedule = globalThis.clearTimeout,
} = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
if (!scene || !camera?.moveEnd || !camera?.moveStart)
throw new TypeError('A complete Cesium viewer is required');
// A pointer held on the globe is a gesture in progress, even when it holds
// still for half a second: the check waits until every pointer pressed on
// it (each finger of a pinch) is released. A release the page never sees,
// outside the window, is taken as given when the window loses focus, the
// page is hidden, the canvas loses the pointer's capture, or the pointer
// moves with no button down.
const canvas = scene.canvas;
const releaseTarget = canvas?.ownerDocument ?? canvas;
const releaseWindow = canvas?.ownerDocument?.defaultView;
const heldPointers = new Set();
const onPointerDown = (event) => heldPointers.add(event?.pointerId);
const onPointerUp = (event) => heldPointers.delete(event?.pointerId);
const onPointerMove = (event) => {
if (event?.buttons === 0) heldPointers.delete(event.pointerId);
};
const releaseAll = () => heldPointers.clear();
const onVisibilityChange = () => {
if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll();
};
const listeners = [
[canvas, 'pointerdown', onPointerDown],
[canvas, 'lostpointercapture', onPointerUp],
[releaseTarget, 'pointerup', onPointerUp],
[releaseTarget, 'pointercancel', onPointerUp],
[releaseTarget, 'pointermove', onPointerMove],
[canvas?.ownerDocument, 'visibilitychange', onVisibilityChange],
[releaseWindow, 'blur', releaseAll],
];
for (const [target, type, listener] of listeners)
target?.addEventListener?.(type, listener);
// Returns false while a flight, a paused scene or a held pointer defers the
// check, so the caller can try again once it ends.
const recover = () => {
if (!isFreeGlobeCamera(viewer)) return true;
// Cesium has no public running-flight flag.
if (camera._currentFlight || paused() || heldPointers.size > 0)
return false;
const height = camera.positionCartographic?.height;
if (
!(height > minHeight) ||
!globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() })
)
return true;
camera.setView({
orientation: {
heading: camera.heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
});
scene.requestRender?.();
return true;
};
const lastPosition = new Cesium.Cartesian3();
const lastDirection = new Cesium.Cartesian3();
let settleTimer = null;
let settleElapsed = 0;
let restingSamples = 0;
let movedSinceCheck = false;
const stopSettling = () => {
if (settleTimer !== null) unschedule(settleTimer);
settleTimer = null;
};
const sample = () => {
settleTimer = null;
settleElapsed += SETTLE_SAMPLE_MS;
const atRest =
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
lastPosition,
0,
SETTLE_POSITION_TOLERANCE_M,
) &&
Cesium.Cartesian3.equalsEpsilon(
camera.directionWC,
lastDirection,
0,
SETTLE_DIRECTION_TOLERANCE,
);
restingSamples = atRest ? restingSamples + 1 : 0;
if (!atRest) {
movedSinceCheck = true;
settleElapsed = 0;
}
// Two quiet samples in a row after a move: at rest for half a second, as
// for moveEnd.
if (restingSamples >= 2 && movedSinceCheck) {
if (recover()) movedSinceCheck = false;
// A flight or a playing scene bounds its own deferral.
else settleElapsed = 0;
}
if (settleElapsed >= SETTLE_GIVE_UP_MS) return;
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
};
const startSettling = () => {
stopSettling();
settleElapsed = 0;
restingSamples = 0;
movedSinceCheck = true;
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
};
const removeMoveEnd = camera.moveEnd.addEventListener(() => {
if (recover()) stopSettling();
// Deferred: keep sampling so the check runs once the flight or scene ends.
else if (settleTimer === null) startSettling();
});
const removeMoveStart = camera.moveStart.addEventListener(startSettling);
let active = true;
return () => {
if (!active) return;
active = false;
stopSettling();
removeMoveEnd();
removeMoveStart();
for (const [target, type, listener] of listeners)
target?.removeEventListener?.(type, listener);
heldPointers.clear();
};
}
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
+669
View File
@@ -13,8 +13,16 @@ import {
moveCamera,
} from './cameraVerbs.js';
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
import {
MIN_GLOBE_FRAME_SHARE,
globeFrameShare,
globeOutOfFrame,
} from './globeInView.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { setScopeMaskEnabled } from './scopeMask.js';
const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
const clone = (value) => Cesium.Cartesian3.clone(value);
const longitude = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
@@ -1347,3 +1355,664 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer
samePose(h.camera, before);
assert.equal(hasRenderHold(), false);
});
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface. With
* `keyhole`, only the pixels inside the scope's visible circle count.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
/** Run a test with the scope mask on or off, restoring the default after. */
function withScope(t, enabled) {
setScopeMaskEnabled(enabled);
t.after(() => setScopeMaskEnabled(true));
}
// The frame limit is measured on a coarser grid than the oracle.
const SHARE_SLACK = 0.02;
// Far-out poses, height in metres and pitch in degrees, where a few tilts up
// are accepted before the limit refuses the next one.
const FAR_OUT_POSES = [
[35_884_000, -86.26],
[35_884_000, -89],
[30_000_000, -86],
[25_000_000, -80],
[40_000_000, -88],
];
test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => {
withScope(t, false); // the whole frame is visible
// Slightly off nadir: from straight down a tilt is clamped before it moves.
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
// From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down
// to about -67°: the first tilt stops there, and later ones are refused.
assert.deepEqual(
results.map((result) => result.ok),
[true, false, false, false],
);
assert.match(results[1].error, /globe out of view/);
assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66));
});
// The frame limit judges a tilt by the way its pitch moves, not by the
// direction word: a tilt down or to a steeper angle turns toward the planet.
for (const args of [{ direction: 'down' }, { pitchDeg: -80 }]) {
test(`a tilt ${JSON.stringify(args)} from the frame limit is judged as the look down it is`, (t) => {
withScope(t, false);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
finishOnce();
const atLimit = camera.pitch;
const result = moveCamera({ motion: 'tilt', ...args });
assert.equal(result.ok, true, result.error);
finishOnce();
assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down');
});
}
for (const args of [{ pitchDeg: -10 }, {}]) {
test(`a tilt ${JSON.stringify(args)} up from the frame limit is refused, not reported as done`, (t) => {
withScope(t, false);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
finishOnce();
const atLimit = camera.pitch;
// A bare tilt means -45°: up from the -67° limit.
const result = moveCamera({ motion: 'tilt', ...args });
assert.equal(result.ok, false);
assert.match(result.error, /globe out of view/);
assert.equal(camera.pitch, atLimit);
});
}
// A tilt to an angle, or a bare tilt (-45°), from orbit turns in place like
// a nudge and must keep the planet in frame the whole way, or the globe
// recovery would snap the view back down.
for (const [label, args, options] of [
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
[
'a continuous tilt up',
{ motion: 'tilt', direction: 'up', mode: 'continuous' },
{ pitch: -90 },
],
[
'a tilt to -5° from 2,000 km',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -90, height: 2_000_000 },
],
]) {
test(`${label} from top-down never turns the planet out of frame`, (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
...options,
viewportWidth: 1600,
viewportHeight: 900,
});
const startPitch = camera.pitch;
const result = moveCamera(args);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
surfacePixelShare(camera, 1600, 900) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`frame ${i}: the planet left the frame`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
assert.equal(globeOutOfFrame(camera), false, 'no snap-back');
if (result.ok) assert.ok(camera.pitch > startPitch, 'the tilt moved');
});
}
// The frame limit stops a tilt to an angle short of it: the reply names the
// angle the camera reaches, not the one asked for (a tilt to -10° from
// top-down reported -10° and ended at -66.5°).
for (const [label, args, options] of [
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
[
'a tilt to -10° from -80°',
{ motion: 'tilt', pitchDeg: -10 },
{ pitch: -80 },
],
[
'a tilt to -5° from 2,000 km',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -90, height: 2_000_000 },
],
// A rolled camera's look turns its pitch by less than the look angle:
// reported -25°, ended at -27.3°.
[
'a tilt to -5° from 2,000 km rolled 30°',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -70, roll: 30, height: 2_000_000 },
],
]) {
test(`${label} from orbit reports the angle it reaches`, (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
...options,
viewportWidth: 1600,
viewportHeight: 900,
});
const result = moveCamera(args);
assert.equal(result.ok, true, result.error);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const reached = degrees(camera.pitch);
assert.ok(
Math.abs(reached - result.pitchDeg) < 1,
`reported ${result.pitchDeg}°, reached ${reached}°`,
);
const asked = args.pitchDeg ?? -45;
if (Math.abs(reached - asked) >= 1)
assert.match(result.note ?? '', /globe out of view/);
else assert.equal(result.note, undefined);
});
}
// A continuous tilt promises no angle: it runs until stopped or the frame
// limit ends it.
test('a continuous tilt up from orbit claims no angle it may not reach', (t) => {
withScope(t, false);
const { tick } = harness(t, {
pitch: -90,
viewportWidth: 1600,
viewportHeight: 900,
});
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'continuous',
});
assert.equal(result.ok, true, result.error);
assert.equal('pitchDeg' in result, false);
assert.equal(result.mode, 'continuous');
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the frame limit ends it');
});
test('voice turn in place from orbit stops with the planet still on screen', (t) => {
withScope(t, false); // the whole frame is visible
const { camera, tick } = harness(t, { pitch: -75 });
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
const heading = camera.heading;
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
surfacePixelShare(camera, 1280, 800) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.notEqual(camera.heading, heading, 'the turn moved before stopping');
});
test('a long voice turn below the limit keeps the share it started with', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet: the limit is that share, less two
// grid cells of sampling noise, for the whole motion rather than per step.
const { camera, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('repeated voice turns from one spot cannot each take a little more', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet. Every turn from this position is
// measured against the same baseline, so asking again stops being allowed.
const { camera, finishOnce, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = start - 2 / (32 * 18) - 1e-9;
const results = [];
for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) {
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({ motion: 'rotate', direction: 'left', mode });
results.push(result.ok);
if (!result.ok) continue;
if (mode === 'once') finishOnce();
else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`);
}
}
assert.ok(results.includes(false), 'a turn at the limit is refused');
assert.equal(results.at(-1), false);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('a voice turn after something else turned the camera starts over', (t) => {
withScope(t, false); // the whole frame is visible
// The same spot, first framed straight down, then turned to about 2% of the
// frame by something other than voice: a turn left is measured from there.
const { camera, finishOnce } = harness(t, {
pitch: -90,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
camera.setView({
orientation: { heading: 0, pitch: radians(-60), roll: 0 },
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
});
test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
withScope(t, false); // the whole frame is visible
// A wheel notch or an orbit drag between requests starts each turn afresh;
// the 1% floor still holds, and below it a turn may only keep what it has.
for (const [nudge, pitch] of [
[(camera) => camera.moveBackward(2), -60],
[(camera) => camera.rotateRight(radians(0.2)), -60],
[(camera) => camera.moveBackward(2), -58],
]) {
const { camera, tick } = harness(t, {
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = Math.min(0.01, start) - 1e-9;
for (let i = 0; i < 12; i += 1) {
nudge(camera);
if (
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok
)
for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(
globeFrameShare(camera) >= floor,
`start ${start}, turn ${i + 1}`,
);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
}
});
test('after the frame changes size, a voice turn toward the planet is allowed', (t) => {
withScope(t, false); // the whole frame is visible
// A tilt stops at its limit; then the window widens at the same pose, so the
// frame shows less than the carried limit. Toward the planet must still go.
const { camera, canvas, viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200;
camera.frustum.aspectRatio = 3200 / 900;
const narrowed = globeFrameShare(camera);
assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`);
const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' });
assert.equal(down.ok, true, down.error);
finishOnce();
assert.ok(globeFrameShare(camera) > narrowed);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok,
true,
);
finishOnce();
const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
if (up.ok) finishOnce();
assert.ok(surfacePixelShare(camera, 3200, 900) > 0);
});
test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => {
withScope(t, true);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result.ok);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
assert.equal(results[0], true);
assert.equal(results.at(-1), false, 'a tilt past the limit is refused');
});
test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => {
// An oblique orbit view: turning in place swings the planet toward a corner,
// where the full frame still shows it but the scope mask hides it.
for (const [height, pitch] of [
[5_000_000, -70],
[3_000_000, -75],
[18_000_000, -80],
]) {
withScope(t, true);
const { camera, tick } = harness(t, {
height,
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok,
true,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`${height / 1000} km at ${pitch}°`,
);
}
});
test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => {
// A small planet disc: its sampled share wobbles as it crosses the grid.
// Whatever the limit decides, "done" must mean the camera turned, and some
// of these tilts must be accepted for that to be checked at all.
let accepted = 0;
for (const [height, pitch] of FAR_OUT_POSES) {
withScope(t, true);
const { camera, tick } = harness(t, {
latitude: 45.37,
longitudeDeg: -33.33,
height,
heading: 89.9,
pitch,
viewportWidth: 1280,
viewportHeight: 1024,
});
for (let i = 0; i < 4; i += 1) {
const before = camera.pitch;
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /zoom in first/);
continue;
}
accepted += 1;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
assert.ok(
camera.pitch - before > radians(0.5) - 1e-9,
`${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`,
);
}
assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0);
}
assert.ok(accepted > 0, 'no far-out tilt was accepted');
});
test('a voice turn reported as done turns at least the half degree its check proved', (t) => {
// A grazing sliver: the first frame's small step lands in a dip of the
// sampled share, which used to stop the turn before it moved at all.
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera({
motion: 'rotate',
direction: 'left',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /globe out of view/);
continue;
}
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(
turned >= radians(0.5) - 1e-9,
`scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`,
);
assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0);
}
});
/** Track an entity below `camera`, locking it into that entity's frame. */
function trackFrom(viewer, camera) {
viewer.trackedEntity = { id: 'high-satellite' };
const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000);
camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below));
}
test('a voice turn that releases a tracked camera is checked as released', (t) => {
// Voice releases the follow camera inside the navigation that runs the
// motion, so the request is judged by the pose the release will leave.
let accepted = 0;
for (const [scope, direction, motion] of [
[true, 'up', 'tilt'],
[false, 'up', 'tilt'],
[false, 'left', 'rotate'],
]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
trackFrom(viewer, camera);
const releaseThenRun = (start) => {
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
};
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera(
{ motion, direction, mode: 'once' },
releaseThenRun,
);
const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`;
if (!result.ok) {
assert.match(result.error, /globe out of view/, label);
continue;
}
accepted += 1;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`);
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0,
`${label}: the planet stays on screen`,
);
}
assert.ok(accepted > 0, 'no released turn was accepted');
});
test('a refused voice turn leaves a tracked camera tracking', (t) => {
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit untracked, then track from there.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
trackFrom(viewer, camera);
const tracked = viewer.trackedEntity;
const transform = Cesium.Matrix4.clone(camera.transform);
let released = false;
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
released = true;
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
},
);
const label = `scope ${scope ? 'on' : 'off'}`;
assert.equal(result.ok, false, label);
assert.match(result.error, /globe out of view/, label);
assert.equal(released, false, label);
assert.equal(viewer.trackedEntity, tracked, label);
assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label);
}
});
test('a turn proven at one position is checked again after a release moves the camera', (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit, then check from 4,000 km closer along the same view
// direction, where the next tilt fits; the release moves the camera back.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
camera.moveForward(4_000_000);
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
camera.moveBackward(4_000_000);
return start();
},
);
assert.equal(result.ok, false);
assert.match(result.error, /globe out of view/);
});
test('the voice frame limit leaves Columbus view alone', (t) => {
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
// view is not limited by the frame.
withScope(t, false);
const { viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.match(refused.error || '', /globe out of view/);
viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.doesNotMatch(result.error || '', /globe out of view/);
});
test('voice tilt below the free-look height is not limited by the frame', (t) => {
const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 });
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.equal(result.ok, true);
finishOnce();
near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt');
});
+251 -16
View File
@@ -20,11 +20,19 @@
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
globeFrameShare,
isFreeGlobeCamera,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from './globeInView.js';
import {
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
KEYHOLE_OUTER_RADIUS,
} from './celestialRing.js';
import { isScopeMaskEnabled } from './scopeMask.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -1046,6 +1054,187 @@ export function interruptCameraMotionIfActive(
return interruptCameraMotion(reason);
}
// The frame share that in-place voice turns measure their limit against, with
// the pose the last voice turn left the camera in. A turn that starts where
// the last one left off keeps that baseline (raised if the planet now fills
// more of the frame), so repeating a request cannot take a little more each
// time. Once anything else has moved or turned the camera, a turn starts over
// from what is on screen.
let _lookBaseline = null;
// The pre-check's proof that a half-degree turn from this pose keeps the planet
// in view: which way, from which position and direction. A motion that starts from that
// pose turns at least that far, so a request reported as done really moves
// even when a smaller first step lands in a dip of the sampled share.
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
let _provenLook = null;
/** The part of the frame a viewer can see: the scope keyhole when it is on. */
function visibleFrame() {
return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 };
}
/** The frame share an in-place turn from `cam`'s pose is limited against. */
function lookBaselineShare(cam) {
const share = globeFrameShare(cam, visibleFrame());
const continues =
_lookBaseline &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.direction,
cam.directionWC,
0,
1e-9,
);
_lookBaseline = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
share: continues ? Math.max(_lookBaseline.share, share) : share,
};
return _lookBaseline.share;
}
/**
* One in-place look step of motion `m`. From orbit a free globe camera turns
* only as far as keeps the planet in frame, measured against the baseline the
* turn started from, then the motion ends there.
*/
function lookWithinFrame(cam, m, look, angle) {
let allowed = angle;
if (isFreeGlobeCamera(_viewer)) {
if (m.globeShareAtStart === undefined) {
m.globeShareAtStart = lookBaselineShare(cam);
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
m.turnedRad = 0;
}
_provenLook = null;
allowed = lookAngleWithinFrame(cam, look, angle, {
...visibleFrame(),
startShare: m.globeShareAtStart,
});
const owed = Math.min(angle, m.provenTurnRad - m.turnedRad);
if (allowed < owed) allowed = owed;
m.turnedRad += allowed;
}
if (allowed > 0) cam[look](allowed);
// The pose this turn leaves, for a turn that continues from it.
if (_lookBaseline)
Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction);
if (allowed < angle) interruptCameraMotion('globe-out-of-frame');
}
/**
* The in-place look a pitch change from `fromPitch` to `goalPitch` makes:
* 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by
* the way its pitch moves, whatever form or direction word it came in.
*/
function tiltLook(fromPitch, goalPitch) {
if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null;
if (goalPitch > fromPitch) return 'lookUp';
return goalPitch < fromPitch ? 'lookDown' : null;
}
/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */
function tiltInPlaceWithinFrame(cam, m, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch));
}
/**
* The pitch an in-place tilt from `cam`'s pose toward `goalPitch` reaches
* before the frame limit stops it, judged as its steps will be: `goalPitch`
* itself when the planet stays in frame the whole way, or the camera is not a
* free globe camera. The steps run on a copy of the camera, each looking by
* the pitch still to go as the tilt's frames do, because a rolled camera's
* look turns its pitch by less than the look angle.
*/
function tiltPitchWithinFrame(cam, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (!look || !isFreeGlobeCamera(_viewer)) return goalPitch;
const frame = { ...visibleFrame(), startShare: lookBaselineShare(cam) };
// A proven half degree is turned whatever the sampled share says.
let owed = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
const probe = Cesium.Camera.clone(cam);
for (let i = 0; i < 32; i += 1) {
if (tiltLook(probe.pitch, goalPitch) !== look) return goalPitch;
const angle = Math.abs(goalPitch - probe.pitch);
if (angle < 1e-6) return goalPitch;
const allowed = Math.max(
lookAngleWithinFrame(probe, look, angle, frame),
Math.min(angle, owed),
);
owed -= allowed;
if (allowed > 0) probe[look](allowed);
if (allowed < angle) return probe.pitch;
}
return probe.pitch;
}
/** The reply's note for a tilt the frame limit stops short of its angle. */
function tiltFrameNote(pitch) {
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this height a shallower angle would turn the globe out of view. Zoom in to tilt further.`;
}
/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */
function lookProven(cam, look) {
return (
_provenLook?.look === look &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.direction,
cam.directionWC,
0,
1e-9,
)
);
}
/**
* The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft'
* or 'lookRight') must pass from orbit: refused when even half a degree would
* cross the frame limit, otherwise proven for that half degree from this pose.
* With `released`, the turn runs through a navigation that first releases a
* tracked or locked camera and keeps its world pose. The frame measure and the
* turn are the same in any reference frame, so such a camera is judged as it
* is, before the release, and a refusal leaves tracking as it was. Otherwise a
* camera that is not a free globe camera is not checked. Returns the refusal,
* or null.
*/
function checkLookFromOrbit(look, { released = false } = {}) {
_provenLook = null;
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
const cam = _viewer.camera;
if (!released && !isFreeGlobeCamera(_viewer)) return null;
if (
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
...visibleFrame(),
startShare: lookBaselineShare(cam),
})
)
return {
ok: false,
action: 'move_camera',
error:
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
};
_provenLook = {
look,
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
};
return null;
}
function clearLookAt() {
try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
@@ -1226,12 +1415,7 @@ function onTick() {
// Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
interruptCameraMotion('globe-camera-owner-changed');
return;
}
@@ -1253,8 +1437,14 @@ function onTick() {
return;
}
cam.rotate(m.rotationAxis, angle);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
} else {
lookWithinFrame(
cam,
m,
m.direction === 'left' ? 'lookLeft' : 'lookRight',
stepRad,
);
}
} else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before.
@@ -1339,7 +1529,7 @@ function tickTilt(cam, m, scale, dt) {
}
}
}
if (!m.hpr) cam.lookUp(limited - cam.pitch);
if (!m.hpr) tiltInPlaceWithinFrame(cam, m, limited);
if (m.mode !== 'once' && (limited !== want || atLimit))
interruptCameraMotion('tilt-clamp');
}
@@ -1618,6 +1808,7 @@ export function initCameraVerbs(
_isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer;
_lookBaseline = null;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover();
@@ -1831,6 +2022,34 @@ export function moveCamera(args = {}, runNavigation = null) {
};
}
}
// A tilt is judged by the way its pitch will move: toward its angle, or a
// nudge up or down; a rotate turns left only when told to, as it runs.
const look =
motion === 'tilt'
? tiltLook(
_viewer.camera.pitch,
tiltToPitch ??
_viewer.camera.pitch +
Cesium.Math.toRadians(ONCE.tiltDeg) *
(direction === 'up' ? 1 : -1),
)
: motion === 'rotate'
? direction === 'left'
? 'lookLeft'
: 'lookRight'
: null;
// Honest limits from orbit too: a stopped turn rests within 0.05° of its
// limit, so one that cannot go half a degree further is refused rather than
// run and reported as done, and one that can is then turned at least that
// far. Near the limit the sampled share wobbles as the planet's edge crosses
// the grid, so a turn that would keep the planet can occasionally be refused
// too.
// A camera that is tracked or locked now is judged as the navigation that
// runs the turn will release it, so a refusal keeps the tracking.
const lookRefusal =
look &&
checkLookFromOrbit(look, { released: typeof runNavigation === 'function' });
if (lookRefusal) return lookRefusal;
if (
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
@@ -1914,9 +2133,17 @@ export function moveCamera(args = {}, runNavigation = null) {
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
};
}
let horizonLimited = null;
// The angle a tilt to an angle stops at short of it, and why.
let stoppedAt = null;
let stopNote = null;
const start = () => {
interruptCameraMotion('replaced');
// Checked again only if the release left the camera somewhere other than
// where the check above expected it.
if (look && !lookProven(_viewer.camera, look)) {
const refusal = checkLookFromOrbit(look);
if (refusal) return refusal;
}
const state = {
kind: motion,
direction: direction || 'right',
@@ -1935,10 +2162,20 @@ export function moveCamera(args = {}, runNavigation = null) {
if (tiltToPitch !== null) {
// From far out the place reaches its horizon before the camera
// reaches the asked angle: stop there, and say so.
const goal =
let goal =
tiltToPitch > from
? Math.min(tiltToPitch, Math.max(from, upLimit))
: tiltToPitch;
if (goal !== tiltToPitch) stopNote = tiltHorizonNote(goal);
// From orbit an in-place tilt stops where the planet would leave the
// frame: aim it there, and say so.
if (!pivot) {
const framed = tiltPitchWithinFrame(_viewer.camera, goal);
if (framed !== goal) {
goal = framed;
stopNote = tiltFrameNote(goal);
}
}
state.mode = 'once';
state.direction = goal > from ? 'up' : 'down';
state.goalPitch = goal;
@@ -1949,7 +2186,7 @@ export function moveCamera(args = {}, runNavigation = null) {
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
);
if (goal !== tiltToPitch) horizonLimited = goal;
if (goal !== tiltToPitch) stoppedAt = goal;
} else {
// A nudge stops at the limit it travels toward.
const step =
@@ -2048,11 +2285,9 @@ export function moveCamera(args = {}, runNavigation = null) {
speed,
mode: state.mode,
...(tiltToPitch !== null && {
pitchDeg: Math.round(
Cesium.Math.toDegrees(horizonLimited ?? tiltToPitch),
),
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
}),
...(horizonLimited !== null && { note: tiltHorizonNote(horizonLimited) }),
...(stopNote !== null && { note: stopNote }),
};
};
const preserveCameraFlight =
+179
View File
@@ -0,0 +1,179 @@
import * as Cesium from 'cesium';
// Cesium's free look (Shift+drag) turns the camera in place. Near the ground
// that surveys a city; from orbit a few drags turn the whole planet out of
// frame and leave only stars.
export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000;
// A voice turn from orbit stops when about this share of the frame still shows
// the planet's surface.
export const MIN_GLOBE_FRAME_SHARE = 0.1;
/**
* A free globe camera: the viewer is in 3D, follows no tracked entity, and its
* camera is not locked to a reference frame.
*/
export function isFreeGlobeCamera(viewer) {
return (
viewer.scene.mode === Cesium.SceneMode.SCENE3D &&
!viewer.trackedEntity &&
Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY)
);
}
const GRID_COLUMNS = 32;
const GRID_ROWS = 18;
// A turn across a sliver of planet moves its edge through sample points, so
// the share wobbles by a cell or two without the planet leaving.
const GRID_NOISE_CELLS = 2;
// Whatever the baseline, a voice turn never takes the planet below this share
// of the frame; a view that already shows less may only keep what it has.
const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01;
const sampleRay = new Cesium.Ray();
const sampleOffset = new Cesium.Cartesian3();
/**
* Cast a 32×18 grid of view rays, one through the centre of each cell, at the
* WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in
* units of half the frame height) only the cells inside that circle count: the
* scope mask hides the rest. Returns the surface hits and the cells counted, or
* null for a frustum other than a perspective one.
*/
function sampleFrame(camera, keyholeRadius) {
const frustum = camera.frustum;
if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null;
const aspect = frustum.aspectRatio;
const tanY = Math.tan(frustum.fovy / 2);
const tanX = tanY * aspect;
const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0;
Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin);
let hits = 0;
let cells = 0;
for (let row = 0; row < GRID_ROWS; row += 1) {
const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1;
for (let column = 0; column < GRID_COLUMNS; column += 1) {
const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1;
if (
keyholeSquared &&
(columnX * aspect) ** 2 + rowY * rowY > keyholeSquared
)
continue;
cells += 1;
Cesium.Cartesian3.multiplyByScalar(
camera.rightWC,
columnX * tanX,
sampleOffset,
);
Cesium.Cartesian3.add(
camera.directionWC,
sampleOffset,
sampleRay.direction,
);
Cesium.Cartesian3.multiplyByScalar(
camera.upWC,
rowY * tanY,
sampleOffset,
);
Cesium.Cartesian3.add(
sampleRay.direction,
sampleOffset,
sampleRay.direction,
);
Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction);
if (
Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84)
)
hits += 1;
}
}
return { hits, cells };
}
/**
* Share of the frame, from 0 to 1, that shows the planet's surface: the
* fraction of a 32×18 grid of view rays, one through the centre of each cell,
* that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is
* the share of the visible circle instead. A frustum other than a perspective
* one counts as fully covered.
*/
export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) {
const sample = sampleFrame(camera, keyholeRadius);
if (!sample || sample.cells === 0) return 1;
return sample.hits / sample.cells;
}
/** True when none of the planet's surface is in `camera`'s visible frame. */
export function globeOutOfFrame(camera, options) {
return globeFrameShare(camera, options) === 0;
}
const lookProbes = new WeakMap();
const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05);
/**
* How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or
* `lookRight` by a non-negative `angle` in radians) keeps the planet in frame
* from orbit. Above the free-look height the step may not leave less than
* `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than
* `startShare` when that is lower, give or take two grid cells of sampling
* noise. However the baseline was taken, it never leaves less than 1% of the
* frame, and a view that already shows less may only keep or gain what it has.
* `startShare` is the baseline the turn is measured against (the current share
* by default): callers pass one value to every step of a motion, and to every
* turn that continues from where the last one left off, so the limit cannot
* slide down a step or a command at a time. `keyholeRadius` limits the measure
* to the scope's visible circle, as in `globeFrameShare`.
* With nothing on screen the limit is nothing either, so the step is left to
* the move-end recovery. Returns `angle` when the whole step fits, otherwise a
* smaller angle that fits, found by bisection to within 0.05°, so a stopped
* motion rests at the limit. Steps are tried on a copy; `camera` is not moved.
*/
export function lookAngleWithinFrame(
camera,
look,
angle,
{ startShare, keyholeRadius = 0 } = {},
) {
if (
!(angle > 0) ||
!(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS)
)
return angle;
const sample = sampleFrame(camera, keyholeRadius);
if (!sample || sample.cells === 0) return angle;
const share = sample.hits / sample.cells;
const baseline = startShare ?? share;
// Capped at what is on screen now, so a step that keeps or gains surface is
// never refused, even when the frame has shrunk since the baseline was taken
// (a resized window or a turned phone).
const floor = Math.min(
share,
Math.max(
Math.min(MIN_GLOBE_FRAME_SHARE, baseline) -
GRID_NOISE_CELLS / sample.cells,
Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline),
),
);
const fits = (step) => {
const probe = Cesium.Camera.clone(camera, lookProbes.get(camera));
lookProbes.set(camera, probe);
probe[look](step);
// Shares are whole grid cells; the epsilon keeps rounding in `floor` from
// costing a cell.
return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9;
};
if (fits(angle)) return angle;
let inside = 0;
let outside = angle;
while (outside - inside > LOOK_EDGE_TOLERANCE) {
const middle = (inside + outside) / 2;
if (fits(middle)) inside = middle;
else outside = middle;
}
return inside;
}
/** True when an in-place look step of `angle` would lose part of its turn. */
export function lookStepLosesGlobe(camera, look, angle, options) {
return lookAngleWithinFrame(camera, look, angle, options) < angle;
}