fix(camera): leave flights and scenes to their framing

Review follow-ups for the globe-in-view recovery and voice limits.

- Leave a camera flight in progress and a playing Director scene, holds
  included, to their own framing. The recovery is now installed with the
  scene tools, which own the Director, and reads its running state.
- Reuse a voice turn's half-degree proof only from the position and
  direction it was proven at, so a release that moves the camera checks
  the turn again.
- Share one free-globe-camera check between the recovery, the voice
  turn limit and the globe rotation.
- Keep an enableLook setting other code makes after the free-look limit
  is installed, instead of writing back the install-time value.
- Name the nudging test for what it asserts, the 1% floor, and document
  that a view losing its surface without a camera move waits for the
  next one.
This commit is contained in:
Manjunath committed 2026-11-05 11:59:11 +05:30
1 parent d36b28f352
commit 64377db91a
8 files changed
+137 -44

No files matched your search

+5 -2
View File
@@ -100,8 +100,11 @@ 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 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). 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, 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 are left as they are, and so are tracked or locked cameras, a camera flight in
Columbus modes. A voice turn requested while the camera is tracked is judged progress, a Director scene while it plays (holds included) and the 2D and
Columbus modes. A view that loses its surface without a camera move (a resize,
or turning the scope on) is left as it is until the next move. A voice turn
requested while the camera is tracked is judged
by the pose voice's release of the follow camera will leave, before that by the pose voice's release of the follow camera will leave, before that
release, so a refused turn keeps the tracking. release, so a refused turn keeps the tracking.
+44
View File
@@ -399,6 +399,26 @@ test('free look follows camera height and is restored on dispose', () => {
assert.equal(f.listeners.size, 0); assert.equal(f.listeners.size, 0);
}); });
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', () => { test('free look stays off when the viewer had it disabled', () => {
const f = freeLookFixture({ enableLook: false, height: 500 }); const f = freeLookFixture({ enableLook: false, height: 500 });
installFreeLookHeightLimit(f.viewer); installFreeLookHeightLimit(f.viewer);
@@ -418,6 +438,7 @@ function realCamera({ width = 1600, height = 900, mode } = {}) {
pixelRatio: 1, pixelRatio: 1,
ellipsoid: Cesium.Ellipsoid.WGS84, ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
tweens: new Cesium.TweenCollection(),
renders: 0, renders: 0,
requestRender() { requestRender() {
this.renders += 1; this.renders += 1;
@@ -674,6 +695,29 @@ test('the recovery follows the scope: corner-only views are turned back only beh
} }
}); });
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));
});
function fakeTimers() { function fakeTimers() {
let next = 1; let next = 1;
const pending = new Map(); const pending = new Map();
-9
View File
@@ -3,12 +3,9 @@ import * as Cesium from 'cesium';
import { import {
createApplicationViewer, createApplicationViewer,
installFreeLookHeightLimit, 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';
@@ -63,12 +60,6 @@ export async function createApplicationScene({
}); });
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
defer(installFreeLookHeightLimit(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)));
+15 -1
View File
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js'; import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import {
installScopeMask,
destroyScopeMask,
isScopeMaskEnabled,
} from '../scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import { installGlobeInViewRecovery } from './viewer.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import { import {
installRenderGovernor, installRenderGovernor,
@@ -44,6 +50,14 @@ export function createApplicationTools({
.get('bhote-koshi-2026') .get('bhote-koshi-2026')
?.module.attachSceneController(sceneDirector); ?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy()); defer(() => sceneDirector.destroy());
defer(
installGlobeInViewRecovery(viewer, {
// The scope mask hides everything outside its keyhole.
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
// An authored scene frames its own shots, holds included.
paused: () => sceneDirector.running,
}),
);
onSceneDirector?.(sceneDirector); onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({ const annotations = initAnnotations({
viewer, viewer,
+18 -9
View File
@@ -4,6 +4,7 @@ import { StarrySky } from '../sky/starrySky.js';
import { import {
FREE_LOOK_MAX_HEIGHT_METERS, FREE_LOOK_MAX_HEIGHT_METERS,
globeOutOfFrame, globeOutOfFrame,
isFreeGlobeCamera,
} from '../globeInView.js'; } from '../globeInView.js';
const PINCH_ZOOM_MULTIPLIER = 8; const PINCH_ZOOM_MULTIPLIER = 8;
@@ -109,7 +110,9 @@ export function installTrackpadPinchZoom(
/** /**
* Allow Cesium's free look only below `maxHeight` and return its disposer. * Allow Cesium's free look only below `maxHeight` and return its disposer.
* Orbit, tilt and zoom stay available at every height. `enableLook` gates the * 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. `enableLook` gates the
* Shift+drag binding only; a drag that starts on the stars below Cesium's * Shift+drag binding only; a drag that starts on the stars below Cesium's
* trackball height still turns the camera in place, which * trackball height still turns the camera in place, which
* `installGlobeInViewRecovery` catches. * `installGlobeInViewRecovery` catches.
@@ -124,11 +127,14 @@ export function installFreeLookHeightLimit(
if (!controller || !camera || !scene?.preUpdate) if (!controller || !camera || !scene?.preUpdate)
throw new TypeError('A complete Cesium viewer is required'); throw new TypeError('A complete Cesium viewer is required');
const originalEnableLook = controller.enableLook; let wanted = controller.enableLook;
let written;
const sync = () => { const sync = () => {
if (written !== undefined && controller.enableLook !== written)
wanted = controller.enableLook;
const height = camera.positionCartographic?.height; const height = camera.positionCartographic?.height;
controller.enableLook = written = wanted && !(Number.isFinite(height) && height > maxHeight);
originalEnableLook && !(Number.isFinite(height) && height > maxHeight); controller.enableLook = written;
}; };
sync(); sync();
const removeListener = scene.preUpdate.addEventListener(sync); const removeListener = scene.preUpdate.addEventListener(sync);
@@ -138,7 +144,7 @@ export function installFreeLookHeightLimit(
if (!active) return; if (!active) return;
active = false; active = false;
removeListener(); removeListener();
controller.enableLook = originalEnableLook; if (controller.enableLook === written) controller.enableLook = wanted;
}; };
} }
@@ -159,7 +165,9 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000;
* catches a saved or shared view that looked away, and a drag that Cesium turns * 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. * into an in-place look below its trackball height whatever `enableLook` says.
* Heading and position are kept. Tracked cameras, locked ones (non-identity * Heading and position are kept. Tracked cameras, locked ones (non-identity
* transform) and the 2D and Columbus modes are left alone. `keyholeRadius` * transform), a camera flight in progress, the 2D and Columbus modes, and any
* time `paused` returns true (an authored scene playing) are left alone.
* `keyholeRadius`
* returns the radius of the visible circle when a scope mask hides the rest of * 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. * the frame (see `globeFrameShare`), or 0; it is read at every check.
* *
@@ -179,6 +187,7 @@ export function installGlobeInViewRecovery(
{ {
minHeight = FREE_LOOK_MAX_HEIGHT_METERS, minHeight = FREE_LOOK_MAX_HEIGHT_METERS,
keyholeRadius = () => 0, keyholeRadius = () => 0,
paused = () => false,
setTimeout: schedule = globalThis.setTimeout, setTimeout: schedule = globalThis.setTimeout,
clearTimeout: unschedule = globalThis.clearTimeout, clearTimeout: unschedule = globalThis.clearTimeout,
} = {}, } = {},
@@ -189,9 +198,9 @@ export function installGlobeInViewRecovery(
throw new TypeError('A complete Cesium viewer is required'); throw new TypeError('A complete Cesium viewer is required');
const recover = () => { const recover = () => {
if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; if (!isFreeGlobeCamera(viewer)) return;
if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) // Cesium has no public running-flight flag.
return; if (camera._currentFlight || paused()) return;
const height = camera.positionCartographic?.height; const height = camera.positionCartographic?.height;
if ( if (
!(height > minHeight) || !(height > minHeight) ||
+27 -1
View File
@@ -1526,7 +1526,7 @@ test('a voice turn after something else turned the camera starts over', (t) => {
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
}); });
test('nudging the camera between voice turns cannot slide the planet away', (t) => { test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
withScope(t, false); // the whole frame is visible withScope(t, false); // the whole frame is visible
// A wheel notch or an orbit drag between requests starts each turn afresh; // 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. // the 1% floor still holds, and below it a turn may only keep what it has.
@@ -1810,6 +1810,32 @@ test('a refused voice turn leaves a tracked camera tracking', (t) => {
} }
}); });
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) => { 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 // At its limit in 3D a further tilt up is refused; the same tilt in Columbus
// view is not limited by the frame. // view is not limited by the frame.
+16 -22
View File
@@ -22,6 +22,7 @@ import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js'; import { GLOBE_VIEW } from './locations.js';
import { import {
globeFrameShare, globeFrameShare,
isFreeGlobeCamera,
lookAngleWithinFrame, lookAngleWithinFrame,
lookStepLosesGlobe, lookStepLosesGlobe,
} from './globeInView.js'; } from './globeInView.js';
@@ -1015,18 +1016,6 @@ 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 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 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 // the last one left off keeps that baseline (raised if the planet now fills
@@ -1036,7 +1025,7 @@ function isFreeGlobeCamera(cam) {
let _lookBaseline = null; let _lookBaseline = null;
// The pre-check's proof that a half-degree turn from this pose keeps the planet // 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 // 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 // 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. // even when a smaller first step lands in a dip of the sampled share.
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
@@ -1079,7 +1068,7 @@ function lookBaselineShare(cam) {
*/ */
function lookWithinFrame(cam, m, look, angle) { function lookWithinFrame(cam, m, look, angle) {
let allowed = angle; let allowed = angle;
if (isFreeGlobeCamera(cam)) { if (isFreeGlobeCamera(_viewer)) {
if (m.globeShareAtStart === undefined) { if (m.globeShareAtStart === undefined) {
m.globeShareAtStart = lookBaselineShare(cam); m.globeShareAtStart = lookBaselineShare(cam);
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
@@ -1105,6 +1094,12 @@ function lookWithinFrame(cam, m, look, angle) {
function lookProven(cam, look) { function lookProven(cam, look) {
return ( return (
_provenLook?.look === look && _provenLook?.look === look &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon( Cesium.Cartesian3.equalsEpsilon(
_provenLook.direction, _provenLook.direction,
cam.directionWC, cam.directionWC,
@@ -1129,7 +1124,7 @@ function checkLookFromOrbit(look, { released = false } = {}) {
_provenLook = null; _provenLook = null;
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
const cam = _viewer.camera; const cam = _viewer.camera;
if (!released && !isFreeGlobeCamera(cam)) return null; if (!released && !isFreeGlobeCamera(_viewer)) return null;
if ( if (
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
...visibleFrame(), ...visibleFrame(),
@@ -1142,7 +1137,11 @@ function checkLookFromOrbit(look, { released = false } = {}) {
error: error:
'From this height that would turn the globe out of view — zoom in first, or turn the other way.', '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) }; _provenLook = {
look,
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
};
return null; return null;
} }
@@ -1329,12 +1328,7 @@ function onTick() {
// Tracking can acquire the camera without going through navigation. // Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof // Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity. // only describes Cesium's rotation while the frame is identity.
if ( if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
interruptCameraMotion('globe-camera-owner-changed'); interruptCameraMotion('globe-camera-owner-changed');
return; return;
} }
+12
View File
@@ -9,6 +9,18 @@ export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000;
// the planet's surface. // the planet's surface.
export const MIN_GLOBE_FRAME_SHARE = 0.1; 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_COLUMNS = 32;
const GRID_ROWS = 18; const GRID_ROWS = 18;
// A turn across a sliver of planet moves its edge through sample points, so // A turn across a sliver of planet moves its edge through sample points, so