fix(globe): the Navara follow orbits on drag and zooms to the floor

Review of Task 5: while following on Navara a left drag panned the globe
about the ground point under the cursor (range 2,154 m → 54,136 m) and the
wheel zoomed toward that point (462 m after 40 notches, Cesium stops at
the 150 m floor). While a follow runs, Navara's spin, zoom and tilt are
now suspended (input.suspendCameraInputs, which gives the app's own
setCameraInputs choice back after the release) and followGestures.js turns
a drag into heading and pitch of the ENU offset (a canvas width is one
turn, its height half a turn; elevation kept within 2°-89°) and a wheel
notch into 20 % of its range, never inside the minimum range, as Cesium's
EntityView orbits the target. The offset is no longer read back from a
camera the follow alone moves.

Also (review item 7): a follow requests a render only when the target, the
offset or a gesture changed, so a still target lets Navara idle.

The contract follow check drags (range within ±5 %, target within 3 px)
and turns the wheel 40 notches (never below 149 m, ends within 5 % of
150 m) on both engines with real input (qa-globe-contract's pointer gains
drag and wheel); after stop it waits for Cesium's gesture inertia to end
before checking that the camera is free. Contract: Cesium 73/73, Navara
79/79.
This commit is contained in:
German Khokhlov committed 2026-10-16 20:57:10 +04:00
1 parent 68ff4e1c25
commit 4e195dac6b
9 files changed
+362 -15

No files matched your search

+3 -1
View File
@@ -1092,5 +1092,7 @@
"src/globe/cesium/follow.test.mjs", "src/globe/cesium/follow.test.mjs",
"src/globe/surfaceDetail.js", "src/globe/surfaceDetail.js",
"src/globe/surfaceDetail.test.mjs", "src/globe/surfaceDetail.test.mjs",
"scripts/qa-surface-heights.mjs" "scripts/qa-surface-heights.mjs",
"src/globe/navara/followGestures.js",
"src/globe/navara/followGestures.test.mjs"
] ]
+2 -1
View File
@@ -2564,7 +2564,8 @@
"src/globe/navara/movers/pack.js", "src/globe/navara/movers/pack.js",
"src/globe/navara/movers/modelMover.js", "src/globe/navara/movers/modelMover.js",
"src/globe/navara/movers/index.js", "src/globe/navara/movers/index.js",
"src/globe/surfaceDetail.js" "src/globe/surfaceDetail.js",
"src/globe/navara/followGestures.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+96 -6
View File
@@ -11,7 +11,7 @@
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { createEngineEvent } from './contract.js'; import { createEngineEvent } from './contract.js';
const { cartesian3, matrix4 } = geo; const { cartesian3, math, matrix4 } = geo;
const WGS84 = geo.ellipsoid.WGS84; const WGS84 = geo.ellipsoid.WGS84;
export const MIN_FOLLOW_RANGE_M = 150; export const MIN_FOLLOW_RANGE_M = 150;
@@ -53,6 +53,7 @@ const scratchInverse = matrix4.create();
const scratchOffset = cartesian3.create(); const scratchOffset = cartesian3.create();
const scratchWorld = cartesian3.create(); const scratchWorld = cartesian3.create();
const scratchUp = cartesian3.create(); const scratchUp = cartesian3.create();
const scratchBefore = cartesian3.create();
/** /**
* One follow step: the camera pose that keeps `target` centred at `offset` * One follow step: the camera pose that keeps `target` centred at `offset`
@@ -149,21 +150,78 @@ export function enuOffset(
); );
} }
// Elevation of the camera above the target's horizon: kept between these
// (radians), as Cesium's tracked camera stays above the ground plane.
const MIN_ELEVATION = math.toRadians(2);
const MAX_ELEVATION = math.toRadians(89);
/** The ENU offset turned by `dHeading` and raised by `dPitch` (radians), same range. */
export function orbitOffset(
offset,
dHeading,
dPitch,
result = cartesian3.create(),
) {
const range = cartesian3.magnitude(offset);
if (!(range > 0)) return cartesian3.clone(offset, result);
const heading = Math.atan2(offset.x, offset.y) + dHeading;
const elevation = math.clamp(
Math.asin(math.clamp(offset.z / range, -1, 1)) + dPitch,
MIN_ELEVATION,
MAX_ELEVATION,
);
const horizontal = range * Math.cos(elevation);
return cartesian3.fromElements(
horizontal * Math.sin(heading),
horizontal * Math.cos(heading),
range * Math.sin(elevation),
result,
);
}
/** The ENU offset scaled by `factor`, never inside `minRange`. */
export function zoomOffset(
offset,
factor,
minRange,
result = cartesian3.create(),
) {
const range = cartesian3.magnitude(offset);
if (!(range > 0) || !(factor > 0)) return cartesian3.clone(offset, result);
const next = Math.max(minRange, range * factor);
return cartesian3.multiplyByScalar(offset, next / range, result);
}
const STILL_M = 1e-6;
/** /**
* @param {{ * @param {{
* camera: { pose: () => object, commanded: object }, * camera: { pose: () => object, commanded: object },
* aim: (pose: { position, direction, up }) => void, * aim: (pose: { position, direction, up }) => void,
* frame: { addEventListener: Function }, * frame: { addEventListener: Function },
* requestRender: () => void, * requestRender: () => void,
* gestures?: (handlers: { orbit: (dHeading: number, dPitch: number) => void,
* zoom: (factor: number) => void }) => () => void,
* }} ports `aim` places the camera without camera.commanded; `frame` is * }} ports `aim` places the camera without camera.commanded; `frame` is
* the event the follow steps on (the engine's preRender, after it read * the event the follow steps on (the engine's preRender, after it read
* the user's camera). * the user's camera). `gestures` (Navara): while following, the engine's
* own drag and wheel handling is suspended and its drag and wheel turn
* and scale the offset instead (as Cesium's EntityView orbits the
* target); it returns the release. Without it the offset is re-read from
* the camera each frame (the fake engine).
*/ */
export function createFollowController({ camera, aim, frame, requestRender }) { export function createFollowController({
camera,
aim,
frame,
requestRender,
gestures = null,
}) {
const followChanged = createEngineEvent(); const followChanged = createEngineEvent();
let current = null; let current = null;
let removeFrame = null; let removeFrame = null;
let removeCommanded = null; let removeCommanded = null;
let releaseGestures = null;
let aiming = false; let aiming = false;
const end = (handle) => { const end = (handle) => {
@@ -171,10 +229,29 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
current = null; current = null;
removeFrame?.(); removeFrame?.();
removeCommanded?.(); removeCommanded?.();
releaseGestures?.();
removeFrame = null; removeFrame = null;
removeCommanded = null; removeCommanded = null;
releaseGestures = null;
followChanged.raiseEvent(null); followChanged.raiseEvent(null);
}; };
// Drag and wheel (gestures port): applied at the next step.
const gestureHandlers = {
orbit(dHeading, dPitch) {
const handle = current;
if (!handle?.offset) return;
orbitOffset(handle.offset, dHeading, dPitch, handle.offset);
handle.moved = true;
requestRender();
},
zoom(factor) {
const handle = current;
if (!handle?.offset) return;
zoomOffset(handle.offset, factor, handle.options.minRange, handle.offset);
handle.moved = true;
requestRender();
},
};
const step = () => { const step = () => {
const handle = current; const handle = current;
@@ -186,14 +263,24 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
target = null; target = null;
} }
if (!isCartesian(target)) return; if (!isCartesian(target)) return;
let changed = handle.moved || !handle.previous;
handle.moved = false;
if (!handle.offset) { if (!handle.offset) {
const from = handle.options.viewFrom(); const from = handle.options.viewFrom();
if (!isCartesian(from)) return; if (!isCartesian(from)) return;
handle.offset = cartesian3.clone(from); handle.offset = cartesian3.clone(from);
} else if (handle.previous) { changed = true;
} else if (handle.previous && !gestures) {
// The user may have turned or zoomed the camera since the last step. // The user may have turned or zoomed the camera since the last step.
const before = cartesian3.clone(handle.offset, scratchBefore);
enuOffset(handle.previous, camera.pose().position, handle.offset); enuOffset(handle.previous, camera.pose().position, handle.offset);
if (cartesian3.distance(before, handle.offset) > STILL_M) changed = true;
} }
if (
handle.previous &&
cartesian3.distance(handle.previous, target) > STILL_M
)
changed = true;
const pose = followPose(target, handle.offset, handle.options.minRange); const pose = followPose(target, handle.offset, handle.options.minRange);
handle.previous = cartesian3.clone(target, handle.previous ?? undefined); handle.previous = cartesian3.clone(target, handle.previous ?? undefined);
// The offset in force (the minimum range applied). // The offset in force (the minimum range applied).
@@ -204,8 +291,9 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
} finally { } finally {
aiming = false; aiming = false;
} }
// A moving target needs the next frame. // A moving target (or a gesture) needs the next frame; a still one
requestRender(); // lets the engine idle.
if (changed) requestRender();
}; };
return { return {
@@ -231,6 +319,8 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
}; };
current = handle; current = handle;
removeFrame = frame.addEventListener(step); removeFrame = frame.addEventListener(step);
if (gestures && !releaseGestures)
releaseGestures = gestures(gestureHandlers);
removeCommanded = camera.commanded.addEventListener(() => { removeCommanded = camera.commanded.addEventListener(() => {
if (!aiming) end(handle); if (!aiming) end(handle);
}); });
+72
View File
@@ -127,3 +127,75 @@ test('stop ends the follow; a new follow replaces the old one', () => {
TypeError, TypeError,
); );
}); });
test('with engine gestures, drag orbits at the same range and wheel stops at the floor', () => {
let pose = {
position: cartesian3.fromDegrees(0, 0, 1e7),
direction: cartesian3.create(-1, 0, 0),
up: cartesian3.create(0, 0, 1),
};
const commanded = createEngineEvent();
const frame = createEngineEvent();
let handlers = null;
let suspended = 0;
const controller = createFollowController({
camera: { pose: () => pose, commanded },
aim: (next) => {
pose = next;
},
frame,
requestRender: () => {},
gestures: (next) => {
handlers = next;
suspended++;
return () => {
handlers = null;
suspended--;
};
},
});
const handle = controller.follow({
position: () => target0,
viewFrom: cartesian3.create(0, -1000, 500),
});
assert.equal(suspended, 1, 'the engine gestures are taken');
frame.raiseEvent();
const range = () => cartesian3.distance(pose.position, target0);
const start = range();
handlers.orbit(0.8, 0.3);
frame.raiseEvent();
assert.ok(Math.abs(range() - start) < 1e-6 * start, 'orbit keeps the range');
const offset = enuOffset(target0, pose.position);
assert.ok(Math.abs(offset.x) > 100, 'the heading turned');
// A camera move nobody commanded (the engine's own controller) is not
// read back while the gestures are the follow's.
pose = { ...pose, position: cartesian3.fromDegrees(10, 10, 1e6) };
frame.raiseEvent();
assert.ok(Math.abs(range() - start) < 1e-6 * start);
for (let i = 0; i < 40; i++) {
handlers.zoom(0.5);
frame.raiseEvent();
assert.ok(range() >= MIN_FOLLOW_RANGE_M - 1e-6, `range ${range()}`);
}
assert.ok(Math.abs(range() - MIN_FOLLOW_RANGE_M) < 1e-6, 'at the floor');
// Pitch stays above the target's horizon and short of straight down.
handlers.orbit(0, 10);
frame.raiseEvent();
const up = enuOffset(target0, pose.position);
assert.ok(up.z > 0 && up.z < MIN_FOLLOW_RANGE_M, 'pitch clamped');
handle.stop();
assert.equal(suspended, 0, 'the gestures are given back');
});
test('a still target and offset request no renders', () => {
const h = harness();
h.controller.follow({
position: () => target0,
viewFrom: cartesian3.create(0, -1000, 500),
});
h.frame();
h.frame();
const settled = h.renders();
for (let i = 0; i < 5; i++) h.frame();
assert.equal(h.renders(), settled, 'idle while nothing moves');
});
+6
View File
@@ -19,6 +19,7 @@ import { createNavaraCredits } from './credits.js';
import { createNavaraInput } from './input.js'; import { createNavaraInput } from './input.js';
import { createNavaraPicking } from './picking.js'; import { createNavaraPicking } from './picking.js';
import { createFollowController } from '../follow.js'; import { createFollowController } from '../follow.js';
import { navaraFollowGestures } from './followGestures.js';
import { createNavaraMaps } from './maps.js'; import { createNavaraMaps } from './maps.js';
import { createNavaraSurface } from './surface.js'; import { createNavaraSurface } from './surface.js';
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
@@ -153,6 +154,11 @@ export function createNavaraEngine(
aim: (pose) => model.aim(pose), aim: (pose) => model.aim(pose),
frame: followStep, frame: followStep,
requestRender: () => view.forceUpdate(), requestRender: () => view.forceUpdate(),
// Drag orbits and wheel scales the follow's offset (followGestures.js).
gestures: (handlers) =>
navaraFollowGestures(view.canvas, () =>
input.input.suspendCameraInputs(),
)(handlers),
}); });
Object.assign(model.camera, { Object.assign(model.camera, {
follow: (options) => follow.follow(options), follow: (options) => follow.follow(options),
+59
View File
@@ -0,0 +1,59 @@
// The follow camera's drag and wheel on Navara (src/globe/follow.js
// `gestures`): while following, Navara's own spin, zoom and tilt are off
// (they pan about the ground point under the cursor and zoom toward it) and
// a left drag turns the offset around the target, a wheel scales its range,
// as Cesium's tracked camera does: a drag across the whole canvas width is
// one turn of heading, across its height half a turn of pitch; one wheel
// notch (deltaY 100) is 20 % of the range.
const WHEEL_STEP = 1.2;
/**
* @param {HTMLCanvasElement} canvas
* @param {() => () => void} suspendCameraInputs The engine input's.
* @param {Window | EventTarget} [windowTarget] Where moves and releases
* outside the canvas arrive.
* @returns {(handlers: { orbit: Function, zoom: Function }) => () => void}
*/
export function navaraFollowGestures(
canvas,
suspendCameraInputs,
windowTarget = globalThis.window ?? canvas,
) {
return ({ orbit, zoom }) => {
const resume = suspendCameraInputs();
let last = null;
const down = (event) => {
if (event.button !== 0) return;
last = { x: event.clientX, y: event.clientY };
};
const move = (event) => {
if (!last) return;
const dx = event.clientX - last.x;
const dy = event.clientY - last.y;
last = { x: event.clientX, y: event.clientY };
const width = canvas.clientWidth || 1;
const height = canvas.clientHeight || 1;
orbit((-dx / width) * 2 * Math.PI, (dy / height) * Math.PI);
};
const up = () => {
last = null;
};
const wheel = (event) => {
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
event.preventDefault?.();
zoom(WHEEL_STEP ** (event.deltaY / 100));
};
canvas.addEventListener('pointerdown', down);
windowTarget.addEventListener('pointermove', move);
windowTarget.addEventListener('pointerup', up);
canvas.addEventListener('wheel', wheel, { passive: false });
return () => {
canvas.removeEventListener('pointerdown', down);
windowTarget.removeEventListener('pointermove', move);
windowTarget.removeEventListener('pointerup', up);
canvas.removeEventListener('wheel', wheel, { passive: false });
resume();
};
};
}
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { navaraFollowGestures } from './followGestures.js';
const event = (type, fields = {}) =>
Object.assign(new Event(type, { cancelable: true }), fields);
test('drag turns, wheel scales, and Navara inputs are off until release', () => {
const canvas = new EventTarget();
canvas.clientWidth = 1000;
canvas.clientHeight = 500;
const win = new EventTarget();
let suspended = 0;
const gestures = navaraFollowGestures(
canvas,
() => {
suspended++;
return () => suspended--;
},
win,
);
const calls = [];
const release = gestures({
orbit: (h, p) => calls.push(['orbit', h, p]),
zoom: (f) => calls.push(['zoom', f]),
});
assert.equal(suspended, 1);
canvas.dispatchEvent(
event('pointerdown', { button: 0, clientX: 100, clientY: 100 }),
);
win.dispatchEvent(event('pointermove', { clientX: 350, clientY: 225 }));
win.dispatchEvent(event('pointerup'));
win.dispatchEvent(event('pointermove', { clientX: 0, clientY: 0 }));
const wheel = event('wheel', { deltaY: 100 });
canvas.dispatchEvent(wheel);
assert.equal(calls.length, 2);
assert.ok(Math.abs(calls[0][1] + Math.PI / 2) < 1e-12, 'quarter turn');
assert.ok(Math.abs(calls[0][2] - Math.PI / 4) < 1e-12);
assert.deepEqual(calls[1], ['zoom', 1.2]);
assert.equal(wheel.defaultPrevented, true);
release();
assert.equal(suspended, 0);
canvas.dispatchEvent(event('wheel', { deltaY: 100 }));
assert.equal(calls.length, 2, 'no handling after release');
});
+26 -5
View File
@@ -111,6 +111,15 @@ export function createNavaraInput(
passive: true, passive: true,
}); });
let suspended = 0;
const sendCameraInputs = () => {
const enabled = cameraInputs && suspended === 0;
input.setCameraOptions({
enableSpin: enabled,
enableZoom: enabled,
enableTilt: enabled,
});
};
const input = { const input = {
on(type, listener, options = {}) { on(type, listener, options = {}) {
const { modifier, pick, drill } = normalizeInputOptions(type, options); const { modifier, pick, drill } = normalizeInputOptions(type, options);
@@ -128,11 +137,23 @@ export function createNavaraInput(
}, },
setCameraInputs(enabled) { setCameraInputs(enabled) {
cameraInputs = Boolean(enabled); cameraInputs = Boolean(enabled);
input.setCameraOptions({ sendCameraInputs();
enableSpin: cameraInputs, },
enableZoom: cameraInputs, /**
enableTilt: cameraInputs, * Navara-only: Navara's spin, zoom and tilt off until the returned
}); * release (the follow camera takes drag and wheel itself); the app's
* setCameraInputs choice comes back after the last release.
*/
suspendCameraInputs() {
suspended++;
sendCameraInputs();
let released = false;
return () => {
if (released) return;
released = true;
suspended--;
sendCameraInputs();
};
}, },
/** /**
* Navara-only extension: camera controller options (zoom distances, * Navara-only extension: camera controller options (zoom distances,
+53 -2
View File
@@ -2588,6 +2588,43 @@ export function createContractSuite(
`the target drifted ${worst.toFixed(2)} px off centre`, `the target drifted ${worst.toFixed(2)} px off centre`,
); );
ensure(step - firstStep >= 120, 'the target did not move'); ensure(step - firstStep >= 120, 'the target did not move');
if (pointer?.drag && pointer?.wheel) {
// A left drag orbits the target: same range (±5 %), centred.
const { width, height } = engine.host.size();
const before = range();
await pointer.drag(
width / 2,
height / 2,
width / 2 + 200,
height / 2 + 60,
);
await settle(4);
ensure(camera.following() === handle, 'a drag ended the follow');
ensure(
Math.abs(range() - before) <= 0.05 * before,
`a drag changed the range ${before.toFixed(0)} → ${range().toFixed(0)} m`,
);
ensure(offCentre() <= 3, 'the target left the centre after a drag');
// The wheel zooms toward the target down to the floor, never below.
let lowest = Infinity;
for (let notch = 0; notch < 40; notch++) {
await pointer.wheel(width / 2, height / 2, -100);
await settle(2);
lowest = Math.min(lowest, range());
}
await settle(4);
lowest = Math.min(lowest, range());
ensure(lowest >= 149, `the wheel reached ${lowest.toFixed(1)} m`);
ensure(
range() <= 150 * 1.05,
`40 wheel notches stopped at ${range().toFixed(1)} m, not the 150 m floor`,
);
ensure(
offCentre() <= 3,
'the target left the centre after the wheel',
);
ensure(camera.following() === handle, 'the wheel ended the follow');
}
// A zoom through the target stops at the minimum range. // A zoom through the target stops at the minimum range.
camera.zoomIn(5000); camera.zoomIn(5000);
await settle(4); await settle(4);
@@ -2599,12 +2636,26 @@ export function createContractSuite(
ensure(camera.following() === null, 'following() after stop'); ensure(camera.following() === null, 'following() after stop');
ensure(changes.at(-1) === null, 'followChanged did not report null'); ensure(changes.at(-1) === null, 'followChanged did not report null');
camera.release(); camera.release();
await settle(); // Gesture inertia (Cesium) may carry the camera a few frames:
// wait for it to rest, then the target moves on without it.
let last = camera.pose().position;
let stillFrames = 0;
await until(() => {
const now = camera.pose().position;
stillFrames =
cartesian3.distance(now, last) < 1e-3 ? stillFrames + 1 : 0;
last = now;
return stillFrames >= 5;
}, 'the camera at rest after stop');
const free = camera.pose().position; const free = camera.pose().position;
await settle(10); await settle(10);
ensure( ensure(
cartesian3.distance(camera.pose().position, free) < 1e-3, cartesian3.distance(camera.pose().position, free) < 1e-3,
'the camera still follows after stop', `the camera still moves after stop (${cartesian3
.distance(camera.pose().position, free)
.toFixed(2)} m in 10 frames; following ${Boolean(
camera.following(),
)})`,
); );
// A command that takes the camera ends the follow. // A command that takes the camera ends the follow.
camera.follow({ camera.follow({