diff --git a/src/globe/follow.js b/src/globe/follow.js index abc4e87d9..40bbdb108 100644 --- a/src/globe/follow.js +++ b/src/globe/follow.js @@ -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; diff --git a/src/globe/follow.test.mjs b/src/globe/follow.test.mjs index 7ca2250ab..dfd68bc75 100644 --- a/src/globe/follow.test.mjs +++ b/src/globe/follow.test.mjs @@ -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); +}); diff --git a/src/globe/navara/followGestures.js b/src/globe/navara/followGestures.js index f37b11850..67dc35323 100644 --- a/src/globe/navara/followGestures.js +++ b/src/globe/navara/followGestures.js @@ -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); diff --git a/src/globe/navara/followGestures.test.mjs b/src/globe/navara/followGestures.test.mjs index fdd356c08..7d3f7ebe1 100644 --- a/src/globe/navara/followGestures.test.mjs +++ b/src/globe/navara/followGestures.test.mjs @@ -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'); }); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 862a2c687..851a4cc3d 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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++) {