diff --git a/src/mbgl/map/transform.cpp b/src/mbgl/map/transform.cpp index c24e6eb9a..925b0c425 100644 --- a/src/mbgl/map/transform.cpp +++ b/src/mbgl/map/transform.cpp @@ -405,6 +405,22 @@ void Transform::flyTo(const CameraOptions& inputCamera, void Transform::moveBy(const ScreenCoordinate& offset, const AnimationOptions& animation) { ScreenCoordinate centerOffset = {offset.x, offset.y}; + + // Reduce the offset so that it never goes past the horizon. If it goes past + // the horizon, the pan direction is opposite of the intended direction. + const double pitch = state.getPitch(); + const double offsetLength = std::hypot(offset.x, offset.y); + if (pitch > 0.0 && offsetLength > 0.0) { + const double cameraToCenter = 0.5 * static_cast(state.getSize().height) / + std::tan(state.getFieldOfView() / 2.0); + const double pixelsToHorizon = std::abs(cameraToCenter / std::tan(pitch)); + constexpr double horizonFactor = 0.75; // must be < 1 to keep the offset short of the horizon + const double scale = pixelsToHorizon * horizonFactor / offsetLength; + if (scale < 1.0) { + centerOffset = {offset.x * scale, offset.y * scale}; + } + } + ScreenCoordinate pointOnScreen = state.getEdgeInsets().getCenter(state.getSize().width, state.getSize().height) - centerOffset; // Use unwrapped LatLng to carry information about moveBy direction. diff --git a/test/map/transform.test.cpp b/test/map/transform.test.cpp index 2452a8d57..9212c16a1 100644 --- a/test/map/transform.test.cpp +++ b/test/map/transform.test.cpp @@ -378,6 +378,32 @@ TEST(Transform, MoveBy) { ASSERT_NEAR(0.0, trueCenter.longitude(), 1.1); } +// On a pitched map a large (fling-sized) pan offset must be reduced so it can +// never reach the horizon -- otherwise the unprojected pan direction flips and the +// pan reverses (or lands in the tile-less void). See maplibre-native#3105. +TEST(Transform, MoveByPitchedDoesNotCrossHorizon) { + Transform transform; + transform.resize({1000, 1000}); + const auto start = CameraOptions().withCenter(LatLng{0.0, 0.0}).withZoom(10.0).withPitch(60.0); + + // Panning "up" the screen on a north-up pitched map moves the center north. + transform.jumpTo(start); + transform.moveBy({0.0, 300.0}); + const double moderateLat = transform.getLatLng().latitude(); + + // A fling-sized offset toward the horizon must keep moving the center the same + // way (north) -- before the fix it reversed south and stay bounded (clamped + // short of the horizon). + transform.jumpTo(start); + transform.moveBy({0.0, 100000.0}); + const double flingLat = transform.getLatLng().latitude(); + + EXPECT_GT(moderateLat, 0.0); // moved north + EXPECT_GT(flingLat, 0.0); // still north; not reversed + EXPECT_GE(flingLat, moderateLat); // fling goes at least as far as the moderate pan + EXPECT_LT(flingLat, util::LATITUDE_MAX); // but is bounded, not off near the horizon +} + TEST(Transform, Antimeridian) { Transform transform; transform.resize({1000, 1000});