Merge branch 'main' into pr/wildlife-sightings

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

No files matched your search

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