mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
b0cbed081b
commit
f0ce1b04c7
5 files changed
+188
-19
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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++) {
|
||||
|
||||
Reference in new issue
Block a user