mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix(camera): keep the globe in view from orbit
From the whole-globe view the camera could end up facing empty space with only stars on screen. Cesium's free look (Shift+drag) turns the camera in place, and from 18,000 km the planet's edge is only about 15 degrees from nadir, so a few drags turned it out of frame. The rolled pose was then saved into the share hash, so a reload or shared link opened on stars too. - Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged. - Measure how much of the frame shows the planet's surface by casting a 32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is on (the default) only the rays inside its keyhole count, since the mask hides or dims the rest of the frame. - When a camera move ends above 1,000 km with no ray on the surface, turn straight down at the same position and heading. Limb glow and a sliver up to about one grid cell thick can count as out of view. The end of a move comes from Cesium's moveEnd and from a check of its own that samples the pose every 250 ms after moveStart and acts once it has held for half a second: at some of these poses the camera drifts by a rounding error every frame, so Cesium never sees it stop and never raises moveEnd. The zoom-out guard from #1522 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, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface, tracked or locked cameras and the 2D and Columbus modes are left alone. - Voice tilt and in-place turns of a free 3D globe camera above 1,000 km stop when about a tenth of the frame still shows the planet (or about as much as it showed when it started, two grid cells of sampling noise allowed), never below about 1% of the visible frame; 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 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. A request made 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. The frame measure and the look-step limit live in src/globeInView.js, shared by the viewer and the voice camera verbs.
This commit is contained in:
1 parent
1facc09f8d
commit
99520b529c
8 files changed
+1491
-5
No files matched your search
@@ -65,6 +65,46 @@ 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. Orbit, tilt and
|
||||||
|
zoom are unchanged at every height. 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 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, or a drag
|
||||||
|
that Cesium turns into an in-place look below its 7,500 km trackball height (one
|
||||||
|
that starts on the stars, or a tilt drag with the screen centre off the planet).
|
||||||
|
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 and the 2D and
|
||||||
|
Columbus modes. 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
|
||||||
|
|||||||
@@ -158,6 +158,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",
|
||||||
@@ -2297,6 +2298,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",
|
||||||
@@ -3604,6 +3606,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,419 @@ 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 };
|
||||||
|
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('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,
|
||||||
|
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'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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,9 +2,13 @@ import { createApplicationOperations } from './operations.js';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
createApplicationViewer,
|
createApplicationViewer,
|
||||||
|
installFreeLookHeightLimit,
|
||||||
|
installGlobeInViewRecovery,
|
||||||
installTrackpadPinchZoom,
|
installTrackpadPinchZoom,
|
||||||
} from '../app/viewer.js';
|
} from '../app/viewer.js';
|
||||||
import { registerDataCredits } from '../data/dataCredits.js';
|
import { registerDataCredits } from '../data/dataCredits.js';
|
||||||
|
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||||||
|
import { isScopeMaskEnabled } from '../scopeMask.js';
|
||||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||||
import { MapStackController } from '../mapStackController.js';
|
import { MapStackController } from '../mapStackController.js';
|
||||||
import { loadPhotorealisticTileset } from '../mapStartup.js';
|
import { loadPhotorealisticTileset } from '../mapStartup.js';
|
||||||
@@ -58,6 +62,13 @@ export async function createApplicationScene({
|
|||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
if (!viewer.isDestroyed()) viewer.destroy();
|
||||||
});
|
});
|
||||||
defer(installTrackpadPinchZoom(viewer));
|
defer(installTrackpadPinchZoom(viewer));
|
||||||
|
defer(installFreeLookHeightLimit(viewer));
|
||||||
|
defer(
|
||||||
|
installGlobeInViewRecovery(viewer, {
|
||||||
|
// The scope mask hides everything outside its keyhole.
|
||||||
|
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
|
||||||
|
}),
|
||||||
|
);
|
||||||
registerDataCredits(viewer, credits);
|
registerDataCredits(viewer, credits);
|
||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
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,
|
||||||
|
} 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 +107,174 @@ export function installTrackpadPinchZoom(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow Cesium's free look only below `maxHeight` and return its disposer.
|
||||||
|
* Orbit, tilt and zoom stay available at every height. `enableLook` gates the
|
||||||
|
* Shift+drag binding only; a drag that starts on the stars below Cesium's
|
||||||
|
* trackball height still turns the camera in place, which
|
||||||
|
* `installGlobeInViewRecovery` catches.
|
||||||
|
*/
|
||||||
|
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 originalEnableLook = controller.enableLook;
|
||||||
|
const sync = () => {
|
||||||
|
const height = camera.positionCartographic?.height;
|
||||||
|
controller.enableLook =
|
||||||
|
originalEnableLook && !(Number.isFinite(height) && height > maxHeight);
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
const removeListener = scene.preUpdate.addEventListener(sync);
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
return () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
removeListener();
|
||||||
|
controller.enableLook = originalEnableLook;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, and a drag that Cesium turns
|
||||||
|
* into an in-place look below its trackball height whatever `enableLook` says.
|
||||||
|
* Heading and position are kept. Tracked cameras, locked ones (non-identity
|
||||||
|
* transform) and the 2D and Columbus modes are left alone. `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,
|
||||||
|
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');
|
||||||
|
|
||||||
|
const recover = () => {
|
||||||
|
if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return;
|
||||||
|
if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY))
|
||||||
|
return;
|
||||||
|
const height = camera.positionCartographic?.height;
|
||||||
|
if (
|
||||||
|
!(height > minHeight) ||
|
||||||
|
!globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() })
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
camera.setView({
|
||||||
|
orientation: {
|
||||||
|
heading: camera.heading,
|
||||||
|
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||||
|
roll: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scene.requestRender?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
movedSinceCheck = false;
|
||||||
|
recover();
|
||||||
|
}
|
||||||
|
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(() => {
|
||||||
|
stopSettling();
|
||||||
|
recover();
|
||||||
|
});
|
||||||
|
const removeMoveStart = camera.moveStart.addEventListener(startSettling);
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
return () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
stopSettling();
|
||||||
|
removeMoveEnd();
|
||||||
|
removeMoveStart();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 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,6 +13,9 @@ 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 } 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 clone = (value) => Cesium.Cartesian3.clone(value);
|
const clone = (value) => Cesium.Cartesian3.clone(value);
|
||||||
@@ -1347,3 +1350,491 @@ 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));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 slide the planet away', (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('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');
|
||||||
|
});
|
||||||
+175
-4
@@ -20,11 +20,18 @@
|
|||||||
|
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { GLOBE_VIEW } from './locations.js';
|
import { GLOBE_VIEW } from './locations.js';
|
||||||
|
import {
|
||||||
|
globeFrameShare,
|
||||||
|
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,
|
||||||
@@ -1008,6 +1015,137 @@ export function interruptCameraMotionIfActive(
|
|||||||
return interruptCameraMotion(reason);
|
return interruptCameraMotion(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A free globe camera: 3D, not tracking, not locked to a frame. The frame
|
||||||
|
* limit for in-place turns applies to it alone, as the move-end recovery does.
|
||||||
|
*/
|
||||||
|
function isFreeGlobeCamera(cam) {
|
||||||
|
return (
|
||||||
|
_viewer.scene.mode === Cesium.SceneMode.SCENE3D &&
|
||||||
|
!_viewer.trackedEntity &&
|
||||||
|
Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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(cam)) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.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(cam)) 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, 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,10 +1364,15 @@ function onTick() {
|
|||||||
interruptCameraMotion('tilt-clamp');
|
interruptCameraMotion('tilt-clamp');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (up) cam.lookUp(stepRad);
|
lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad);
|
||||||
else cam.lookDown(stepRad);
|
} else {
|
||||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
lookWithinFrame(
|
||||||
else cam.lookRight(stepRad);
|
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.
|
||||||
@@ -1413,6 +1556,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();
|
||||||
@@ -1524,6 +1668,27 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const look =
|
||||||
|
motion === 'tilt' || motion === 'rotate'
|
||||||
|
? {
|
||||||
|
up: 'lookUp',
|
||||||
|
down: 'lookDown',
|
||||||
|
left: 'lookLeft',
|
||||||
|
right: 'lookRight',
|
||||||
|
}[direction]
|
||||||
|
: 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')) &&
|
||||||
@@ -1609,6 +1774,12 @@ export function moveCamera(args = {}, runNavigation = 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',
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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