diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3fde50e05..4244d5937 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface than that, such as the oblique orbital Director shots, -are left as they are, and so are tracked or locked cameras and the 2D and -Columbus modes. A voice turn requested while the camera is tracked is judged +are left as they are, and so are tracked or locked cameras, a camera flight in +progress, a Director scene while it plays (holds included) 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 release, so a refused turn keeps the tracking. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 401eefa0b..d9d915f20 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -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(); diff --git a/src/app/scene.js b/src/app/scene.js index 9c115638d..8792a1f4d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -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))); diff --git a/src/app/tools.js b/src/app/tools.js index 9d91f70a9..68033947d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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, diff --git a/src/app/viewer.js b/src/app/viewer.js index ce182a0ab..c99a34d07 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -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) || diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index f15ae4978..543c01bff 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -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. diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 964b5c1a7..3c8424b82 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -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; } diff --git a/src/globeInView.js b/src/globeInView.js index 1ccbcda23..98a3445dc 100644 --- a/src/globeInView.js +++ b/src/globeInView.js @@ -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