mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge branch 'main' into pr/wildlife-sightings
This commit is contained in:
10 files changed
+2078
-18
No files matched your search
@@ -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).
|
||||||
@@ -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
|
`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.
|
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
|
Analyst region queries resolve unique Natural Earth names offline without
|
||||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||||
Duplicate names require a geocoded containment anchor; when several entries for
|
Duplicate names require a geocoded containment anchor; when several entries for
|
||||||
|
|||||||
@@ -164,6 +164,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/app/viewer.js",
|
"src/app/viewer.js",
|
||||||
"src/app/atmosphereCompat.js",
|
"src/app/atmosphereCompat.js",
|
||||||
|
"src/globeInView.js",
|
||||||
"src/sky/starrySky.js",
|
"src/sky/starrySky.js",
|
||||||
"src/sky/stars.js",
|
"src/sky/stars.js",
|
||||||
"src/data/bundledJson.js",
|
"src/data/bundledJson.js",
|
||||||
@@ -2363,6 +2364,7 @@
|
|||||||
"src/camera.js",
|
"src/camera.js",
|
||||||
"src/cameraGroundGuard.js",
|
"src/cameraGroundGuard.js",
|
||||||
"src/cameraVerbs.js",
|
"src/cameraVerbs.js",
|
||||||
|
"src/globeInView.js",
|
||||||
"src/cctvFocusPolicy.js",
|
"src/cctvFocusPolicy.js",
|
||||||
"src/cctvFocusRequest.js",
|
"src/cctvFocusRequest.js",
|
||||||
"src/celestialRing.js",
|
"src/celestialRing.js",
|
||||||
@@ -3719,6 +3721,9 @@
|
|||||||
"src/annotations/worldAnnotationRenderer.js",
|
"src/annotations/worldAnnotationRenderer.js",
|
||||||
"src/cameraGroundGuard.js",
|
"src/cameraGroundGuard.js",
|
||||||
"src/cameraVerbs.js",
|
"src/cameraVerbs.js",
|
||||||
|
"src/globeInView.js",
|
||||||
|
"src/scopeMaskPolicy.js",
|
||||||
|
"src/scopeMask.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
|
|||||||
@@ -2,7 +2,20 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { createApplication } from './application.js';
|
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'];
|
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||||
function fixture(overrides = {}) {
|
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(event.propagationStopped, false);
|
||||||
assert.equal(f.relayed.length, 0);
|
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,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
createApplicationViewer,
|
createApplicationViewer,
|
||||||
|
installFreeLookHeightLimit,
|
||||||
installTrackpadPinchZoom,
|
installTrackpadPinchZoom,
|
||||||
} from '../app/viewer.js';
|
} from '../app/viewer.js';
|
||||||
import { registerDataCredits } from '../data/dataCredits.js';
|
import { registerDataCredits } from '../data/dataCredits.js';
|
||||||
@@ -58,6 +59,7 @@ export async function createApplicationScene({
|
|||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
if (!viewer.isDestroyed()) viewer.destroy();
|
||||||
});
|
});
|
||||||
defer(installTrackpadPinchZoom(viewer));
|
defer(installTrackpadPinchZoom(viewer));
|
||||||
|
defer(installFreeLookHeightLimit(viewer));
|
||||||
registerDataCredits(viewer, credits);
|
registerDataCredits(viewer, credits);
|
||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||||
|
|||||||
+15
-1
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
|||||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||||
import { installViews, isEmbeddedInline } from './embed.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 { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||||
import {
|
import {
|
||||||
installRenderGovernor,
|
installRenderGovernor,
|
||||||
@@ -44,6 +50,14 @@ export function createApplicationTools({
|
|||||||
.get('bhote-koshi-2026')
|
.get('bhote-koshi-2026')
|
||||||
?.module.attachSceneController(sceneDirector);
|
?.module.attachSceneController(sceneDirector);
|
||||||
defer(() => sceneDirector.destroy());
|
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);
|
onSceneDirector?.(sceneDirector);
|
||||||
const annotations = initAnnotations({
|
const annotations = initAnnotations({
|
||||||
viewer,
|
viewer,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||||
import { StarrySky } from '../sky/starrySky.js';
|
import { StarrySky } from '../sky/starrySky.js';
|
||||||
|
import {
|
||||||
|
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||||
|
globeOutOfFrame,
|
||||||
|
isFreeGlobeCamera,
|
||||||
|
} from '../globeInView.js';
|
||||||
|
|
||||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
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. */
|
/** Cesium.Viewer options of the standard globe viewer. */
|
||||||
export function applicationViewerOptions(creditContainer) {
|
export function applicationViewerOptions(creditContainer) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ import {
|
|||||||
moveCamera,
|
moveCamera,
|
||||||
} from './cameraVerbs.js';
|
} from './cameraVerbs.js';
|
||||||
import { getRenderGovernorDiagnostics } from './renderGovernor.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 radians = Cesium.Math.toRadians;
|
||||||
|
const degrees = Cesium.Math.toDegrees;
|
||||||
const clone = (value) => Cesium.Cartesian3.clone(value);
|
const clone = (value) => Cesium.Cartesian3.clone(value);
|
||||||
const longitude = (camera) =>
|
const longitude = (camera) =>
|
||||||
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
|
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);
|
samePose(h.camera, before);
|
||||||
assert.equal(hasRenderHold(), false);
|
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
@@ -20,11 +20,19 @@
|
|||||||
|
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { GLOBE_VIEW } from './locations.js';
|
import { GLOBE_VIEW } from './locations.js';
|
||||||
|
import {
|
||||||
|
globeFrameShare,
|
||||||
|
isFreeGlobeCamera,
|
||||||
|
lookAngleWithinFrame,
|
||||||
|
lookStepLosesGlobe,
|
||||||
|
} from './globeInView.js';
|
||||||
import {
|
import {
|
||||||
projectEarthDiscToViewport,
|
projectEarthDiscToViewport,
|
||||||
earthDiscScreenRadius,
|
earthDiscScreenRadius,
|
||||||
getKeyholeGeometry,
|
getKeyholeGeometry,
|
||||||
|
KEYHOLE_OUTER_RADIUS,
|
||||||
} from './celestialRing.js';
|
} from './celestialRing.js';
|
||||||
|
import { isScopeMaskEnabled } from './scopeMask.js';
|
||||||
import {
|
import {
|
||||||
holdContinuousRender,
|
holdContinuousRender,
|
||||||
releaseContinuousRender,
|
releaseContinuousRender,
|
||||||
@@ -1046,6 +1054,187 @@ export function interruptCameraMotionIfActive(
|
|||||||
return interruptCameraMotion(reason);
|
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() {
|
function clearLookAt() {
|
||||||
try {
|
try {
|
||||||
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||||
@@ -1226,12 +1415,7 @@ function onTick() {
|
|||||||
// Tracking can acquire the camera without going through navigation.
|
// Tracking can acquire the camera without going through navigation.
|
||||||
// Yield without resetting its pose/frame: the world-space arc proof
|
// Yield without resetting its pose/frame: the world-space arc proof
|
||||||
// only describes Cesium's rotation while the frame is identity.
|
// only describes Cesium's rotation while the frame is identity.
|
||||||
if (
|
if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
|
||||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
|
||||||
_viewer.trackedEntity ||
|
|
||||||
_isCockpitActive?.() ||
|
|
||||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
|
||||||
) {
|
|
||||||
interruptCameraMotion('globe-camera-owner-changed');
|
interruptCameraMotion('globe-camera-owner-changed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1253,8 +1437,14 @@ function onTick() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cam.rotate(m.rotationAxis, angle);
|
cam.rotate(m.rotationAxis, angle);
|
||||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
} else {
|
||||||
else cam.lookRight(stepRad);
|
lookWithinFrame(
|
||||||
|
cam,
|
||||||
|
m,
|
||||||
|
m.direction === 'left' ? 'lookLeft' : 'lookRight',
|
||||||
|
stepRad,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (m.kind === 'route') {
|
} else if (m.kind === 'route') {
|
||||||
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
||||||
// applies the pose and honours the same completion path as before.
|
// 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))
|
if (m.mode !== 'once' && (limited !== want || atLimit))
|
||||||
interruptCameraMotion('tilt-clamp');
|
interruptCameraMotion('tilt-clamp');
|
||||||
}
|
}
|
||||||
@@ -1618,6 +1808,7 @@ export function initCameraVerbs(
|
|||||||
_isCockpitActive =
|
_isCockpitActive =
|
||||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||||
_viewer = viewer;
|
_viewer = viewer;
|
||||||
|
_lookBaseline = null;
|
||||||
if (_tickRemover) _tickRemover();
|
if (_tickRemover) _tickRemover();
|
||||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||||
if (_trackingRemover) _trackingRemover();
|
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 (
|
if (
|
||||||
(motion === 'rotate_globe' ||
|
(motion === 'rotate_globe' ||
|
||||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||||
@@ -1914,9 +2133,17 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
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 = () => {
|
const start = () => {
|
||||||
interruptCameraMotion('replaced');
|
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 = {
|
const state = {
|
||||||
kind: motion,
|
kind: motion,
|
||||||
direction: direction || 'right',
|
direction: direction || 'right',
|
||||||
@@ -1935,10 +2162,20 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
if (tiltToPitch !== null) {
|
if (tiltToPitch !== null) {
|
||||||
// From far out the place reaches its horizon before the camera
|
// From far out the place reaches its horizon before the camera
|
||||||
// reaches the asked angle: stop there, and say so.
|
// reaches the asked angle: stop there, and say so.
|
||||||
const goal =
|
let goal =
|
||||||
tiltToPitch > from
|
tiltToPitch > from
|
||||||
? Math.min(tiltToPitch, Math.max(from, upLimit))
|
? Math.min(tiltToPitch, Math.max(from, upLimit))
|
||||||
: tiltToPitch;
|
: 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.mode = 'once';
|
||||||
state.direction = goal > from ? 'up' : 'down';
|
state.direction = goal > from ? 'up' : 'down';
|
||||||
state.goalPitch = goal;
|
state.goalPitch = goal;
|
||||||
@@ -1949,7 +2186,7 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
ONCE.durationS,
|
ONCE.durationS,
|
||||||
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
||||||
);
|
);
|
||||||
if (goal !== tiltToPitch) horizonLimited = goal;
|
if (goal !== tiltToPitch) stoppedAt = goal;
|
||||||
} else {
|
} else {
|
||||||
// A nudge stops at the limit it travels toward.
|
// A nudge stops at the limit it travels toward.
|
||||||
const step =
|
const step =
|
||||||
@@ -2048,11 +2285,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
speed,
|
speed,
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
...(tiltToPitch !== null && {
|
...(tiltToPitch !== null && {
|
||||||
pitchDeg: Math.round(
|
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
|
||||||
Cesium.Math.toDegrees(horizonLimited ?? tiltToPitch),
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
...(horizonLimited !== null && { note: tiltHorizonNote(horizonLimited) }),
|
...(stopNote !== null && { note: stopNote }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const preserveCameraFlight =
|
const preserveCameraFlight =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user