From 38f679265dd298eab14d52a40bb980143c9c936b Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Mon, 12 Oct 2026 20:00:14 -0700 Subject: [PATCH 1/3] fix(camera): apply bounds padding as viewport padding - Pass resolved viewport padding through native camera options before fitting bounds - Use zero additional coordinate padding on iOS and Android - Preserve omitted padding edges and explicit or inherited bearing and pitch - Verify the padding reset and related cases against real native SDKs on both platforms --- .../rnmbx/components/camera/CameraStop.kt | 19 ++++++++++------- ios/RNMBX/RNMBXCamera.swift | 21 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt index 5e6a23385..177ddddf8 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt @@ -127,13 +127,18 @@ class CameraStop { val tilt = if (mTilt != null) mTilt!! else currentCamera.pitch val bearing = if (mBearing != null) mBearing!! else currentCamera.bearing - // cameraForCoordinateBounds insets the bounds within the map's current padding, so omitted edges must add nothing. - val boundsPadding = intArrayOf(mPaddingLeft ?: 0, mPaddingTop ?: 0, mPaddingRight ?: 0, mPaddingBottom ?: 0) - val boundsCamera = map.cameraForCoordinateBounds( - mBounds!!.toBounds(), - convert(clippedPadding(boundsPadding, mapView)), - bearing, - tilt + val bounds = mBounds!!.toBounds() + // Apply viewport padding before fitting; coordinatesPadding would add to it. + val boundsCamera = map.cameraForCoordinates( + coordinates = listOf(bounds.southwest, bounds.northeast), + camera = CameraOptions.Builder() + .padding(cameraPaddingEdgeInsets) + .bearing(bearing) + .pitch(tilt) + .build(), + coordinatesPadding = EdgeInsets(0.0, 0.0, 0.0, 0.0), + maxZoom = null, + offset = null ) builder.center(boundsCamera.center) builder.anchor(boundsCamera.anchor) diff --git a/ios/RNMBX/RNMBXCamera.swift b/ios/RNMBX/RNMBXCamera.swift index 1731b4e49..989224e95 100644 --- a/ios/RNMBX/RNMBXCamera.swift +++ b/ios/RNMBX/RNMBXCamera.swift @@ -533,13 +533,20 @@ open class RNMBXCamera : RNMBXMapAndMapViewComponentBase { withMapView { map in let bounds = [sw, ne] - // camera(for:padding:) insets the bounds within the map's current padding, so omitted edges must add nothing. - camera = map.mapboxMap.camera( - for: bounds, - padding: stopPadding(defaults: .zero), - bearing: heading ?? map.mapboxMap.cameraState.bearing, - pitch: pitch ?? map.mapboxMap.cameraState.pitch - ) + // Apply viewport padding before fitting; coordinatesPadding would add to it. + camera = logged("RNMBXCamera", "Failed to fit camera to bounds") { + try map.mapboxMap.camera( + for: bounds, + camera: CameraOptions( + padding: padding, + bearing: heading ?? map.mapboxMap.cameraState.bearing, + pitch: pitch ?? map.mapboxMap.cameraState.pitch + ), + coordinatesPadding: .zero, + maxZoom: nil, + offset: nil + ) + } } } else { camera = CameraOptions( From da84ea8861c2cd079b2218a57cc48ab3d8b915bc Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Tue, 13 Oct 2026 01:03:00 -0700 Subject: [PATCH 2/3] fix(android): fit degenerate camera bounds - Expand zero-width or zero-height bounds only for camera fitting. - Preserve exact source bounds for camera constraints. - Add regression tests for thin, point, and boundary bounds. --- .../com/rnmapbox/rnmbx/utils/LatLngBounds.kt | 25 ++++- .../rnmbx/components/camera/CameraStopTest.kt | 97 +++++++++++++++++++ .../rnmapbox/rnmbx/utils/LatLngBoundsTest.kt | 54 +++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 android/src/test/kotlin/com/rnmapbox/rnmbx/components/camera/CameraStopTest.kt create mode 100644 android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt b/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt index 08f612b51..d205df061 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt @@ -2,7 +2,6 @@ package com.rnmapbox.rnmbx.utils import com.mapbox.geojson.Point import com.mapbox.maps.CoordinateBounds -import com.rnmapbox.rnmbx.utils.LatLngBounds class LatLngBounds internal constructor( var latNorth: Double, @@ -27,9 +26,31 @@ class LatLngBounds internal constructor( ) } + fun toCameraBounds(): CoordinateBounds { + val latitudePadding = if (latNorth == latSouth) 0.000001 else 0.0 + val longitudePadding = if (lonWest == lonEast) 0.000001 else 0.0 + if (latitudePadding == 0.0 && longitudePadding == 0.0) { + return toBounds() + } + + // Mapbox can return zoom 0 when fitting degenerate bounds (#4050). + // Expand only zero-size dimensions for fitting; constraints keep the exact original bounds. + return CoordinateBounds( + Point.fromLngLat( + (lonWest - longitudePadding).coerceAtLeast(-180.0), + (latSouth - latitudePadding).coerceAtLeast(-90.0) + ), + Point.fromLngLat( + (lonEast + longitudePadding).coerceAtMost(180.0), + (latNorth + latitudePadding).coerceAtMost(90.0) + ), + false + ) + } + companion object { fun from(bbox: Double, bbox1: Double, bbox2: Double, bbox3: Double): LatLngBounds { return LatLngBounds(bbox, bbox1, bbox2, bbox3) } } -} \ No newline at end of file +} diff --git a/android/src/test/kotlin/com/rnmapbox/rnmbx/components/camera/CameraStopTest.kt b/android/src/test/kotlin/com/rnmapbox/rnmbx/components/camera/CameraStopTest.kt new file mode 100644 index 000000000..88f636e82 --- /dev/null +++ b/android/src/test/kotlin/com/rnmapbox/rnmbx/components/camera/CameraStopTest.kt @@ -0,0 +1,97 @@ +package com.rnmapbox.rnmbx.components.camera + +import com.mapbox.geojson.Point +import com.mapbox.maps.CameraOptions +import com.mapbox.maps.CameraState +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapboxMap +import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView +import com.rnmapbox.rnmbx.utils.LatLngBounds +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class CameraStopTest { + @Test + fun `bounds fit expands horizontal bounds and uses resolved viewport padding`() { + val map = mock() + val mapView = mock() + val cameraState = mock() + val fittedCamera = CameraOptions.Builder() + .center(Point.fromLngLat(-121.65, 37.0)) + .zoom(10.0) + .bearing(15.0) + .pitch(20.0) + .padding(EdgeInsets(10.0, 20.0, 300.0, 40.0)) + .build() + + whenever(mapView.getMapboxMap()).thenReturn(map) + whenever(mapView.width).thenReturn(1000) + whenever(mapView.height).thenReturn(800) + whenever(map.cameraState).thenReturn(cameraState) + whenever(cameraState.center).thenReturn(Point.fromLngLat(-122.1, 37.0)) + whenever(cameraState.zoom).thenReturn(14.0) + whenever(cameraState.bearing).thenReturn(15.0) + whenever(cameraState.pitch).thenReturn(20.0) + whenever(cameraState.padding).thenReturn(EdgeInsets(10.0, 20.0, 30.0, 40.0)) + whenever( + map.cameraForCoordinates( + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(fittedCamera) + + val stop = CameraStop().apply { + setBounds( + LatLngBounds.from(37.0, -121.2, 37.0, -122.1), + paddingLeft = null, + paddingRight = null, + paddingTop = null, + paddingBottom = 300 + ) + } + + stop.toCameraUpdate(mapView) + + val coordinates = argumentCaptor>() + val camera = argumentCaptor() + val coordinatesPadding = argumentCaptor() + verify(map).cameraForCoordinates( + coordinates.capture(), + camera.capture(), + coordinatesPadding.capture(), + anyOrNull(), + anyOrNull() + ) + + val southWest = coordinates.firstValue[0] + val northEast = coordinates.firstValue[1] + assertEquals(-122.1, southWest.longitude()) + assertEquals(-121.2, northEast.longitude()) + assertTrue(southWest.latitude() < 37.0) + assertTrue(northEast.latitude() > 37.0) + assertEquals(37.0, (southWest.latitude() + northEast.latitude()) / 2.0, 1e-12) + + assertEquals(0.0, coordinatesPadding.firstValue.top) + assertEquals(0.0, coordinatesPadding.firstValue.left) + assertEquals(0.0, coordinatesPadding.firstValue.bottom) + assertEquals(0.0, coordinatesPadding.firstValue.right) + + val cameraPadding = camera.firstValue.padding!! + assertEquals(10.0, cameraPadding.top) + assertEquals(20.0, cameraPadding.left) + assertEquals(300.0, cameraPadding.bottom) + assertEquals(40.0, cameraPadding.right) + assertEquals(15.0, camera.firstValue.bearing) + assertEquals(20.0, camera.firstValue.pitch) + } +} diff --git a/android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt b/android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt new file mode 100644 index 000000000..9570b4aee --- /dev/null +++ b/android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt @@ -0,0 +1,54 @@ +package com.rnmapbox.rnmbx.utils + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class LatLngBoundsTest { + @Test + fun `horizontal camera bounds enclose the original line without shifting its center`() { + val bounds = LatLngBounds.from(37.0, -121.2, 37.0, -122.1) + val cameraBounds = bounds.toCameraBounds() + + assertTrue(cameraBounds.southwest.latitude() < 37.0) + assertTrue(cameraBounds.northeast.latitude() > 37.0) + assertEquals(37.0, (cameraBounds.southwest.latitude() + cameraBounds.northeast.latitude()) / 2, 1e-12) + assertEquals(-122.1, cameraBounds.southwest.longitude()) + assertEquals(-121.2, cameraBounds.northeast.longitude()) + + // Camera constraints must still use the exact bounds after a fit. + assertEquals(37.0, bounds.toBounds().southwest.latitude()) + assertEquals(37.0, bounds.toBounds().northeast.latitude()) + } + + @Test + fun `vertical camera bounds expand longitude without shifting their center`() { + val bounds = LatLngBounds.from(37.1, -121.1, 37.0, -121.1) + val cameraBounds = bounds.toCameraBounds() + + assertEquals(37.0, cameraBounds.southwest.latitude()) + assertEquals(37.1, cameraBounds.northeast.latitude()) + assertTrue(cameraBounds.southwest.longitude() < -121.1) + assertTrue(cameraBounds.northeast.longitude() > -121.1) + assertEquals(-121.1, (cameraBounds.southwest.longitude() + cameraBounds.northeast.longitude()) / 2, 1e-12) + } + + @Test + fun `point and very thin nonzero bounds remain fit-safe`() { + val point = LatLngBounds.from(37.0, -121.1, 37.0, -121.1).toCameraBounds() + assertTrue(point.southwest.latitude() < point.northeast.latitude()) + assertTrue(point.southwest.longitude() < point.northeast.longitude()) + + val thin = LatLngBounds.from(37.00000001, -121.2, 37.0, -122.1) + assertEquals(thin.toBounds(), thin.toCameraBounds()) + } + + @Test + fun `expanding a horizontal fit never exceeds valid latitudes`() { + for (latitude in listOf(-90.0, 90.0)) { + val cameraBounds = LatLngBounds.from(latitude, 1.0, latitude, -1.0).toCameraBounds() + assertTrue(cameraBounds.southwest.latitude() >= -90.0) + assertTrue(cameraBounds.northeast.latitude() <= 90.0) + assertTrue(cameraBounds.southwest.latitude() < cameraBounds.northeast.latitude()) + } + } +} From 07fb7c393cc84e5ed453ff03d77f8584f493bdc0 Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Tue, 13 Oct 2026 01:11:09 -0700 Subject: [PATCH 3/3] fix(android): use expanded bounds when fitting camera --- .../java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt index 177ddddf8..0596e6e3c 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt @@ -127,7 +127,7 @@ class CameraStop { val tilt = if (mTilt != null) mTilt!! else currentCamera.pitch val bearing = if (mBearing != null) mBearing!! else currentCamera.bearing - val bounds = mBounds!!.toBounds() + val bounds = mBounds!!.toCameraBounds() // Apply viewport padding before fitting; coordinatesPadding would add to it. val boundsCamera = map.cameraForCoordinates( coordinates = listOf(bounds.southwest, bounds.northeast),