Judge tilts by camera pitch and follow a newer flight at once

- A tilt's angle, limits and "unchanged" check use the camera's own
  pitch. At regional range the pivot pitch is up to about 8 degrees
  shallower, so "tilt to 5 degrees" from 900 km ended at -12.9 degrees.
  The swing now solves for the pivot pitch that gives the camera angle.
- A zoom out during a pivoting tilt stops at the 1,000 km pivot limit
  instead of the orbit's 5,000 km, and a zoom in that reaches the
  clearance is applied before the pivot ends.
- An angle the camera already holds still takes navigation, so it stops
  a running motion and releases the follow camera.
- A motion queued behind a fly-to moves to a newer fly-to as soon as it
  begins, so a stalled lookup no longer holds it past a flight that
  landed. Wait listeners are removed when a wait ends, and a lookup that
  found nothing points straight at the flight that moved.
- A fly-to that stops a running motion names it (stoppedMotion).
- A tilt left or right is refused, and the pitchDeg description says
  steps use direction.
This commit is contained in:
Manjunath committed 2026-11-05 11:36:29 +05:30
1 parent 4c9b27dc20
commit 1c59105a80
8 files changed
+310 -128

No files matched your search

+98 -17
View File
@@ -340,16 +340,16 @@ test('pitchDeg with a direction that agrees still tilts', (t) => {
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('the pitchDeg and direction check uses the pitch the swing starts from', (t) => {
// At regional range the Earth curves away: the pivot pitch is a degree or
// two shallower than the camera's (-30°), so -29° is still down from it.
test('the pitchDeg and direction check uses the camera pitch', (t) => {
// At regional range the Earth curves away and the pivot pitch is a degree
// or two shallower than the camera's (-30°); -29° is still up from it.
harness(t, { height: 150000, pitch: -30 });
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
assert.equal(up.ok, false);
assert.match(up.error, /tilts down, not up/);
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
assert.equal(down.ok, true);
assert.equal(down.direction, 'down');
assert.equal(down.ok, false);
assert.match(down.error, /tilts up, not down/);
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
assert.equal(up.ok, true);
assert.equal(up.direction, 'up');
});
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
@@ -414,6 +414,13 @@ test('a zoom in during a pivot tilt stops at the clearance', (t) => {
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.
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
// The camera must take that range, not just report it.
h.tick();
const floor = 150 / Math.sin(radians(40));
assert.ok(
Math.abs(pivotRange(h) - floor) < floor * 0.02,
`range ${pivotRange(h)} m, wanted about ${floor} m`,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
@@ -453,17 +460,39 @@ test('from globe range a tilt happens in place', (t) => {
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
});
test('a regional tilt-up nudge at the pivot limit refuses honestly', (t) => {
// 20 km up at -7°, the pivot 186 km out sits at -5.3°: already at the limit.
harness(t, { height: 20000, pitch: -7 });
const result = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(result.ok, false);
assert.match(result.error, /upper tilt limit/);
assert.equal(getActiveCameraMotion(), null);
for (const height of [100_000, 400_000, 900_000])
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
// The pivot pitch is shallower than the camera's by up to 8° at this
// range; the camera reached only -5.9° to -12.9° when the swing stopped
// at a pivot pitch of -5°.
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.pitchDeg, -5);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
});
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
// still tilt up 2°.
const h = harness(t, { height: 20000, pitch: -7 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a voice tilt from a tracked view judges its limit from the pivot it will use', (t) => {
const h = harness(t, { height: 20000, pitch: -7 });
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
const h = harness(t, { height: 20000, pitch: -5.2 });
h.viewer.trackedEntity = { id: 'tracked' };
// Voice releases the follow camera before the motion starts.
const release = (start) => {
@@ -474,3 +503,55 @@ test('a voice tilt from a tracked view judges its limit from the pivot it will u
assert.equal(result.ok, false);
assert.match(result.error, /upper tilt limit/);
});
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
const h = harness(t, { height: 900_000 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
h.tick();
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
let farthest = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
farthest = Math.max(farthest, pivotRange(h));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('an unchanged angle still stops a running motion through navigation', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
let handoffs = 0;
const navigate = (start) => {
handoffs += 1;
return start();
};
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(handoffs, 1, 'the request must take navigation');
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const held = pitchDeg(h.camera);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(pitchDeg(h.camera), held);
});
for (const direction of ['left', 'right'])
test(`a tilt ${direction} is refused`, (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', direction });
assert.equal(result.ok, false);
assert.match(result.error, /tilt needs up or down/);
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
assert.equal(angled.ok, false);
assert.match(angled.error, /pitchDeg alone/);
assert.equal(getActiveCameraMotion(), null);
});
+86 -68
View File
@@ -1267,62 +1267,63 @@ function onTick() {
/**
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
* so the place under the crosshair stays centred; without one it looks up or
* down in place. Pitch is clamped to [-89°, -5°] only in the direction of
* travel: a top-down view sits at -90°, below the limit, and must still be
* able to tilt up out of it. A pivot swing toward the horizon lowers the
* camera, so it stops at TILT_MIN_CLEARANCE_M and the rest tilts in place.
* down in place. Every angle and limit is the camera's own pitch, which at
* regional range is several degrees steeper than the pivot's (the Earth
* curves away), so the swing solves for the pivot pitch that gives it. Pitch
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
* sits at -90°, below the limit, and must still be able to tilt up out of it.
* A pivot swing toward the horizon lowers the camera, so it stops at
* TILT_MIN_CLEARANCE_M and the rest tilts in place.
*/
function tickTilt(cam, m, scale, dt) {
let step;
if (m.mode === 'once') {
step = m.tiltRad * (scale - (m.lastScale || 0));
m.lastScale = scale;
} else {
step =
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
dt *
(m.direction === 'up' ? 1 : -1);
}
const from = cam.pitch;
const want =
m.mode === 'once'
? m.startPitch + (m.goalPitch - m.startPitch) * scale
: from +
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
dt *
(m.direction === 'up' ? 1 : -1);
const up = want > from;
const limited = up
? Math.min(want, Math.max(from, PITCH_MAX))
: Math.max(want, Math.min(from, PITCH_MIN));
if (m.hpr) {
const from = m.hpr.pitch;
const next = from + step;
const limited =
step > 0
? Math.min(next, Math.max(from, tiltPivotPitchMax(m.hpr.range)))
: Math.max(next, Math.min(from, PITCH_MIN));
m.hpr.pitch = limited;
// Apply a range a zoom set since the last frame, even when the pitch
// then has nowhere to go (a zoom in to the clearance ends the pivot).
cam.lookAt(m.target, m.hpr);
// Up toward the horizon the swing lowers the camera: hold the clearance.
const cap = up
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
: Infinity;
for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) {
const pitch = Math.min(
cap,
Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch),
);
if (pitch === m.hpr.pitch) break;
m.hpr.pitch = pitch;
cam.lookAt(m.target, m.hpr);
}
// A once motion that just completed released the lookAt frame before
// this last step re-took it.
if (_active !== m) clearLookAt();
if (limited === next) return;
if (step < 0 || limited >= PITCH_MAX) {
interruptCameraMotion('tilt-clamp');
return;
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
// Down to its clearance: let go of the pivot and tilt the rest in
// place, which keeps the height.
if (_active === m) clearLookAt();
m.hpr = null;
m.target = null;
}
// Down to its clearance: let go of the pivot and tilt the rest in place,
// which keeps the height.
if (_active === m) clearLookAt();
m.hpr = null;
m.target = null;
step = next - limited;
}
const from = cam.pitch;
const next = from + step;
const limited =
step > 0
? Math.min(next, Math.max(from, PITCH_MAX))
: Math.max(next, Math.min(from, PITCH_MIN));
cam.lookUp(limited - from);
if (limited !== next) interruptCameraMotion('tilt-clamp');
if (!m.hpr) cam.lookUp(limited - cam.pitch);
if (m.mode !== 'once' && limited !== want)
interruptCameraMotion('tilt-clamp');
}
/** Shallowest pivot pitch that keeps the clearance at this range. */
function tiltPivotPitchMax(range) {
return Math.min(
PITCH_MAX,
-Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)),
);
return -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range));
}
/**
@@ -1331,12 +1332,11 @@ function tiltPivotPitchMax(range) {
* range, or while an entity is tracked (the follow camera owns that frame;
* voice releases it before moving, so a voice tilt still pivots).
*/
function tiltStartFrame(cam, { releasesTracking = false } = {}) {
const tracked = Boolean(_viewer.trackedEntity) && !releasesTracking;
const target = tracked ? null : _getTarget?.(_viewer);
function tiltStartFrame(cam) {
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
let pivot = target ? tiltPivot(cam, target) : null;
if (pivot && pivot.range > TILT_PIVOT_MAX_RANGE_M) pivot = null;
return { target, pivot, from: pivot ? pivot.pitch : cam.pitch };
return { target, pivot, from: cam.pitch };
}
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
@@ -1604,7 +1604,14 @@ export function moveCameraArgsError(args = {}) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`,
};
}
if (motion === 'tilt' && (direction === 'left' || direction === 'right')) {
return {
ok: false,
action: 'move_camera',
error: 'tilt needs up or down, or pitchDeg alone for an angle.',
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
@@ -1664,13 +1671,10 @@ export function moveCamera(args = {}, runNavigation = null) {
if (argsError) return argsError;
const tiltToPitch =
motion === 'tilt' ? requestedTiltPitch(args, direction) : null;
// A managed (voice) navigation releases the follow camera before start(),
// so checks made now must judge from the frame start() will see.
const releasesTracking = typeof runNavigation === 'function';
if (motion === 'tilt' && tiltToPitch !== null) {
// 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.
const { from } = tiltStartFrame(_viewer.camera, { releasesTracking });
// Angles and limits are the camera's own pitch, whatever the swing pivots
// about; releasing the follow camera does not change it.
const from = _viewer.camera.pitch;
// -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;
@@ -1678,15 +1682,21 @@ export function moveCamera(args = {}, runNavigation = null) {
atTopDown ||
Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
) {
return {
ok: true,
action: 'move_camera',
motion,
pitchDeg: Math.round(
Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
),
unchanged: true,
// Still a camera request: it stops a running motion and releases the
// follow camera, as a tilt that moves would.
const hold = () => {
interruptCameraMotion('replaced');
return {
ok: true,
action: 'move_camera',
motion,
pitchDeg: Math.round(
Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
),
unchanged: true,
};
};
return typeof runNavigation === 'function' ? runNavigation(hold) : hold();
}
// pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
// as {direction:'down', pitchDeg:10} would tilt up from -45°.
@@ -1703,8 +1713,7 @@ export function moveCamera(args = {}, runNavigation = null) {
} else if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
// The clamp reads the pitch the motion starts from.
const pitch = tiltStartFrame(_viewer.camera, { releasesTracking }).from;
const pitch = _viewer.camera.pitch;
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
return {
ok: false,
@@ -1821,17 +1830,23 @@ export function moveCamera(args = {}, runNavigation = null) {
state.target = target;
state.hpr = pivot;
}
state.startPitch = from;
if (tiltToPitch !== null) {
state.mode = 'once';
state.direction = tiltToPitch > from ? 'up' : 'down';
state.tiltRad = tiltToPitch - from;
state.goalPitch = tiltToPitch;
state.durationS = Math.max(
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(state.tiltRad)) / TILT_TO_DEG_S,
Math.abs(Cesium.Math.toDegrees(tiltToPitch - from)) / TILT_TO_DEG_S,
);
} else {
state.tiltRad =
// A nudge stops at the limit it travels toward.
const step =
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
state.goalPitch =
step > 0
? Math.min(from + step, Math.max(from, PITCH_MAX))
: Math.max(from + step, Math.min(from, PITCH_MIN));
}
}
if (motion === 'rotate_globe') {
@@ -1954,10 +1969,13 @@ export function adjustOrbitRange(factor) {
kind === 'tilt'
? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch)))
: 80;
// A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing
// would carry the camera thousands of km.
const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000;
const range = hpr.range;
hpr.range = Math.max(
Math.min(floor, range),
Math.min(Math.max(5_000_000, range), range * factor),
Math.min(Math.max(ceiling, range), range * factor),
);
// Under half a metre counts as not moved, as for a plain zoom.
return { kind, changed: Math.abs(hpr.range - range) >= 0.5 };
+2 -2
View File
@@ -56,8 +56,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// area scopes and the osm-places layer; natural-region candidates and the
// tightened area tool wording follow review.
// move_camera tilt takes an optional pitchDeg angle, described as
// absolute rather than a step.
'1dbdef45a477806046cfe4a74a7766f137d7a2b551fdd0b9595986068a85ee8d',
// absolute, with steps sent as a direction.
'b192c42d1a1c59bea010da93ed0cfb1ce63d2e3ab5e5685921d3bf66e3f94e26',
);
});
+76 -37
View File
@@ -49,6 +49,7 @@ import {
adjustOrbitRange,
manualCameraInputCount,
moveCameraArgsError,
getActiveCameraMotion,
} from '../cameraVerbs.js';
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
@@ -293,6 +294,8 @@ export function createGevActionRunner({
// cancelled the flight, or the flight (still geocoding) then replaced the
// motion. The wait follows later fly-tos (voiceFlightTarget).
let voiceFlight = null;
// Waiters to tell when a newer voice flight begins.
const voiceFlightWatchers = new Set();
let analystEngine;
// Area handles live for this runner (the page): resolve_area, the
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
@@ -1153,6 +1156,25 @@ export function createGevActionRunner({
prior,
);
voiceFlight = flight;
for (const notify of [...voiceFlightWatchers]) notify();
// A motion already running (one the model sent before this fly-to) is
// stopped when the flight takes navigation; the result says so.
let stoppedMotion = null;
const noteStop =
(hook) =>
(...hookArgs) => {
const before = getActiveCameraMotion();
try {
return hook(...hookArgs);
} finally {
if (
before &&
!stoppedMotion &&
getActiveCameraMotion()?.motionId !== before.motionId
)
stoppedMotion = before.kind;
}
};
const flown = flyToRequestedLocation(viewer, args, {
onFlightEnd: flight.settle,
onProgress: reportProgress,
@@ -1161,26 +1183,28 @@ export function createGevActionRunner({
signal: runOptions.signal,
runImmediate:
typeof styleManager?.runImmediateLocationNavigation === 'function'
? (navigate) =>
styleManager.runImmediateLocationNavigation(navigate)
? noteStop((navigate) =>
styleManager.runImmediateLocationNavigation(navigate),
)
: null,
beginDeferred:
typeof styleManager?.beginDeferredLocationNavigation === 'function'
? () => styleManager.beginDeferredLocationNavigation()
? noteStop(() => styleManager.beginDeferredLocationNavigation())
: null,
reassertDeferred:
typeof styleManager?.reassertDeferredLocationNavigation === 'function'
? (generation) =>
styleManager.reassertDeferredLocationNavigation(generation)
? noteStop((generation) =>
styleManager.reassertDeferredLocationNavigation(generation),
)
: null,
onStart: () => {
onStart: noteStop(() => {
if (typeof styleManager?.beginLocationNavigation === 'function') {
styleManager.beginLocationNavigation();
return;
}
interruptCameraMotion('nav:fly_to_location');
if (viewer.trackedEntity) stopAllTracking(viewer, dataManager);
},
}),
});
// No flight started: nothing to wait for. A lookup that was not found,
// threw, or was cancelled once a newer voice fly-to had begun (which
@@ -1199,7 +1223,11 @@ export function createGevActionRunner({
flight.settle('failed');
else notStarted();
}, notStarted);
return flown;
return flown.then((result) =>
stoppedMotion && result && typeof result === 'object'
? { ...result, stoppedMotion }
: result,
);
}
if (name === 'adjust_camera_zoom') {
@@ -1315,35 +1343,31 @@ export function createGevActionRunner({
if (argsError) return argsError;
const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS;
const start = pending;
// Act on where the camera lands. Whatever a flight reports, a later
// fly-to replaces it, and the wait moves to that one as soon as it
// begins (a stalled geocode must not hold the motion past a newer
// flight that landed). 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. A later lookup that found nothing moved nothing, so the
// motion looks past it to the last flight that did move the camera.
let flight = start;
let status = await waitForVoiceFlight(
flight,
runOptions.signal,
deadline,
);
// A later fly-to replaces this one: follow it to where the camera
// lands, within the same deadline, whatever this flight reported. 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. A later
// lookup that found nothing moved nothing, so the motion acts on the
// last flight it saw that did move the camera.
let landed = flight.movedNothing ? null : { flight, status };
while (status !== 'aborted' && status !== 'timeout' && current()) {
const target = voiceFlightTarget(voiceFlight, start);
if (!target || target === flight) break;
flight = target;
let status;
for (;;) {
status = await waitForVoiceFlight(
flight,
runOptions.signal,
deadline,
voiceFlightWatchers,
);
if (!flight.movedNothing) landed = { flight, status };
if (status === 'aborted' || status === 'timeout' || !current()) break;
const target = voiceFlightTarget(voiceFlight, start);
if (!target || (target === flight && status !== 'newer')) break;
flight = target;
}
if (flight.movedNothing && landed) ({ flight, status } = landed);
if (!current())
return { ok: false, action: 'move_camera', cancelled: true };
if (status !== 'arrived')
if (status !== 'arrived' || flight.movedNothing)
return {
ok: false,
action: 'move_camera',
@@ -2973,7 +2997,6 @@ function trackVoiceFlight(
inputCount = manualCameraInputCount(),
replaced = null,
) {
let resolve;
const flight = {
done: false,
seq: (voiceFlightCount += 1),
@@ -2981,14 +3004,20 @@ function trackVoiceFlight(
movedNothing: false,
// Manual input after this means the user took the camera mid-flight.
inputCount,
ended: new Promise((settle) => {
resolve = settle;
}),
status: null,
// Waiters to tell when this flight ends.
watchers: new Set(),
settle: (status) => {
if (flight.done) return;
flight.done = true;
if (!flight.movedNothing) flight.replaced = null;
resolve(status);
flight.status = status;
// Kept only past a lookup that found nothing, and then straight to the
// flight that moved, so a run of such lookups holds no chain.
flight.replaced = flight.movedNothing
? voiceFlightTarget(flight.replaced)
: null;
for (const notify of [...flight.watchers]) notify(status);
flight.watchers.clear();
},
};
return flight;
@@ -3027,22 +3056,32 @@ const VOICE_FLIGHT_WAITING_MOTIONS = new Set([
'rotate',
]);
/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */
function waitForVoiceFlight(flight, signal, deadline) {
/**
* Wait for a voice flight to end, or for a newer one to begin ('newer'):
* 'timeout' or 'aborted' if neither happens. A flight that has ended answers
* at once. Every listener is removed when the wait ends, so a flight that
* never settles holds nothing.
*/
function waitForVoiceFlight(flight, signal, deadline, newerWatchers) {
if (signal?.aborted) return Promise.resolve('aborted');
if (flight.done) return Promise.resolve(flight.status);
return new Promise((resolve) => {
const finish = (status) => {
clearTimeout(timer);
signal?.removeEventListener?.('abort', onAbort);
flight.watchers.delete(finish);
newerWatchers?.delete(onNewer);
resolve(status);
};
const onAbort = () => finish('aborted');
const onNewer = () => finish('newer');
const timer = setTimeout(
() => finish('timeout'),
Math.max(0, deadline - Date.now()),
);
signal?.addEventListener?.('abort', onAbort, { once: true });
flight.ended.then(finish);
flight.watchers.add(finish);
newerWatchers?.add(onNewer);
});
}
+44
View File
@@ -895,6 +895,50 @@ test('a motion gives up on a flight that has not landed in 15 s', async (t) => {
assert.match(result.error, /did not arrive/);
});
test('a motion follows a newer fly-to that lands while its own lookup stalls', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
// Place A's lookup never answers.
searchNavigation: () => new Promise(() => {}),
});
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
runner('fly_to_location', { query: 'Place A' });
const moved = runner('move_camera', { motion: 'orbit' });
const movedDone = settledYet(moved);
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
t.mock.timers.tick(2_000);
await Promise.resolve();
assert.equal(movedDone(), false, 'the orbit must wait for the newer flight to land');
flights[0].complete();
for (let i = 0; i < 5 && !movedDone(); i += 1) await Promise.resolve();
assert.equal(movedDone(), true, 'the orbit must not wait out A');
const result = await moved;
assert.equal(result.ok, true);
assert.equal(result.motion, 'orbit');
});
test('a fly-to that stops a running motion says so', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
// The model sent the orbit first, so it ran at once.
const orbit = await runner('move_camera', { motion: 'orbit', mode: 'continuous' });
assert.equal(orbit.ok, true);
const fly = await runner('fly_to_location', { locationId: 'austin' });
assert.equal(fly.ok, true);
assert.equal(fly.stoppedMotion, 'orbit');
const again = await runner('fly_to_location', { locationId: 'austin' });
assert.equal('stoppedMotion' in again, false);
});
test('bad move_camera arguments are refused without waiting for a flight', async () => {
const { viewer, styleManager } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
+2 -2
View File
@@ -350,8 +350,8 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in
// the analyst area scopes) raised the ceiling to 55,500; tightening the
// AREAS rule and the area tool descriptions left it at 55,103 as measured
// below (every tool, osm_query included); main's Sea Marks and Ski Pistes
// layers bring it to 55,210, and the tilt pitchDeg argument to 55,332.
// integrationContract's Overpass prompt is the tightest, at 55,492 (8
// layers bring it to 55,210, and the tilt pitchDeg argument to 55,335.
// integrationContract's Overpass prompt is the tightest, at 55,495 (5
// under). A new tool or rule has to fit under this ceiling.
const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length;
assert.ok(size <= 55_500, `prompt size ${size}`);