fix(globe): the Navara follow turns and dollies as Cesium's tracked camera

Review of Tasks 6-7: a 240 px drag right turned Navara's follow by -67.5°
where Cesium turns +119.5°, and a right drag did nothing (Cesium dollies,
1.95 Mm → 0.91 Mm). followGestures.js now follows Cesium's
ScreenSpaceCameraController for a tracked camera:
- left drag (rotate3D): heading += 1.77 × dx / width × 2π (clockwise for a
  drag right; 1.77 is Cesium's maximum rotate rate, which a tracked camera
  beyond a few metres always reaches) and the camera rises by
  1.77 × -dy / height × π; each move counts at most a tenth of the canvas
  (maximumMovementRatio);
- right drag (zoom3D): follow.js dollyOffset moves 5 × dy / height of the
  distance above the floor (closing) or of the range (opening), never
  inside it; no context menu while following.

The contract follow check turns the camera with a 240 px drag right and
asserts the heading turn against Cesium's rate (±15 %; the harness holds the
pointer before releasing it so Cesium adds no inertia), and dollies with a
right drag down (range below 80 %, never under 149 m). Cesium 73/73, Navara
79/79.
This commit is contained in:
German Khokhlov committed 2026-10-16 22:14:02 +04:00
1 parent b0cbed081b
commit f0ce1b04c7
5 files changed
+188 -19

No files matched your search

+29
View File
@@ -205,6 +205,28 @@ export function zoomOffset(
return cartesian3.multiplyByScalar(offset, next / range, result);
}
/**
* The ENU offset dollied as Cesium's zoom3D: `ratio` is the drag over the
* canvas height (down closes in), at most a tenth per move; the camera moves
* five times that share of its distance above the floor (closing) or of its
* range (opening), never inside `minRange`.
*/
export function dollyOffset(
offset,
ratio,
minRange,
result = cartesian3.create(),
) {
const range = cartesian3.magnitude(offset);
if (!(range > 0) || !Number.isFinite(ratio))
return cartesian3.clone(offset, result);
const window = Math.min(ratio, 0.1);
const closing = window > 0;
const distance = 5 * (range - (closing ? minRange : 0)) * window;
const next = Math.max(minRange, range - distance);
return cartesian3.multiplyByScalar(offset, next / range, result);
}
const STILL_M = 1e-6;
/**
@@ -257,6 +279,13 @@ export function createFollowController({
handle.moved = true;
requestRender();
},
dolly(ratio) {
const handle = current;
if (!handle?.offset) return;
dollyOffset(handle.offset, ratio, handle.options.minRange, handle.offset);
handle.moved = true;
requestRender();
},
zoom(factor) {
const handle = current;
if (!handle?.offset) return;
+22
View File
@@ -199,3 +199,25 @@ test('a still target and offset request no renders', () => {
for (let i = 0; i < 5; i++) h.frame();
assert.equal(h.renders(), settled, 'idle while nothing moves');
});
test('a dolly moves along the offset as Cesium zoom3D, never inside the floor', async () => {
const { dollyOffset } = await import('./follow.js');
const offset = cartesian3.create(0, -1000, 0);
// Five times the window ratio of the distance above the floor (closing).
const closer = dollyOffset(offset, 0.05, 150);
assert.ok(
Math.abs(cartesian3.magnitude(closer) - (1000 - 5 * 850 * 0.05)) < 1e-9,
);
const away = dollyOffset(offset, -0.05, 150);
assert.ok(
Math.abs(cartesian3.magnitude(away) - (1000 + 5 * 1000 * 0.05)) < 1e-9,
);
// At most a tenth per move, and never through the floor.
const capped = dollyOffset(offset, 0.5, 150);
assert.ok(
Math.abs(cartesian3.magnitude(capped) - (1000 - 5 * 850 * 0.1)) < 1e-9,
);
let near = cartesian3.create(0, -160, 0);
for (let i = 0; i < 20; i++) near = dollyOffset(near, 0.1, 150);
assert.ok(cartesian3.magnitude(near) >= 150);
});
+27 -9
View File
@@ -1,12 +1,19 @@
// 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.
// the gestures act on the offset as Cesium's tracked camera does
// (ScreenSpaceCameraController rotate3D and zoom3D):
// - left drag: heading turns by rate × dx / width × 2π and the camera rises
// by rate × −dy / height × π, rate = 1.77 (Cesium's maximum rotate rate,
// which a tracked camera beyond a few metres always reaches); each move
// counts at most a tenth of the canvas (maximumMovementRatio); a drag
// right turns the heading clockwise;
// - right drag: a dolly by dy / height (down closes in);
// - wheel: one notch (deltaY 100) is 20 % of the range.
const WHEEL_STEP = 1.2;
const ROTATE_RATE = 1.77;
const MAX_MOVEMENT_RATIO = 0.1;
/**
* @param {HTMLCanvasElement} canvas
@@ -20,21 +27,28 @@ export function navaraFollowGestures(
suspendCameraInputs,
windowTarget = globalThis.window ?? canvas,
) {
return ({ orbit, zoom }) => {
return ({ orbit, zoom, dolly }) => {
const resume = suspendCameraInputs();
let last = null;
const down = (event) => {
if (event.button !== 0) return;
last = { x: event.clientX, y: event.clientY };
if (event.button !== 0 && event.button !== 2) return;
last = { x: event.clientX, y: event.clientY, button: event.button };
};
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 };
last = { ...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);
if (last.button === 2) {
dolly?.(dy / height);
return;
}
orbit(
ROTATE_RATE * Math.min(dx / width, MAX_MOVEMENT_RATIO) * 2 * Math.PI,
ROTATE_RATE * Math.min(-dy / height, MAX_MOVEMENT_RATIO) * Math.PI,
);
};
const up = () => {
last = null;
@@ -44,11 +58,15 @@ export function navaraFollowGestures(
event.preventDefault?.();
zoom(WHEEL_STEP ** (event.deltaY / 100));
};
// The right button drags; no context menu over it.
const contextMenu = (event) => event.preventDefault?.();
canvas.addEventListener('pointerdown', down);
windowTarget.addEventListener('pointermove', move);
windowTarget.addEventListener('pointerup', up);
canvas.addEventListener('wheel', wheel, { passive: false });
canvas.addEventListener('contextmenu', contextMenu);
return () => {
canvas.removeEventListener('contextmenu', contextMenu);
canvas.removeEventListener('pointerdown', down);
windowTarget.removeEventListener('pointermove', move);
windowTarget.removeEventListener('pointerup', up);
+54 -10
View File
@@ -5,10 +5,10 @@ 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', () => {
test('drag turns and right drag dollies as Cesium tracked camera; wheel scales; inputs off until release', () => {
const canvas = new EventTarget();
canvas.clientWidth = 1000;
canvas.clientHeight = 500;
canvas.clientWidth = 1280;
canvas.clientHeight = 800;
const win = new EventTarget();
let suspended = 0;
const gestures = navaraFollowGestures(
@@ -23,23 +23,67 @@ test('drag turns, wheel scales, and Navara inputs are off until release', () =>
const release = gestures({
orbit: (h, p) => calls.push(['orbit', h, p]),
zoom: (f) => calls.push(['zoom', f]),
dolly: (r) => calls.push(['dolly', r]),
});
assert.equal(suspended, 1);
// A 240 px right drag in 12 moves: Cesium turns the heading by
// 1.77 × 240 / 1280 × 2π = +119.5° (rotate3D at its maximum rate).
canvas.dispatchEvent(
event('pointerdown', { button: 0, clientX: 100, clientY: 100 }),
);
win.dispatchEvent(event('pointermove', { clientX: 350, clientY: 225 }));
win.dispatchEvent(event('pointerup'));
for (let i = 1; i <= 12; i++)
win.dispatchEvent(
event('pointermove', { clientX: 100 + 20 * i, clientY: 100 }),
);
win.dispatchEvent(event('pointerup', { button: 0 }));
const heading = calls
.filter(([kind]) => kind === 'orbit')
.reduce((sum, [, h]) => sum + h, 0);
assert.ok(
Math.abs((heading * 180) / Math.PI - 119.475) < 0.1,
`heading ${(heading * 180) / Math.PI}`,
);
// A drag down lowers the camera (Cesium rotateUp with a negative angle).
calls.length = 0;
canvas.dispatchEvent(
event('pointerdown', { button: 0, clientX: 100, clientY: 100 }),
);
win.dispatchEvent(event('pointermove', { clientX: 100, clientY: 140 }));
win.dispatchEvent(event('pointerup', { button: 0 }));
assert.ok(calls[0][2] < 0, 'pitch down');
assert.ok(Math.abs(calls[0][2] + (1.77 * 40 * Math.PI) / 800) < 1e-12);
// Per move at most a tenth of the canvas counts (maximumMovementRatio).
calls.length = 0;
canvas.dispatchEvent(
event('pointerdown', { button: 0, clientX: 0, clientY: 0 }),
);
win.dispatchEvent(event('pointermove', { clientX: 640, clientY: 0 }));
win.dispatchEvent(event('pointerup', { button: 0 }));
assert.ok(Math.abs(calls[0][1] - 1.77 * 0.1 * 2 * Math.PI) < 1e-12);
// A right drag down: a dolly by the window ratio (Cesium zoom3D).
calls.length = 0;
const context = event('contextmenu');
canvas.dispatchEvent(context);
assert.equal(
context.defaultPrevented,
true,
'no context menu while following',
);
canvas.dispatchEvent(
event('pointerdown', { button: 2, clientX: 100, clientY: 100 }),
);
win.dispatchEvent(event('pointermove', { clientX: 100, clientY: 140 }));
win.dispatchEvent(event('pointerup', { button: 2 }));
assert.deepEqual(calls, [['dolly', 40 / 800]]);
// Moves without a press do nothing; the wheel scales.
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.deepEqual(calls.at(-1), ['zoom', 1.2]);
assert.equal(wheel.defaultPrevented, true);
release();
assert.equal(suspended, 0);
const count = calls.length;
canvas.dispatchEvent(event('wheel', { deltaY: 100 }));
assert.equal(calls.length, 2, 'no handling after release');
assert.equal(calls.length, count, 'no handling after release');
});
+56
View File
@@ -2605,6 +2605,62 @@ export function createContractSuite(
`a drag changed the range ${before.toFixed(0)} → ${range().toFixed(0)} m`,
);
ensure(offCentre() <= 3, 'the target left the centre after a drag');
// A 240 px drag right turns the heading clockwise as Cesium's
// tracked camera: 1.77 × 240 / width × 360° (±15 %).
// Cesium's gesture inertia carries a turn on for a while: the
// heading is read once it has been still for 5 frames.
const headingAtRest = async () => {
let last = camera.pose().heading;
let still = 0;
await until(() => {
const now = camera.pose().heading;
still =
Math.abs(math.negativePiToPi(now - last)) < 1e-4
? still + 1
: 0;
last = now;
return still >= 5;
}, 'the heading at rest');
return last;
};
const headingBefore = await headingAtRest();
await pointer.drag(
width / 2 - 120,
height / 2,
width / 2 + 120,
height / 2,
);
const headingAfter = await headingAtRest();
const turned =
(math.negativePiToPi(headingAfter - headingBefore) * 180) /
Math.PI;
const expected = (1.77 * 240 * 360) / width;
// Clockwise, at Cesium's rate (the harness holds the pointer
// still before releasing it: no inertia).
ensure(
Math.abs(turned - expected) <= 0.15 * expected,
`a 240 px drag right turned the heading ${turned.toFixed(1)}°, Cesium turns ${expected.toFixed(1)}°`,
);
ensure(offCentre() <= 3, 'the target left the centre after a turn');
// A right drag down closes in (Cesium zoom3D), above the floor.
if (pointer.dragRight) {
const far = range();
await pointer.dragRight(
width / 2,
height / 2 - 100,
width / 2,
height / 2 + 100,
);
await headingAtRest();
ensure(
range() < 0.8 * far && range() >= 149,
`a right drag down moved the range ${far.toFixed(0)} → ${range().toFixed(0)} m`,
);
ensure(
offCentre() <= 3,
'the target left the centre after a dolly',
);
}
// The wheel zooms toward the target down to the floor, never below.
let lowest = Infinity;
for (let notch = 0; notch < 40; notch++) {