Clamp pan offset to the horizon on pitched maps (#3105) (#4362)

This commit is contained in:
Silver
2026-07-12 05:25:38 -05:00
committed by GitHub
parent 62d716aed5
commit 60a7a7a5ed
2 changed files with 42 additions and 0 deletions

View File

@@ -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<double>(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.

View File

@@ -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});