From 4e195dac6b280a920cae9e05243a5aad3256464c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 20:57:10 +0400 Subject: [PATCH] fix(globe): the Navara follow orbits on drag and zooms to the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 3 +- src/globe/follow.js | 102 +++++++++++++++++++++-- src/globe/follow.test.mjs | 72 ++++++++++++++++ src/globe/navara/createNavaraEngine.js | 6 ++ src/globe/navara/followGestures.js | 59 +++++++++++++ src/globe/navara/followGestures.test.mjs | 45 ++++++++++ src/globe/navara/input.js | 31 +++++-- src/globe/testing/contractSuite.mjs | 55 +++++++++++- 9 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 src/globe/navara/followGestures.js create mode 100644 src/globe/navara/followGestures.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7d7851f0f..caf8c504d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1092,5 +1092,7 @@ "src/globe/cesium/follow.test.mjs", "src/globe/surfaceDetail.js", "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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ebd865555..a62a9503b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2564,7 +2564,8 @@ "src/globe/navara/movers/pack.js", "src/globe/navara/movers/modelMover.js", "src/globe/navara/movers/index.js", - "src/globe/surfaceDetail.js" + "src/globe/surfaceDetail.js", + "src/globe/navara/followGestures.js" ], "external": [ "hls.js", diff --git a/src/globe/follow.js b/src/globe/follow.js index fcbffefcd..56c749f07 100644 --- a/src/globe/follow.js +++ b/src/globe/follow.js @@ -11,7 +11,7 @@ import * as geo from '../geo/index.js'; import { createEngineEvent } from './contract.js'; -const { cartesian3, matrix4 } = geo; +const { cartesian3, math, matrix4 } = geo; const WGS84 = geo.ellipsoid.WGS84; export const MIN_FOLLOW_RANGE_M = 150; @@ -53,6 +53,7 @@ const scratchInverse = matrix4.create(); const scratchOffset = cartesian3.create(); const scratchWorld = cartesian3.create(); const scratchUp = cartesian3.create(); +const scratchBefore = cartesian3.create(); /** * 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 {{ * camera: { pose: () => object, commanded: object }, * aim: (pose: { position, direction, up }) => void, * frame: { addEventListener: Function }, * 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 * 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(); let current = null; let removeFrame = null; let removeCommanded = null; + let releaseGestures = null; let aiming = false; const end = (handle) => { @@ -171,10 +229,29 @@ export function createFollowController({ camera, aim, frame, requestRender }) { current = null; removeFrame?.(); removeCommanded?.(); + releaseGestures?.(); removeFrame = null; removeCommanded = null; + releaseGestures = 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 handle = current; @@ -186,14 +263,24 @@ export function createFollowController({ camera, aim, frame, requestRender }) { target = null; } if (!isCartesian(target)) return; + let changed = handle.moved || !handle.previous; + handle.moved = false; if (!handle.offset) { const from = handle.options.viewFrom(); if (!isCartesian(from)) return; 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. + const before = cartesian3.clone(handle.offset, scratchBefore); 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); handle.previous = cartesian3.clone(target, handle.previous ?? undefined); // The offset in force (the minimum range applied). @@ -204,8 +291,9 @@ export function createFollowController({ camera, aim, frame, requestRender }) { } finally { aiming = false; } - // A moving target needs the next frame. - requestRender(); + // A moving target (or a gesture) needs the next frame; a still one + // lets the engine idle. + if (changed) requestRender(); }; return { @@ -231,6 +319,8 @@ export function createFollowController({ camera, aim, frame, requestRender }) { }; current = handle; removeFrame = frame.addEventListener(step); + if (gestures && !releaseGestures) + releaseGestures = gestures(gestureHandlers); removeCommanded = camera.commanded.addEventListener(() => { if (!aiming) end(handle); }); diff --git a/src/globe/follow.test.mjs b/src/globe/follow.test.mjs index d9877996e..7ca2250ab 100644 --- a/src/globe/follow.test.mjs +++ b/src/globe/follow.test.mjs @@ -127,3 +127,75 @@ test('stop ends the follow; a new follow replaces the old one', () => { 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'); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index ec17248d4..a22b59925 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -19,6 +19,7 @@ import { createNavaraCredits } from './credits.js'; import { createNavaraInput } from './input.js'; import { createNavaraPicking } from './picking.js'; import { createFollowController } from '../follow.js'; +import { navaraFollowGestures } from './followGestures.js'; import { createNavaraMaps } from './maps.js'; import { createNavaraSurface } from './surface.js'; import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; @@ -153,6 +154,11 @@ export function createNavaraEngine( aim: (pose) => model.aim(pose), frame: followStep, 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, { follow: (options) => follow.follow(options), diff --git a/src/globe/navara/followGestures.js b/src/globe/navara/followGestures.js new file mode 100644 index 000000000..f37b11850 --- /dev/null +++ b/src/globe/navara/followGestures.js @@ -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(); + }; + }; +} diff --git a/src/globe/navara/followGestures.test.mjs b/src/globe/navara/followGestures.test.mjs new file mode 100644 index 000000000..fdd356c08 --- /dev/null +++ b/src/globe/navara/followGestures.test.mjs @@ -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'); +}); diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index 515de19ec..4a295e1ad 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -111,6 +111,15 @@ export function createNavaraInput( passive: true, }); + let suspended = 0; + const sendCameraInputs = () => { + const enabled = cameraInputs && suspended === 0; + input.setCameraOptions({ + enableSpin: enabled, + enableZoom: enabled, + enableTilt: enabled, + }); + }; const input = { on(type, listener, options = {}) { const { modifier, pick, drill } = normalizeInputOptions(type, options); @@ -128,11 +137,23 @@ export function createNavaraInput( }, setCameraInputs(enabled) { cameraInputs = Boolean(enabled); - input.setCameraOptions({ - enableSpin: cameraInputs, - enableZoom: cameraInputs, - enableTilt: cameraInputs, - }); + sendCameraInputs(); + }, + /** + * 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, diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 12571f2cf..862a2c687 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -2588,6 +2588,43 @@ export function createContractSuite( `the target drifted ${worst.toFixed(2)} px off centre`, ); 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. camera.zoomIn(5000); await settle(4); @@ -2599,12 +2636,26 @@ export function createContractSuite( ensure(camera.following() === null, 'following() after stop'); ensure(changes.at(-1) === null, 'followChanged did not report null'); 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; await settle(10); ensure( 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. camera.follow({