Follow a replacement voice flight whatever its predecessor reports

A motion queued behind a voice fly-to now follows a later fly-to in the same reply whether the earlier one arrived, was cancelled or failed. A geocode that loses navigation reports failed, so the motion used to be dropped; and a flight left flying while the next place is looked up could land and run the motion there, taking navigation and cancelling the newer flight. The deadline, abort and stale-reply checks still end the wait.

pitchDeg -90 or -89 from a top-down view is now unchanged rather than a 1° tilt up, and the tilt tests' pitch loops are bounded.
This commit is contained in:
Manjunath committed 2026-11-04 13:40:43 +05:30
1 parent eeb20d24e9
commit 44860df90e
4 files changed
+104 -6

No files matched your search

+21 -2
View File
@@ -78,6 +78,11 @@ function harness(
} }
const pitchDeg = (camera) => degrees(camera.pitch); const pitchDeg = (camera) => degrees(camera.pitch);
/** Tick until done() holds; a regression fails here instead of hanging. */
const tickUntil = (h, done, frames = 600) => {
for (let i = 0; i < frames && !done(); i += 1) h.tick();
assert.ok(done(), `not reached in ${frames} frames`);
};
const headingDeg = (camera) => const headingDeg = (camera) =>
degrees(Cesium.Math.negativePiToPi(camera.heading)); degrees(Cesium.Math.negativePiToPi(camera.heading));
@@ -171,6 +176,20 @@ test('a requested angle the camera already holds is a no-op', (t) => {
assert.equal(getActiveCameraMotion(), null); assert.equal(getActiveCameraMotion(), null);
}); });
for (const pitch of [-90, -89]) {
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
// The tool description calls -89 top-down; the clamp must not tilt a
// -90° view up 1° and report it as a move up.
const h = harness(t);
const before = pitchDeg(h.camera);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(getActiveCameraMotion(), null);
assert.equal(pitchDeg(h.camera), before);
});
}
test('tilt down from top-down still refuses honestly', (t) => { test('tilt down from top-down still refuses honestly', (t) => {
harness(t); harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'down' }); const result = moveCamera({ motion: 'tilt', direction: 'down' });
@@ -335,7 +354,7 @@ test('the pitchDeg and direction check uses the pitch the swing starts from', (t
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => { test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
const h = harness(t, { height: 1000 }); const h = harness(t, { height: 1000 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
while (pitchDeg(h.camera) < -30) h.tick(); tickUntil(h, () => pitchDeg(h.camera) >= -30);
adjustOrbitRange(1 / 2.4); adjustOrbitRange(1 / 2.4);
adjustOrbitRange(1 / 2.4); adjustOrbitRange(1 / 2.4);
h.tick(); h.tick();
@@ -390,7 +409,7 @@ test('a zoom in during a pivot tilt stops at the clearance', (t) => {
// the camera about 107 m up, then at the 80 m zoom floor. // the camera about 107 m up, then at the 80 m zoom floor.
const h = harness(t, { height: 400 }); const h = harness(t, { height: 400 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
while (pitchDeg(h.camera) < -40) h.tick(); tickUntil(h, () => pitchDeg(h.camera) >= -40);
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true }); assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached. // At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false }); assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
+7 -1
View File
@@ -1671,7 +1671,13 @@ export function moveCamera(args = {}, runNavigation = null) {
// Judge from the pitch the motion will start at: the pivot's, which at // Judge from the pitch the motion will start at: the pivot's, which at
// regional range is a degree or two shallower than the camera's. // regional range is a degree or two shallower than the camera's.
const { from } = tiltStartFrame(_viewer.camera, { releasesTracking }); const { from } = tiltStartFrame(_viewer.camera, { releasesTracking });
if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { // -89 or -90 means top-down, which a -90° view already is: the clamp
// must not tilt it up 1°.
const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN;
if (
atTopDown ||
Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
) {
return { return {
ok: true, ok: true,
action: 'move_camera', action: 'move_camera',
+11 -3
View File
@@ -1302,9 +1302,17 @@ export function createGevActionRunner({
runOptions.signal, runOptions.signal,
deadline, deadline,
); );
// A later fly-to in the same reply cancels this one: follow it to // A later fly-to in the same reply replaces this one: follow it to
// where the camera lands, within the same deadline. // where the camera lands, within the same deadline. Its status does
while (status === 'cancelled' && voiceFlight !== flight && current()) { // not matter. A geocode that loses navigation reports failed, and a
// flight left flying while the next place is looked up can still
// land; running there would take navigation and cancel the newer one.
while (
voiceFlight !== flight &&
status !== 'aborted' &&
status !== 'timeout' &&
current()
) {
flight = voiceFlight; flight = voiceFlight;
status = await waitForVoiceFlight( status = await waitForVoiceFlight(
flight, flight,
+65
View File
@@ -570,6 +570,71 @@ test('a motion follows a later fly-to in the same reply that cancels its flight'
assert.equal(result.motion, 'orbit'); assert.equal(result.motion, 'orbit');
}); });
test('a motion follows a later fly-to whose start cancels its geocode', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager } = tiltAfterFlightHarness();
const searches = [];
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
searchNavigation: (_viewer, query, options) => new Promise((resolve) => {
searches.push({ query, options, resolve });
}),
});
const flyA = runner('fly_to_location', { query: 'Place A' });
const moved = runner('move_camera', { motion: 'orbit' });
const movedDone = settledYet(moved);
const flyB = runner('fly_to_location', { query: 'Place B' });
await new Promise((resolve) => setTimeout(resolve, 0));
// B takes navigation authority, so A's lookup comes back cancelled.
searches[0].resolve({ cancelled: true });
assert.equal((await flyA).cancelled, true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(movedDone(), false, 'the orbit must follow B, not drop');
searches[1].resolve({ label: 'Place B', navigationMode: 'city-overview' });
assert.equal((await flyB).ok, true);
searches[1].options.onComplete();
const result = await moved;
assert.equal(result.ok, true);
assert.equal(result.motion, 'orbit');
});
test('a motion does not run at a flight that lands while the next place is looked up', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, order, flights } = tiltAfterFlightHarness();
const searches = [];
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
searchNavigation: (_viewer, query, options) => new Promise((resolve) => {
searches.push({ query, options, resolve });
}),
});
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
const movedDone = settledYet(moved);
const flyB = runner('fly_to_location', { query: 'Place B' });
await new Promise((resolve) => setTimeout(resolve, 0));
// Deferred navigation leaves A flying while B is looked up, and A lands.
const before = order.length;
flights[0].complete();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(movedDone(), false, 'the tilt must wait for B');
assert.equal(
order.slice(before).includes('cancel'),
false,
'the tilt must not take navigation and cancel B',
);
searches[0].resolve({ label: 'Place B', navigationMode: 'city-overview' });
assert.equal((await flyB).ok, true);
searches[0].options.onComplete();
const result = await moved;
assert.equal(result.ok, true);
assert.equal(result.motion, 'tilt');
});
test('a stale motion does not follow a newer reply\'s fly-to', async () => { test('a stale motion does not follow a newer reply\'s fly-to', async () => {
const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const runner = createGevActionRunner({ const runner = createGevActionRunner({