Follow up voice tilt review: superseded flights, wait, globe range

- A motion waiting on a voice fly-to follows a later fly-to in the
  same reply that cancels it, within one deadline, instead of
  reporting that its flight did not arrive. A stale reply stops
  following.
- The wait is 15 s, down from 30 s: it holds the tool output, and on
  Gemini the later tools in the turn.
- Beyond 1,000 km of range a tilt happens in place instead of
  swinging thousands of km about the crosshair point.
- The tilt-nudge limits read the pivot pitch the motion starts from,
  including for a voice tilt from a tracked view, whose follow camera
  is released before it moves.
- move_camera's argument checks run before the flight wait, so a
  malformed call is refused at once.
- Tests: t.after cleanup in the fly-then-motion loop, the pitchDeg
  pin asserts the field before removing it, and the handoff-order
  pin follows the extracted argument checks.
This commit is contained in:
Manjunath committed 2026-11-03 20:05:34 +05:30
1 parent 2aef67197c
commit 9e64d517ad
7 files changed
+266 -79

No files matched your search

+1 -1
View File
@@ -5372,7 +5372,7 @@ and unreachable upstream (502/504) separately from road geometry.
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constLine truncated
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
+10 -1
View File
@@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca
'move camera',
);
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'if (argsError) return argsError;',
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff');
const moveArgs = body(
cameraVerbs,
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
'move camera argument checks',
);
assert.ok(
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
'unknown motions are refused before handoff',
);
const route = body(
cameraVerbs,
+32
View File
@@ -368,3 +368,35 @@ test('a zoom out during a far orbit never pulls the camera in', (t) => {
h.tick();
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
});
test('from globe range a tilt happens in place', (t) => {
// A pivot 2,000 km out would swing the camera thousands of km.
const h = harness(t, { height: 2_000_000 });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
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);
});
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 });
h.viewer.trackedEntity = { id: 'tracked' };
// Voice releases the follow camera before the motion starts.
const release = (start) => {
h.viewer.trackedEntity = undefined;
return start();
};
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
assert.equal(result.ok, false);
assert.match(result.error, /upper tilt limit/);
});
+100 -65
View File
@@ -57,6 +57,11 @@ const TILT_TO_DEG_S = 30;
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
*/
const TILT_MIN_CLEARANCE_M = 150;
/**
* Beyond this range a pivot would swing the camera thousands of km about the
* crosshair point, so the tilt happens in place instead.
*/
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
* Every knob an owner may want to retune lives in this block. The shaping is
@@ -1322,11 +1327,15 @@ function tiltPivotPitchMax(range) {
/**
* Where a tilt starts. It swings about the ground under the crosshair so the
* place stays centred; the follow camera owns a tracked view's frame.
* place stays centred. It tilts in place with no ground there, from globe
* 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) {
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
const pivot = target ? tiltPivot(cam, target) : null;
function tiltStartFrame(cam, { releasesTracking = false } = {}) {
const tracked = Boolean(_viewer.trackedEntity) && !releasesTracking;
const target = tracked ? 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 };
}
@@ -1542,6 +1551,83 @@ export function initCameraVerbs(
}
}
/**
* A tilt's angle: the one asked for, the default oblique view when a bare
* "tilt the camera" names neither angle nor direction, or null for a nudge.
*/
function requestedTiltPitch(args, direction) {
// Only a real number or numeric text: Number('') and Number(null) are 0,
// which would swing the camera to the horizon.
const raw = args.pitchDeg;
const requested =
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
? Number(raw)
: NaN;
// The camera never looks above the horizon, so a positive angle can only
// mean the same angle below it ("tilt 45 degrees" sent as 45).
if (Number.isFinite(requested))
return Cesium.Math.clamp(
Cesium.Math.toRadians(-Math.abs(requested)),
PITCH_MIN,
PITCH_MAX,
);
return direction === null
? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG)
: null;
}
/**
* Argument errors move_camera refuses whatever the camera is doing, so a
* caller holding a motion back behind a flight can refuse it before waiting.
* @param {Object} args
* @returns {Object|null} The refusal, or null when the arguments are usable.
*/
export function moveCameraArgsError(args = {}) {
const motion = String(args.motion || '').toLowerCase();
const direction = args.direction
? String(args.direction).toLowerCase()
: null;
if (motion === 'stop') return null;
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
(motion !== 'tilt' || requestedTiltPitch(args, direction) === null) &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
return null;
}
/**
* move_camera implementation. Returns the house result shape; rejections are
* plain-English `ok:false` errors.
@@ -1574,69 +1660,17 @@ export function moveCamera(args = {}, runNavigation = null) {
};
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
}
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
// Tilt to an angle: the one asked for, or a default oblique view when a
// bare "tilt the camera" names neither angle nor direction.
let tiltToPitch = null;
if (motion === 'tilt') {
// Only a real number or numeric text: Number('') and Number(null) are 0,
// which would swing the camera to the horizon.
const raw = args.pitchDeg;
const requested =
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
? Number(raw)
: NaN;
// The camera never looks above the horizon, so a positive angle can only
// mean the same angle below it ("tilt 45 degrees" sent as 45).
if (Number.isFinite(requested))
tiltToPitch = Cesium.Math.clamp(
Cesium.Math.toRadians(-Math.abs(requested)),
PITCH_MIN,
PITCH_MAX,
);
else if (direction === null)
tiltToPitch = Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG);
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
tiltToPitch === null &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
const argsError = moveCameraArgsError(args);
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);
const { from } = tiltStartFrame(_viewer.camera, { releasesTracking });
if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) {
return {
ok: true,
@@ -1661,7 +1695,8 @@ 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).
const pitch = _viewer.camera.pitch;
// The clamp reads the pitch the motion starts from.
const pitch = tiltStartFrame(_viewer.camera, { releasesTracking }).from;
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
return {
ok: false,
+1
View File
@@ -1018,6 +1018,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// the legacy camera fields to keep the pin.
const camera = legacyTools.find((tool) => tool.name === 'move_camera')
.parameters.properties;
assert.equal(camera.pitchDeg.type, 'number');
delete camera.pitchDeg;
camera.direction.description =
'Required for pan/tilt/rotate; orbit/rotate_globe default right.';
+32 -6
View File
@@ -48,6 +48,7 @@ import {
interruptCameraMotion,
adjustOrbitRange,
manualCameraInputCount,
moveCameraArgsError,
} from '../cameraVerbs.js';
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
@@ -1198,8 +1199,26 @@ export function createGevActionRunner({
voiceFlight &&
!voiceFlight.done
) {
const flight = voiceFlight;
const status = await waitForVoiceFlight(flight, runOptions.signal);
// Bad arguments are refused now, not after waiting on the flight.
const argsError = moveCameraArgsError(args);
if (argsError) return argsError;
const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS;
let flight = voiceFlight;
let status = await waitForVoiceFlight(
flight,
runOptions.signal,
deadline,
);
// A later fly-to in the same reply cancels this one: follow it to
// where the camera lands, within the same deadline.
while (status === 'cancelled' && voiceFlight !== flight && current()) {
flight = voiceFlight;
status = await waitForVoiceFlight(
flight,
runOptions.signal,
deadline,
);
}
if (!current())
return { ok: false, action: 'move_camera', cancelled: true };
if (status !== 'arrived')
@@ -2759,8 +2778,12 @@ function trackVoiceFlight(inputCount = manualCameraInputCount()) {
return flight;
}
/** Longest a motion waits for a flight (geocode plus flight) before giving up. */
const VOICE_FLIGHT_WAIT_MS = 30_000;
/**
* Longest a motion waits for its flight (geocode plus flight, which is about
* 2 s) before giving up. The wait also holds the tool output, and on Gemini
* the later tools in the turn.
*/
const VOICE_FLIGHT_WAIT_MS = 15_000;
/**
* Motions that act on the place the camera ends up at, so one sent beside a
@@ -2775,7 +2798,7 @@ const VOICE_FLIGHT_WAITING_MOTIONS = new Set([
]);
/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */
function waitForVoiceFlight(flight, signal) {
function waitForVoiceFlight(flight, signal, deadline) {
if (signal?.aborted) return Promise.resolve('aborted');
return new Promise((resolve) => {
const finish = (status) => {
@@ -2784,7 +2807,10 @@ function waitForVoiceFlight(flight, signal) {
resolve(status);
};
const onAbort = () => finish('aborted');
const timer = setTimeout(() => finish('timeout'), VOICE_FLIGHT_WAIT_MS);
const timer = setTimeout(
() => finish('timeout'),
Math.max(0, deadline - Date.now()),
);
signal?.addEventListener?.('abort', onAbort, { once: true });
flight.ended.then(finish);
});
+90 -6
View File
@@ -347,7 +347,8 @@ for (const motion of [
{ motion: 'pan', direction: 'left' },
{ motion: 'rotate', direction: 'right' },
]) {
test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async () => {
test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, order, flights } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
@@ -368,10 +369,10 @@ for (const motion of [
const result = await moved;
assert.equal(result.ok, true);
assert.equal(result.motion, motion.motion);
interruptCameraMotion('test-cleanup');
});
test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async () => {
test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager } = tiltAfterFlightHarness();
let finishSearch;
let landFlight;
@@ -399,7 +400,6 @@ for (const motion of [
landFlight();
assert.equal((await moved).ok, true);
assert.equal(getActiveCameraMotion()?.kind, motion.motion);
interruptCameraMotion('test-cleanup');
});
test(`${motion.motion} waiting on a flight that never lands is not run`, async () => {
@@ -470,7 +470,8 @@ test('a drag during the first of two chained fly-tos still drops the queued moti
assert.match(result.error, /moved the camera during the flight/);
});
test('input before a fly-to does not drop the motion queued behind it', async () => {
test('input before a fly-to does not drop the motion queued behind it', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const canvasListeners = new Map();
viewer.scene.canvas.addEventListener = (type, listener) => {
@@ -486,7 +487,90 @@ test('input before a fly-to does not drop the motion queued behind it', async ()
const moved = runner('move_camera', { motion: 'orbit' });
flights[0].complete();
assert.equal((await moved).ok, true);
interruptCameraMotion('test-cleanup');
});
test('a motion follows a later fly-to in the same reply that cancels its flight', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'orbit' });
const movedDone = settledYet(moved);
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
// The second flight supersedes the first, as Cesium's flyTo does.
flights[0].cancel();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(movedDone(), false, 'the orbit must wait for the second flight');
flights[1].complete();
const result = await moved;
assert.equal(result.ok, true);
assert.equal(result.motion, 'orbit');
});
test('a stale motion does not follow a newer reply\'s fly-to', async () => {
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
let live = true;
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'orbit' }, { isCurrent: () => live });
// A newer reply takes over and flies elsewhere; this reply is stale.
live = false;
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
flights[0].cancel();
// The newer flight never lands here: following it would hold this answer.
const result = await Promise.race([
moved,
new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)),
]);
assert.notEqual(result, 'still waiting');
assert.equal(result.ok, false);
assert.equal(result.cancelled, true);
});
test('a motion gives up on a flight that has not landed in 15 s', async (t) => {
const { viewer, styleManager } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const moved = runner('move_camera', { motion: 'orbit' });
const movedDone = settledYet(moved);
await Promise.resolve();
t.mock.timers.tick(14_999);
await Promise.resolve();
assert.equal(movedDone(), false);
t.mock.timers.tick(1);
const result = await moved;
assert.equal(result.ok, false);
assert.match(result.error, /did not arrive/);
});
test('bad move_camera arguments are refused without waiting for a flight', async () => {
const { viewer, styleManager } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'pan' });
const movedDone = settledYet(moved);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(movedDone(), true, 'the refusal must not wait on the flight');
const result = await moved;
assert.equal(result.ok, false);
assert.match(result.error, /needs a direction/);
});
test('stop does not wait for a voice flight', async () => {