Skip to content

Axonometric projection ignores camera padding (EdgeInsets) #4545

Description

@sargunv

(filed w/ GPT 5.6 Sol, edited by me)

Describe the bug

Axonometric projection does not move the projected camera target to the logical viewport center defined by CameraOptions::padding.

Transform::moveBy() still calculates movement from the padded viewport center. The projection and gesture calculations therefore disagree when the padding is asymmetric. Panning can move in an unexpected direction, and bearing or pitch changes orbit around the physical viewport center instead of the padded center.

To Reproduce

  1. Create a Transform with a 512 × 512 viewport.
  2. Configure a camera with a 160 px left inset.
  3. Enable axonometric projection.
  4. Project the configured camera center to the screen.
  5. Call moveBy(), or change the bearing and pitch while preserving the padded-center landmark.

For example:

transform.resize({512, 512});
transform.jumpTo(
    CameraOptions()
        .withCenter(LatLng{40.7128, -74.0060})
        .withZoom(15.0)
        .withBearing(25.0)
        .withPitch(45.0)
        .withPadding(EdgeInsets{0.0, 160.0, 0.0, 0.0}));
transform.setProjectionMode(
    ProjectionMode()
        .withAxonometric(true)
        .withXSkew(0.0)
        .withYSkew(1.0));

The logical viewport center is (336, 256), but axonometric projection does not apply the inset offset.

A downstream reproduction and regression test are available in MapLibre Compose PR #1227:

maplibre/maplibre-compose#1227

Expected behavior

Axonometric projection honors EdgeInsets in the same way as perspective projection. The configured camera center projects to the padded viewport center. moveBy() follows the requested screen-space delta, and bearing or pitch changes preserve the landmark under that center.

Screenshots

Not applicable.

Platform information (please complete the following information):

  • Operating System: macOS
  • Platform: Core C++, observed through maplibre-native-ffi and MapLibre Compose
  • maplibre-native-ffi: 0.202608.3
  • MapLibre Native commit in that release: 550f64be2232e09934ffc660a9acdbacff8b164a
  • Also present on MapLibre Native main: e5d0fce32a98ec94303ab18ae3da46581b0edd48

Additional context

TransformState::getProjMatrix() applies the edge-inset offset only when axonometric is false:

// Move the center of perspective to center of specified edgeInsets.
// Values are in range [-1, 1] where the upper and lower range values
// position viewport center to the screen edges. This is overridden
// if using axonometric perspective (not in public API yet, Issue #11882).
// TODO(astojilj): Issue #11882 should take edge insets into account, too.
if (!axonometric) {
cameraToClip[8] = -offset.x * 2.0 / size.width;
cameraToClip[9] = offset.y * 2.0 / size.height;
}
// Apply north orientation angle
if (getNorthOrientation() != NorthOrientation::Upwards) {
matrix::rotate_z(cameraToClip, cameraToClip, -getNorthOrientationAngle());
}
matrix::multiply(projMatrix, cameraToClip, worldToCamera);
if (axonometric) {
// mat[11] controls perspective
projMatrix[11] = 0.0;
// mat[8], mat[9] control x-skew, y-skew
double pixelsPerMeter = 1.0 / Projection::getMetersPerPixelAtLatitude(getLatLng().latitude(), getZoom());
projMatrix[8] = xSkew * pixelsPerMeter;
projMatrix[9] = ySkew * pixelsPerMeter;
}

Transform::moveBy() nevertheless starts from edgeInsets.getCenter():

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.
easeTo(CameraOptions().withCenter(screenCoordinateToLatLng(pointOnScreen, LatLng::Unwrapped)), animation);
}

An archived Mapbox GL Native discussion predicted this conflict (@1ec5):

mapbox/mapbox-gl-native#11882 (comment)

This issue is latent because only the Node bindings expose an axonometric perspective option, while only the mobile SDKs expose content inset options. But as soon as we implement the feature requested here, we’ll need to address the overridden projection matrix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    coreChanges that affect the C++ core of MapLibre Native

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions