diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index 142dc5279..cca229400 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -148,6 +148,9 @@ export function createCesiumCamera(viewer) { // completed synchronously (Cesium keeps no current flight for those). let flight = null; let completing = false; + // Cesium calls complete from the flight's tween before it leaves + // scene.tweens; the flight is over by then. + let landing = false; function trackFlight(options) { const token = {}; flight = token; @@ -158,7 +161,12 @@ export function createCesiumCamera(viewer) { end, complete: () => { end(); - options.complete?.(); + landing = true; + try { + options.complete?.(); + } finally { + landing = false; + } }, cancel: () => { end(); @@ -266,7 +274,8 @@ export function createCesiumCamera(viewer) { }, // Also a flight started straight on viewer.camera (layers' flyTo and // flyToBoundingSphere): Cesium runs every camera flight as a scene tween. - flightActive: () => flight != null || viewer.scene.tweens?.length > 0, + flightActive: () => + flight != null || (!landing && viewer.scene.tweens?.length > 0), lookAt(target, offset) { cesium.lookAt(toCesium3(target), cesiumOffset(offset)); }, diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index 8a88a9205..1a86f27d7 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -132,6 +132,26 @@ test('flightActive also reports a flight started straight on the Cesium camera', assert.equal(camera.flightActive(), false); }); +test('a flight is no longer active when its complete callback runs', () => { + const { viewer, camera } = setup(); + let activeInComplete = null; + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e6), + complete: () => { + activeInComplete = camera.flightActive(); + }, + }); + // Cesium calls complete from the flight's tween, which stays in the scene's + // tween collection until the callback returns. + const [options] = viewer.callsOf('camera.flyTo')[0].args; + const tween = {}; + viewer.scene.tweens.push(tween); + options.complete(); + viewer.scene.tweens.splice(viewer.scene.tweens.indexOf(tween), 1); + assert.equal(activeInComplete, false); + assert.equal(camera.flightActive(), false); +}); + test('completeFlight lands at the destination and calls complete only', () => { const { scene, engine } = createCameraViewer(); // Cesium's own flight: a real tween collection under the real camera. diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index fb2c231c1..59165b41f 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -186,6 +186,7 @@ export function createCameraModel({ function endFlight(current, outcome) { if (flight !== current) return; flight = null; + clock.clearTimeout(current.timer); current.resume?.(); if (outcome === 'cancel') current.options.cancel?.(); } @@ -202,6 +203,14 @@ export function createCameraModel({ place(target); } + function finishFlight(current) { + if (flight !== current || destroyed) return; + endFlight(current, 'complete'); + port.stop(); + land(current.options, current.target); + current.options.complete?.(); + } + function startFlight(options, target) { cancelCurrentFlight(); if (options.duration !== undefined && options.duration <= 0) { @@ -209,10 +218,17 @@ export function createCameraModel({ options.complete?.(); return; } + // Match Cesium's distance-derived duration when none was supplied. + const duration = + options.duration ?? + Math.min( + Math.ceil(cartesian3.distance(state.position, target.position) / 1e6) + + 2, + 3, + ); + const deadline = clock.now() + duration * 1000; const pose = navaraPose(target.position, target.direction, target.up); - const flyOptions = {}; - if (options.duration !== undefined) - flyOptions.duration = options.duration * 1000; + const flyOptions = { duration: duration * 1000 }; if (options.easing !== undefined) flyOptions.easing = NAVARA_EASINGS[options.easing]; const current = { options, target, resume: port.suspendCameraInputs?.() }; @@ -227,13 +243,17 @@ export function createCameraModel({ } // Navara moves the camera from here on; read it back every frame. pushed = null; + // Navara caps flight time per frame, so slow frames lose elapsed time. + // The contract duration is wall time, independent of rendering cadence. + current.timer = clock.setTimeout( + () => finishFlight(current), + Math.max(0, deadline - clock.now()), + ); pending.then( (arrived) => { if (flight !== current || destroyed) return; if (!arrived) return endFlight(current, 'cancel'); - endFlight(current, 'complete'); - land(options, target); - options.complete?.(); + finishFlight(current); }, () => endFlight(current, 'cancel'), ); diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs index 50be031e7..04fede563 100644 --- a/src/globe/navara/cameraModel.test.mjs +++ b/src/globe/navara/cameraModel.test.mjs @@ -229,7 +229,7 @@ test('flyTo with duration 0 lands and completes before it returns', () => { }); test('a timed flyTo flies with Navara in ms with the mapped easing and completes on arrival', async () => { - const { camera, port, frame } = setup(); + const { camera, port, frame, clock } = setup(); const destination = cartesian3.fromDegrees(100, 0, 5e6); const outcome = []; camera.flyTo({ @@ -255,10 +255,119 @@ test('a timed flyTo flies with Navara in ms with the mapped easing and completes assert.deepEqual(outcome, ['complete']); assert.equal(camera.flightActive(), false); near(cartesian3.distance(camera.pose().position, destination), 0, 1e-9, 'p'); + clock.advance(3000); + assert.deepEqual(outcome, ['complete']); }); -test('easings map onto Navara presets and an omitted one stays Navara’s own', () => { - const { camera, port } = setup(); +test('flights land by their duration even when Navara has not settled', async (context) => { + for (const method of ['flyTo', 'flyToSphere']) + for (const easing of ['linear', 'cubicInOut', 'sinusoidalInOut', undefined]) + for (const height of [100, 40000, 1.2e7]) + await context.test(`${method}, ${easing}, ${height} m`, async () => { + const { camera, port, clock } = setup(); + const destination = cartesian3.fromDegrees(86.925, 27.988, height); + const outcome = []; + let suspensions = 0; + port.suspendCameraInputs = () => { + suspensions++; + return () => suspensions--; + }; + const options = { + duration: 1, + easing, + complete: () => { + assert.equal(camera.flightActive(), false); + assert.equal(suspensions, 0); + outcome.push('complete'); + }, + cancel: () => outcome.push('cancel'), + }; + if (method === 'flyTo') camera.flyTo({ destination, ...options }); + else + camera.flyToSphere( + { center: destination, radius: 100 }, + { ...options, range: 5000, pitch: -0.5 }, + ); + const target = port.flights[0].pose; + clock.advance(999); + assert.deepEqual(outcome, []); + assert.equal(camera.flightActive(), true); + assert.equal(suspensions, 1); + clock.advance(1); + assert.deepEqual(outcome, ['complete']); + assert.equal(port.stops, 1); + const expected = cartesian3.fromDegrees( + target.lng, + target.lat, + target.height, + ); + near( + cartesian3.distance(camera.pose().position, expected), + 0, + 1e-6, + 'landed position', + ); + assert.deepEqual(port.applied.at(-1), target); + assert.equal(clock.timers.length, 0); + // A native result may arrive late or never arrive at all. + if (height !== 1.2e7) port.flights[0].settle(height === 40000); + await flush(); + assert.deepEqual(outcome, ['complete']); + }); +}); + +test('a flight deadline includes the time spent starting the native flight', () => { + const { camera, port, clock } = setup(); + const fly = port.fly; + port.fly = (...args) => { + clock.advance(250); + return fly(...args); + }; + let done = false; + camera.flyTo({ + destination: cartesian3.fromDegrees(10, 45, 5000), + duration: 1, + complete: () => (done = true), + }); + clock.advance(749); + assert.equal(done, false); + clock.advance(1); + assert.equal(done, true); +}); + +test('ending a flight clears its deadline and ignores late native settlement', async (context) => { + for (const action of ['cancel', 'complete', 'replace', 'setView', 'destroy']) + await context.test(action, async () => { + const { camera, port, clock, model } = setup(); + const destination = cartesian3.fromDegrees(10, 45, 5000); + const outcome = []; + camera.flyTo({ + destination, + duration: 1, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + clock.advance(250); + if (action === 'cancel') camera.cancelFlight(); + if (action === 'complete') camera.completeFlight(); + if (action === 'setView') camera.setView({ destination }); + if (action === 'destroy') model.destroy(); + if (action === 'replace') camera.flyTo({ destination, duration: 2 }); + const expected = action === 'complete' ? ['complete'] : ['cancel']; + assert.deepEqual(outcome, expected); + assert.equal(clock.timers.length, action === 'replace' ? 1 : 0); + clock.advance(750); + port.flights[0].settle(true); + await flush(); + assert.deepEqual(outcome, expected); + assert.equal(camera.flightActive(), action === 'replace'); + model.destroy(); + assert.equal(clock.timers.length, 0); + }); +}); + +test('easings map onto Navara presets and omitted durations use Cesium’s default', () => { + const { camera, port, clock } = setup(); for (const easing of ['linear', 'cubicInOut', undefined]) camera.flyTo({ destination: cartesian3.fromDegrees(1, 2, 3e5), @@ -269,8 +378,16 @@ test('easings map onto Navara presets and an omitted one stays Navara’s own', port.flights.map((flight) => flight.options.easing), ['linear', 'cubicInOut', undefined], ); - camera.flyTo({ destination: cartesian3.fromDegrees(1, 2, 3e5) }); - assert.equal(port.flights.at(-1).options.duration, undefined); + const destination = cartesian3.fromDegrees(1, 2, 3e5); + camera.flyTo({ destination }); + assert.equal(port.flights.at(-1).options.duration, 3000); + clock.advance(3000); + assert.equal(camera.flightActive(), false); + camera.setView({ destination }); + camera.flyTo({ destination, orientation: { heading: 1 } }); + assert.equal(port.flights.at(-1).options.duration, 2000); + clock.advance(2000); + assert.equal(camera.flightActive(), false); }); test('an invalid flight pose does not suspend camera inputs', () => { diff --git a/src/globe/testing/contractSuite/camera.mjs b/src/globe/testing/contractSuite/camera.mjs index 021225eca..e18ff7c73 100644 --- a/src/globe/testing/contractSuite/camera.mjs +++ b/src/globe/testing/contractSuite/camera.mjs @@ -255,6 +255,77 @@ export function poseChecks(engine, { settle, until, nadirView }) { export function flightChecks(engine, { settle, until, nadirView, centre }) { const { camera } = engine; return [ + [ + 'camera.flyTo: completes within its duration at every distance and easing', + async () => { + const duration = 0.4; + // Allow frame scheduling jitter, but not the director's safety delay. + const toleranceMs = 100; + for (const easing of [ + 'linear', + 'cubicInOut', + 'sinusoidalInOut', + undefined, + ]) + for (const coordinates of [ + [20.01, 10, 5000], + [-160, -10, 1.2e7], + ]) { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 10000), + }); + await settle(); + const destination = cartesian3.fromDegrees(...coordinates); + const outcome = []; + let arrived; + let active; + let elapsed; + const started = performance.now(); + try { + camera.flyTo({ + destination, + duration, + easing, + complete: () => { + elapsed = performance.now() - started; + arrived = camera.pose().position; + active = camera.flightActive(); + outcome.push('complete'); + }, + cancel: () => outcome.push('cancel'), + }); + await until( + () => + outcome.length > 0 || + performance.now() - started > duration * 1000 + toleranceMs, + 'flight deadline', + ); + ensure( + outcome.join() === 'complete', + `${easing}: flight missed its duration (${outcome})`, + ); + ensure( + elapsed <= duration * 1000 + toleranceMs, + `${easing}: flight completed after ${elapsed} ms`, + ); + ensure(!active, 'flight still active in complete'); + near( + cartesian3.distance(arrived, destination), + 0, + 0.01, + 'position at completion (m)', + ); + await settle(2); + ensure( + outcome.join() === 'complete', + `flight ended with ${outcome}`, + ); + } finally { + camera.cancelFlight(); + } + } + }, + ], [ 'camera.flyTo: duration 0 lands and completes before flyTo returns', async () => { diff --git a/src/globe/testing/contractSuite/camera.test.mjs b/src/globe/testing/contractSuite/camera.test.mjs new file mode 100644 index 000000000..821696126 --- /dev/null +++ b/src/globe/testing/contractSuite/camera.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from '../fakeEngine.js'; +import { createContractSuite } from './index.mjs'; + +for (const timing of ['on time', 'late', 'missing']) { + test(`flight duration check: ${timing} completion`, async (context) => { + let now = 0; + context.mock.method(performance, 'now', () => now); + const engine = createFakeEngine(); + const flyTo = engine.camera.flyTo; + engine.camera.flyTo = (options) => + flyTo({ + ...options, + complete: () => { + if (timing === 'missing') return; + if (timing === 'late') now += 200; + options.complete?.(); + }, + }); + const check = createContractSuite(engine, { + waitFrames: async (count) => { + for (let frame = 0; frame < count; frame++) { + now += 50; + engine.fake.frame(50); + } + }, + }).find(({ name }) => name.startsWith('camera.flyTo: completes within')); + try { + if (timing === 'on time') await check.run(); + else + await assert.rejects( + check.run(), + timing === 'late' + ? /flight completed after 600 ms/ + : /flight missed its duration/, + ); + assert.equal(engine.camera.flightActive(), false); + } finally { + engine.destroy(); + } + }); +}