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

+44
View File
@@ -399,6 +399,26 @@ test('free look follows camera height and is restored on dispose', () => {
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', () => {
const f = freeLookFixture({ enableLook: false, height: 500 });
installFreeLookHeightLimit(f.viewer);
@@ -418,6 +438,7 @@ function realCamera({ width = 1600, height = 900, mode } = {}) {
pixelRatio: 1,
ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
tweens: new Cesium.TweenCollection(),
renders: 0,
requestRender() {
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() {
let next = 1;
const pending = new Map();
-9
View File
@@ -3,12 +3,9 @@ import * as Cesium from 'cesium';
import {
createApplicationViewer,
installFreeLookHeightLimit,
installGlobeInViewRecovery,
installTrackpadPinchZoom,
} from '../app/viewer.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 { MapStackController } from '../mapStackController.js';
import { loadPhotorealisticTileset } from '../mapStartup.js';
@@ -63,12 +60,6 @@ export async function createApplicationScene({
});
defer(installTrackpadPinchZoom(viewer));
defer(installFreeLookHeightLimit(viewer));
defer(
installGlobeInViewRecovery(viewer, {
// The scope mask hides everything outside its keyhole.
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
}),
);
registerDataCredits(viewer, credits);
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,
+18 -9
View File
@@ -4,6 +4,7 @@ import { StarrySky } from '../sky/starrySky.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeOutOfFrame,
isFreeGlobeCamera,
} from '../globeInView.js';
const PINCH_ZOOM_MULTIPLIER = 8;
@@ -109,7 +110,9 @@ export function installTrackpadPinchZoom(
/**
* Allow Cesium's free look only below `maxHeight` and return its disposer.
* Orbit, tilt and zoom stay available at every height. `enableLook` gates the
* 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
* trackball height still turns the camera in place, which
* `installGlobeInViewRecovery` catches.
@@ -124,11 +127,14 @@ export function installFreeLookHeightLimit(
if (!controller || !camera || !scene?.preUpdate)
throw new TypeError('A complete Cesium viewer is required');
const originalEnableLook = controller.enableLook;
let wanted = controller.enableLook;
let written;
const sync = () => {
if (written !== undefined && controller.enableLook !== written)
wanted = controller.enableLook;
const height = camera.positionCartographic?.height;
controller.enableLook =
originalEnableLook && !(Number.isFinite(height) && height > maxHeight);
written = wanted && !(Number.isFinite(height) && height > maxHeight);
controller.enableLook = written;
};
sync();
const removeListener = scene.preUpdate.addEventListener(sync);
@@ -138,7 +144,7 @@ export function installFreeLookHeightLimit(
if (!active) return;
active = false;
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
* into an in-place look below its trackball height whatever `enableLook` says.
* Heading and position are kept. Tracked cameras, locked ones (non-identity
* transform) and the 2D and Columbus modes are left alone. `keyholeRadius`
* 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
* 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,
keyholeRadius = () => 0,
paused = () => false,
setTimeout: schedule = globalThis.setTimeout,
clearTimeout: unschedule = globalThis.clearTimeout,
} = {},
@@ -189,9 +198,9 @@ export function installGlobeInViewRecovery(
throw new TypeError('A complete Cesium viewer is required');
const recover = () => {
if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return;
if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY))
return;
if (!isFreeGlobeCamera(viewer)) return;
// Cesium has no public running-flight flag.
if (camera._currentFlight || paused()) return;
const height = camera.positionCartographic?.height;
if (
!(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);
});
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
// 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.
@@ -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) => {
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
// 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 {
globeFrameShare,
isFreeGlobeCamera,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from './globeInView.js';
@@ -1015,18 +1016,6 @@ export function interruptCameraMotionIfActive(
return interruptCameraMotion(reason);
}
/**
* A free globe camera: 3D, not tracking, not locked to a frame. The frame
* limit for in-place turns applies to it alone, as the move-end recovery does.
*/
function isFreeGlobeCamera(cam) {
return (
_viewer.scene.mode === Cesium.SceneMode.SCENE3D &&
!_viewer.trackedEntity &&
Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
);
}
// The frame share that in-place voice turns measure their limit against, with
// the pose the last voice turn left the camera in. A turn that starts where
// the last one left off keeps that baseline (raised if the planet now fills
@@ -1036,7 +1025,7 @@ function isFreeGlobeCamera(cam) {
let _lookBaseline = null;
// The pre-check's proof that a half-degree turn from this pose keeps the planet
// in view: which way, from which direction. A motion that starts from that
// 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);
@@ -1079,7 +1068,7 @@ function lookBaselineShare(cam) {
*/
function lookWithinFrame(cam, m, look, angle) {
let allowed = angle;
if (isFreeGlobeCamera(cam)) {
if (isFreeGlobeCamera(_viewer)) {
if (m.globeShareAtStart === undefined) {
m.globeShareAtStart = lookBaselineShare(cam);
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
@@ -1105,6 +1094,12 @@ function lookWithinFrame(cam, m, look, angle) {
function lookProven(cam, look) {
return (
_provenLook?.look === look &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.direction,
cam.directionWC,
@@ -1129,7 +1124,7 @@ function checkLookFromOrbit(look, { released = false } = {}) {
_provenLook = null;
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
const cam = _viewer.camera;
if (!released && !isFreeGlobeCamera(cam)) return null;
if (!released && !isFreeGlobeCamera(_viewer)) return null;
if (
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
...visibleFrame(),
@@ -1142,7 +1137,11 @@ function checkLookFromOrbit(look, { released = false } = {}) {
error:
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
};
_provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) };
_provenLook = {
look,
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
};
return null;
}
@@ -1329,12 +1328,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;
}
+12
View File
@@ -9,6 +9,18 @@ export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000;
// 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