Complete camera flights at their duration on both engines

Navara's own flight clock loses time on slow frames (a one-second flight
took 1.55 s at 50 ms frames and 3.1 s at 100 ms), so stories waited for
the director's timeout. The adapter now owns the flight deadline and lands
the camera on time. On Cesium a flight still counted as active inside its
complete callback, because Cesium removes its tween only afterwards. The
contract suite checks completion time and state for every easing.
This commit is contained in:
German Khokhlov committed 2026-11-12 00:42:17 +04:00
1 parent 667190f815
commit 6c77b1ed41
6 files changed
+293 -13

No files matched your search

+11 -2
View File
@@ -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));
},
+20
View File
@@ -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.
+26 -6
View File
@@ -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'),
);
+122 -5
View File
@@ -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', () => {
@@ -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 () => {
@@ -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();
}
});
}