Address review: zoom, drag, clearance and pitchDeg for voice tilt

- A zoom during a pivoting tilt now scales the range the tilt holds,
  as it already did for orbit, instead of being undone on the next
  frame while reporting success. The zoom limits no longer move a view
  the wrong way: a zoom in leaves a view closer than 80 m in place, and
  a zoom out leaves an orbit beyond 5,000 km where it is.
- A grab, touch or wheel on the map while a voice fly-to is in flight
  drops the motion queued behind it. Cesium lands the flight anyway,
  so the orbit or tilt would otherwise take the camera back. Chained
  fly-tos in one reply keep the earliest snapshot.
- A pivot tilt toward the horizon stops lowering the camera at 150 m
  above the pivot and tilts the rest in place, so a close view no
  longer ends inside tiles or a hillside.
- pitchDeg is an absolute angle. A direction that contradicts it is
  refused, judged from the pitch the swing starts at, and the tool
  description says so.
This commit is contained in:
Manjunath committed 2026-11-03 19:35:02 +05:30
1 parent 2ac73519ec
commit 5c74b860b7
8 files changed
+331 -31

No files matched your search

+146
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
adjustOrbitRange,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
@@ -222,3 +223,148 @@ test('a pivoting tilt yields when tracking takes the camera', (t) => {
h.viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal(getActiveCameraMotion(), null);
});
/** Metres between the camera and the point it pivots about. */
const pivotRange = (h) =>
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
const heightM = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
h.tick();
const before = pivotRange(h);
assert.equal(adjustOrbitRange(0.5), true, 'the tilt must take the zoom');
for (let i = 0; i < 5; i += 1) h.tick();
// The next frame's lookAt must not put the old range back.
assert.ok(
Math.abs(pivotRange(h) - before / 2) < 1,
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
);
});
test('a zoom with no pivot to hold is left to the camera', (t) => {
harness(t, { pivot: false });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
assert.equal(adjustOrbitRange(0.5), false);
});
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
const h = harness(t, { height: 250 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
let lowest = Infinity;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
lowest = Math.min(lowest, heightM(h.camera));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('a continuous tilt up from close in keeps its clearance', (t) => {
const h = harness(t, { height: 250 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', pitchDeg: -5 });
h.finish();
// 4 km of range clears the floor at every pitch: all pivot, no drift.
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('pitchDeg against a contradicting direction is refused', (t) => {
harness(t, { pitch: -45 });
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: 10,
});
assert.equal(result.ok, false);
assert.match(result.error, /absolute angle/);
assert.equal(getActiveCameraMotion(), null);
});
test('pitchDeg with a direction that agrees still tilts', (t) => {
const h = harness(t, { pitch: -45 });
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: -80,
});
assert.equal(result.ok, true);
assert.equal(result.direction, 'down');
h.finish();
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.
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');
});
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
const h = harness(t, { height: 1000 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
while (pitchDeg(h.camera) < -30) h.tick();
adjustOrbitRange(1 / 2.4);
adjustOrbitRange(1 / 2.4);
h.tick();
const afterZoom = heightM(h.camera);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(
heightM(h.camera) > afterZoom - 1,
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
// 62 m of range, under the 80 m zoom floor.
const h = harness(t, { height: 40, pitch: -40 });
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
h.tick();
const before = pivotRange(h);
assert.ok(before < 80, `range ${before} m`);
adjustOrbitRange(1 / 1.6);
h.tick();
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
});
test('a zoom out during a far orbit never pulls the camera in', (t) => {
// 15,000 km up, beyond the 5,000 km zoom cap.
const h = harness(t, { height: 15_000_000 });
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
h.tick();
const before = pivotRange(h);
assert.ok(before > 5_000_000, `range ${before} m`);
assert.equal(adjustOrbitRange(2.4), true);
h.tick();
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
});
+90 -23
View File
@@ -52,6 +52,11 @@ const PITCH_MAX = Cesium.Math.toRadians(-5);
const TILT_DEFAULT_PITCH_DEG = -45;
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
const TILT_TO_DEG_S = 30;
/**
* A pivoting tilt keeps the range, so its height above the pivot is
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
*/
const TILT_MIN_CLEARANCE_M = 150;
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
* Every knob an owner may want to retune lives in this block. The shaping is
@@ -970,6 +975,17 @@ let _trackingRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
/** Canvas grabs, touches and wheel turns since load. */
let _manualInputCount = 0;
/**
* Lets a caller holding a motion back (behind a fly-to) see whether the user
* took the camera meanwhile; Cesium does not cancel a flight on input.
* @returns {number} Manual camera inputs so far.
*/
export function manualCameraInputCount() {
return _manualInputCount;
}
/**
* Install a motion and stamp it with a fresh id. The id is what lets a caller
@@ -1248,7 +1264,8 @@ function onTick() {
* 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.
* 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;
@@ -1261,24 +1278,58 @@ function tickTilt(cam, m, scale, dt) {
dt *
(m.direction === 'up' ? 1 : -1);
}
const from = m.hpr ? m.hpr.pitch : 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));
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;
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();
} else {
cam.lookUp(limited - from);
if (limited === next) return;
if (step < 0 || limited >= PITCH_MAX) {
interruptCameraMotion('tilt-clamp');
return;
}
// 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');
}
/** 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)),
);
}
/**
* 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.
*/
function tiltStartFrame(cam) {
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
const pivot = target ? tiltPivot(cam, target) : null;
return { target, pivot, from: pivot ? pivot.pitch : cam.pitch };
}
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
function tiltPivot(cam, target) {
const local = Cesium.Matrix4.multiplyByPoint(
@@ -1482,7 +1533,10 @@ export function initCameraVerbs(
const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input');
const h = () => {
_manualInputCount += 1;
interruptCameraMotion('manual-input');
};
canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
}
@@ -1580,9 +1634,10 @@ export function moveCamera(args = {}, runNavigation = null) {
};
}
if (motion === 'tilt' && tiltToPitch !== null) {
if (
Math.abs(tiltToPitch - _viewer.camera.pitch) < Cesium.Math.toRadians(0.5)
) {
// 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);
if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) {
return {
ok: true,
action: 'move_camera',
@@ -1591,6 +1646,18 @@ export function moveCamera(args = {}, runNavigation = null) {
unchanged: true,
};
}
// pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
// as {direction:'down', pitchDeg:10} would tilt up from -45°.
const towards = tiltToPitch > from ? 'up' : 'down';
if ((direction === 'up' || direction === 'down') && direction !== towards) {
const target = Math.round(Cesium.Math.toDegrees(tiltToPitch));
const now = Math.round(Cesium.Math.toDegrees(from));
return {
ok: false,
action: 'move_camera',
error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`,
};
}
} 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).
@@ -1706,16 +1773,11 @@ export function moveCamera(args = {}, runNavigation = null) {
mode,
};
if (motion === 'tilt') {
const cam = _viewer.camera;
// Swing about the ground under the crosshair so the place stays
// centred. The follow camera owns a tracked view's frame.
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
const pivot = target ? tiltPivot(cam, target) : null;
const { target, pivot, from } = tiltStartFrame(_viewer.camera);
if (pivot) {
state.target = target;
state.hpr = pivot;
}
const from = pivot ? pivot.pitch : cam.pitch;
if (tiltToPitch !== null) {
state.mode = 'once';
state.direction = tiltToPitch > from ? 'up' : 'down';
@@ -1833,14 +1895,19 @@ export function moveCamera(args = {}, runNavigation = null) {
/**
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
* fight the per-frame lookAt (field test: "zoom out did literally nothing").
* A tilt swinging about its pivot holds a range the same way.
* @param {number} factor >1 zooms out (radius grows), <1 zooms in.
* @returns {boolean} true when an orbit consumed the zoom.
* @returns {boolean} true when an orbit or pivoting tilt consumed the zoom.
*/
export function adjustOrbitRange(factor) {
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false;
if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr)
return false;
// The limits never move a view the wrong way: a zoom in leaves a closer
// view in place, and a zoom out leaves a farther one.
const range = _active.hpr.range;
_active.hpr.range = Math.max(
80,
Math.min(5_000_000, _active.hpr.range * factor),
Math.min(80, range),
Math.min(Math.max(5_000_000, range), range * factor),
);
return true;
}
+3 -2
View File
@@ -49,8 +49,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Snow leaves the set_visual_style enum.
// The Operator HUD layout leaves set_hud's layout enum.
// The layer alias hint leaves out aliases the layer id already spells.
// move_camera tilt takes an optional pitchDeg angle.
'35261624ef8596608b80e2876f83760723ecf6592acc43fd77691cdb0a972608',
// move_camera tilt takes an optional pitchDeg angle, described as
// absolute rather than a step.
'7258f48a19dead97d1a52cd897355ac84cb1f9077f1e8573fd64e3937660f5d0',
);
});
+19 -3
View File
@@ -47,6 +47,7 @@ import {
flyRoute,
interruptCameraMotion,
adjustOrbitRange,
manualCameraInputCount,
} from '../cameraVerbs.js';
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
@@ -1107,7 +1108,10 @@ export function createGevActionRunner({
runOptions,
);
}
const flight = trackVoiceFlight();
// Chained fly-tos in one reply: a grab during the earlier one counts.
const flight = trackVoiceFlight(
voiceFlight && !voiceFlight.done ? voiceFlight.inputCount : undefined,
);
voiceFlight = flight;
const flown = flyToRequestedLocation(viewer, args, {
onFlightEnd: flight.settle,
@@ -1194,7 +1198,8 @@ export function createGevActionRunner({
voiceFlight &&
!voiceFlight.done
) {
const status = await waitForVoiceFlight(voiceFlight, runOptions.signal);
const flight = voiceFlight;
const status = await waitForVoiceFlight(flight, runOptions.signal);
if (!current())
return { ok: false, action: 'move_camera', cancelled: true };
if (status !== 'arrived')
@@ -1204,6 +1209,15 @@ export function createGevActionRunner({
motion,
error: `Not run: the flight this ${motion} was waiting for did not arrive.`,
};
// The flight lands even when the user grabs the globe mid-flight;
// the queued motion must not take the camera back from them.
if (manualCameraInputCount() !== flight.inputCount)
return {
ok: false,
action: 'move_camera',
motion,
error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`,
};
}
return moveCamera(args, (navigate, releaseOptions) =>
runManagedVoiceNavigation(
@@ -2722,10 +2736,12 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
}
/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */
function trackVoiceFlight() {
function trackVoiceFlight(inputCount = manualCameraInputCount()) {
let resolve;
const flight = {
done: false,
// Manual input after this means the user took the camera mid-flight.
inputCount,
ended: new Promise((settle) => {
resolve = settle;
}),
+69
View File
@@ -420,6 +420,75 @@ for (const motion of [
});
}
// Cesium lands a flight even when the user grabs the globe mid-flight, so the
// motion queued behind it must not then take the camera back.
for (const motion of [
{ motion: 'orbit' },
{ motion: 'tilt', pitchDeg: -45 },
]) {
test(`${motion.motion} queued behind a fly-to is dropped when the user drags mid-flight`, async () => {
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const canvasListeners = new Map();
viewer.scene.canvas.addEventListener = (type, listener) => {
canvasListeners.set(type, listener);
};
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);
canvasListeners.get('pointerdown')();
flights[0].complete();
const result = await moved;
assert.equal(result.ok, false);
assert.equal(result.motion, motion.motion);
assert.match(result.error, /moved the camera during the flight/);
assert.equal(getActiveCameraMotion(), null);
});
}
test('a drag during the first of two chained fly-tos still drops the queued motion', async () => {
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const canvasListeners = new Map();
viewer.scene.canvas.addEventListener = (type, listener) => {
canvasListeners.set(type, listener);
};
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
canvasListeners.get('touchstart')();
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'orbit' });
flights.at(-1).complete();
const result = await moved;
assert.equal(result.ok, false);
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 () => {
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const canvasListeners = new Map();
viewer.scene.canvas.addEventListener = (type, listener) => {
canvasListeners.set(type, listener);
};
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
canvasListeners.get('wheel')();
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'orbit' });
flights[0].complete();
assert.equal((await moved).ok, true);
interruptCameraMotion('test-cleanup');
});
test('stop does not wait for a voice flight', async () => {
const { viewer, styleManager } = tiltAfterFlightHarness();
const runner = createGevActionRunner({