mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
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:
1 parent
4c9b27dc20
commit
1c59105a80
8 files changed
+310
-128
No files matched your search
+98
-17
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in new issue
Block a user