diff --git a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt index bc276f01ad..b4d123adb8 100644 --- a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt @@ -27,7 +27,6 @@ import org.groundplatform.android.data.remote.RemoteDataStore import org.groundplatform.android.data.sync.MutationSyncWorkManager import org.groundplatform.android.data.uuid.OfflineUuidGenerator import org.groundplatform.android.system.auth.AuthenticationManager -import org.groundplatform.android.ui.map.gms.GmsExt.contains import org.groundplatform.domain.model.Role import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.geometry.Geometry diff --git a/app/src/main/java/org/groundplatform/android/system/GeocodingManager.kt b/app/src/main/java/org/groundplatform/android/system/GeocodingManager.kt index 5624cdc7e6..791cfea82d 100644 --- a/app/src/main/java/org/groundplatform/android/system/GeocodingManager.kt +++ b/app/src/main/java/org/groundplatform/android/system/GeocodingManager.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.groundplatform.android.R import org.groundplatform.android.di.coroutines.IoDispatcher -import org.groundplatform.android.ui.map.gms.GmsExt.center import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import timber.log.Timber @@ -51,7 +50,7 @@ constructor( suspend fun getAreaName(bounds: Bounds): String { // Get potential addresses of five sample points: the centroid and the four vertices of the // bounding box. - val samplePoints = bounds.corners + bounds.center() + val samplePoints = bounds.corners + bounds.center val samplePointAddresses = withContext(ioDispatcher) { samplePoints.map { fetchAddressesBlocking(it) } } val nameComponents = diff --git a/app/src/main/java/org/groundplatform/android/ui/common/BaseMapViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/common/BaseMapViewModel.kt index a0228d9ebc..e369a1e1e2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/common/BaseMapViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/common/BaseMapViewModel.kt @@ -56,14 +56,13 @@ import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.map.NewCameraPositionViaBounds import org.groundplatform.android.ui.map.NewCameraPositionViaCoordinates import org.groundplatform.android.ui.map.NewCameraPositionViaCoordinatesAndZoomLevel -import org.groundplatform.android.ui.map.gms.GmsExt.contains -import org.groundplatform.android.ui.map.gms.GmsExt.toBounds import org.groundplatform.android.ui.map.gms.toCoordinates import org.groundplatform.android.ui.util.getDefaultColor import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.imagery.TileSource import org.groundplatform.domain.model.locationofinterest.LocationOfInterest +import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.map.CameraPosition import org.groundplatform.domain.model.map.MapType import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface @@ -300,7 +299,9 @@ constructor( // Compute the default viewport which includes all LOIs in the given survey. val geometries = locationOfInterestRepository.getValidLois(survey).first().map { it.geometry } - return geometries.toBounds()?.let { NewCameraPositionViaBounds(bounds = it, padding = 100) } + return Bounds.fromGeometries(geometries)?.let { + NewCameraPositionViaBounds(bounds = it, padding = 100) + } } /** Called when the map camera is moved. */ diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt index c31e9008a9..4d06be419b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskMapFragment import org.groundplatform.android.ui.map.Feature -import org.groundplatform.android.ui.map.gms.GmsExt.toBounds +import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.map.CameraPosition @AndroidEntryPoint @@ -58,7 +58,7 @@ class DrawAreaTaskMapFragment @Inject constructor() : override fun setDefaultViewPort() { val feature = taskViewModel.draftArea.value val geometry = feature?.geometry ?: return - val bounds = listOf(geometry).toBounds() ?: return + val bounds = Bounds.fromGeometry(geometry) ?: return moveToBounds(bounds, padding = 200, shouldAnimate = false) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt index d13a5248ce..77036a56a7 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt @@ -56,7 +56,6 @@ import org.groundplatform.domain.model.submission.DrawAreaTaskIncompleteData import org.groundplatform.domain.model.submission.TaskData import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.usecases.user.GetUserSettingsUseCase -import org.groundplatform.domain.util.calculateShoelacePolygonArea import org.groundplatform.ui.util.getFormattedArea import org.jetbrains.annotations.VisibleForTesting import timber.log.Timber @@ -143,7 +142,7 @@ internal constructor( updateVertices(taskData.lineString.coordinates) } is DrawAreaTaskData -> { - updateVertices(taskData.area.getShellCoordinates()) + updateVertices(taskData.area.shell.coordinates) try { completePolygon() } catch (e: IllegalStateException) { @@ -280,9 +279,9 @@ internal constructor( syncSessionState() refreshMap() - setValue(DrawAreaTaskData(Polygon(LinearRing(session.vertices)))) - val areaInSquareMeters = calculateShoelacePolygonArea(session.vertices) - _polygonArea.value = getFormattedArea(areaInSquareMeters, measurementUnits) + val polygon = Polygon(LinearRing(session.vertices)) + setValue(DrawAreaTaskData(polygon)) + _polygonArea.value = getFormattedArea(polygon.area(), measurementUnits) } /** diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt index e8fce38fe2..ba8d34e36e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt @@ -51,7 +51,6 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.DataCollectionEntryP import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.Feature -import org.groundplatform.android.ui.map.gms.GmsExt.area import org.groundplatform.android.ui.util.getDefaultColor import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.job.Job diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/GmsExt.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/GmsExt.kt deleted file mode 100644 index 8f1d72ad9f..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/GmsExt.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.android.ui.map.gms - -import com.google.android.gms.maps.model.LatLngBounds -import com.google.maps.android.SphericalUtil.computeArea -import org.groundplatform.domain.model.geometry.Coordinates -import org.groundplatform.domain.model.geometry.Geometry -import org.groundplatform.domain.model.geometry.LineString -import org.groundplatform.domain.model.geometry.LinearRing -import org.groundplatform.domain.model.geometry.MultiPolygon -import org.groundplatform.domain.model.geometry.Point -import org.groundplatform.domain.model.geometry.Polygon -import org.groundplatform.domain.model.map.Bounds - -/** Extensions for indirectly using GMS functions in map-provider agnostic codebase. */ -object GmsExt { - - fun Bounds.contains(geometry: Geometry): Boolean { - val latLngBounds = toGoogleMapsObject() - return geometry.getShellCoordinates().any { latLngBounds.contains(it.toLatLng()) } - } - - fun Bounds.center(): Coordinates = toGoogleMapsObject().center.toModelObject() - - fun List.toBounds(): Bounds? { - // TODO: Don't use shell coordinates for polygon and multi-polygons. - // Issue URL: https://github.com/google/ground-android/issues/1825 - val coordinates = this.flatMap { it.getShellCoordinates() } - if (coordinates.isNotEmpty()) { - val bounds = LatLngBounds.builder() - coordinates.forEach { bounds.include(it.toGoogleMapsObject()) } - return bounds.build().toModelObject() - } - - return null - } - - /** Returns the list of [Coordinates] in the geometry or in the outer shell of the geometry. */ - fun Geometry.getShellCoordinates(): List = - when (this) { - is Point -> listOf(coordinates) - is LineString -> coordinates - is LinearRing -> coordinates - is Polygon -> getShellCoordinates() - is MultiPolygon -> polygons.flatMap { it.getShellCoordinates() } - } - - fun Geometry.area(): Double = - when (this) { - is Point -> 0.0 - is LineString -> 0.0 - is LinearRing -> 0.0 - is Polygon -> - computeArea(shell.coordinates.toLatLngList()) - - holes.sumOf { computeArea(it.coordinates.toLatLngList()) } - is MultiPolygon -> polygons.sumOf { it.area() } - } -} diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterManager.kt index 32900f54df..2eb8cf4bd3 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureClusterManager.kt @@ -26,7 +26,6 @@ import com.google.maps.android.collections.MarkerManager import org.groundplatform.android.R import org.groundplatform.android.ui.IconFactory import org.groundplatform.android.ui.map.Feature -import org.groundplatform.android.ui.map.gms.GmsExt.toBounds import org.groundplatform.android.ui.map.gms.toGoogleMapsObject import org.groundplatform.domain.model.map.Bounds @@ -86,7 +85,7 @@ class FeatureClusterManager( /** Pan and zoom the camera to the bounds of features contained in the selected cluster. */ private fun onClusterClick(cluster: Cluster): Boolean { - cluster.items.map { it.feature.geometry }.toBounds()?.let { animateCamera(it) } + Bounds.fromGeometries(cluster.items.map { it.feature.geometry })?.let { animateCamera(it) } return true } diff --git a/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt b/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt index 88c68cc915..711603c747 100644 --- a/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt @@ -39,7 +39,6 @@ import org.groundplatform.android.data.local.stores.LocalSubmissionStore import org.groundplatform.android.data.local.stores.LocalSurveyStore import org.groundplatform.android.data.local.stores.LocalUserStore import org.groundplatform.android.proto.geometry -import org.groundplatform.android.ui.map.gms.GmsExt.getShellCoordinates import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.User import org.groundplatform.domain.model.geometry.Coordinates @@ -175,7 +174,7 @@ class LocalLocationOfInterestStoreTest : BaseHiltTest() { val newLoi = loi.copy(geometry = Polygon(LinearRing(TEST_POLYGON_2))) localLoiStore.merge(newLoi) val newLoi2 = localLoiStore.getLocationOfInterest(TEST_SURVEY, FakeData.LOI_ID) - assertThat(newLoi2?.geometry?.getShellCoordinates()).isEqualTo(TEST_POLYGON_2) + assertThat((newLoi2?.geometry as? Polygon)?.shell?.coordinates).isEqualTo(TEST_POLYGON_2) } @Test diff --git a/app/src/test/java/org/groundplatform/android/model/geometry/GeometryTest.kt b/app/src/test/java/org/groundplatform/android/model/geometry/GeometryTest.kt index 75dd0a62ab..1b9cdf5087 100644 --- a/app/src/test/java/org/groundplatform/android/model/geometry/GeometryTest.kt +++ b/app/src/test/java/org/groundplatform/android/model/geometry/GeometryTest.kt @@ -18,7 +18,6 @@ package org.groundplatform.android.model.geometry import com.google.common.truth.Truth.assertThat import org.groundplatform.android.data.local.room.converter.toLocalDataStoreObject import org.groundplatform.android.data.remote.firebase.schema.Path -import org.groundplatform.android.ui.map.gms.GmsExt.area import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.geometry.InvalidGeometryException import org.groundplatform.domain.model.geometry.LineString diff --git a/app/src/test/java/org/groundplatform/android/system/GeocodingManagerTest.kt b/app/src/test/java/org/groundplatform/android/system/GeocodingManagerTest.kt index eb3cdb2ebb..e0c432ec81 100644 --- a/app/src/test/java/org/groundplatform/android/system/GeocodingManagerTest.kt +++ b/app/src/test/java/org/groundplatform/android/system/GeocodingManagerTest.kt @@ -24,7 +24,6 @@ import javax.inject.Inject import kotlin.test.assertEquals import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.di.SystemModule -import org.groundplatform.android.ui.map.gms.GmsExt.center import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import org.junit.Test @@ -70,7 +69,7 @@ class GeocodingManagerTest( private val NW = Coordinates(N, W) private val SE = Coordinates(S, E) private val BOUNDS = Bounds(SW, NE) - private val CENTER = BOUNDS.center() + private val CENTER = BOUNDS.center private const val LOCALITY = "Marambaia" private const val SUB_ADMIN_AREA = "Belém" private const val ADMIN_AREA1 = "Parà" diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt index 39848e50b2..4894091c58 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt @@ -42,7 +42,6 @@ import org.groundplatform.android.ui.datacollection.components.ButtonAction import org.groundplatform.android.ui.datacollection.tasks.TaskPositionInterface import org.groundplatform.android.ui.datacollection.tasks.polygon.PolygonDrawingSession.Companion.DISTANCE_THRESHOLD_DP import org.groundplatform.android.ui.map.Feature -import org.groundplatform.android.ui.map.gms.GmsExt.getShellCoordinates import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.geometry.LineString import org.groundplatform.domain.model.geometry.LinearRing @@ -654,9 +653,14 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { val geometry = featureTestObserver.value()?.geometry assertNotNull(geometry) - assertWithMessage(geometry.getShellCoordinates().toString()) - .that(geometry.getShellCoordinates().size) - .isEqualTo(expectedVerticesCount) + val vertices = + when (geometry) { + is LineString -> geometry.coordinates + is LinearRing -> geometry.coordinates + is Polygon -> geometry.shell.coordinates + else -> emptyList() + } + assertWithMessage(vertices.toString()).that(vertices.size).isEqualTo(expectedVerticesCount) assertThat(geometry) .isInstanceOf( when { diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/geometry/Geometry.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/geometry/Geometry.kt index c03ca88c61..fae82f3bcf 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/geometry/Geometry.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/geometry/Geometry.kt @@ -15,26 +15,35 @@ */ package org.groundplatform.domain.model.geometry +import kotlin.math.max import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.groundplatform.domain.model.map.Bounds +import org.groundplatform.domain.util.calculateSphericalPolygonArea import org.groundplatform.domain.util.isClosed /** A common ancestor for all geometry types. */ @Serializable sealed interface Geometry { - /** - * Returns the center coordinates of the geometry. It may or may not be within the geometry bounds - * if the shape is irregular. - */ - fun center(): Coordinates - - /** Returns true if there are one or more vertices in the geometry. */ + /** Returns true if this geometry contains no coordinates or vertices. */ fun isEmpty(): Boolean /** Validates that the current [Geometry] is well-formed. */ fun validate() { // default no-op implementation } + + /** + * Returns the center coordinates of the geometry. It may or may not be within the geometry bounds + * if the shape is irregular. + */ + fun center(): Coordinates + + /** + * Returns the geodesic area of the geometry in square meters, or 0.0 for 0-dimensional and + * 1-dimensional geometries. + */ + fun area(): Double } /** @@ -45,11 +54,11 @@ sealed interface Geometry { @SerialName("polygon") data class Polygon(val shell: LinearRing, val holes: List = listOf()) : Geometry { - override fun center(): Coordinates = shell.center() + override fun isEmpty(): Boolean = shell.isEmpty() - override fun isEmpty() = shell.isEmpty() + override fun center(): Coordinates = shell.center() - fun getShellCoordinates() = shell.coordinates + override fun area(): Double = max(0.0, shell.area() - holes.sumOf { it.area() }) } /** Represents a single point. */ @@ -57,9 +66,11 @@ data class Polygon(val shell: LinearRing, val holes: List = listOf() @SerialName("point") data class Point(val coordinates: Coordinates) : Geometry { + override fun isEmpty(): Boolean = false + override fun center(): Coordinates = coordinates - override fun isEmpty() = false + override fun area(): Double = 0.0 } /** A collection of [Polygon]s. */ @@ -67,9 +78,11 @@ data class Point(val coordinates: Coordinates) : Geometry { @SerialName("multi_polygon") data class MultiPolygon(val polygons: List) : Geometry { + override fun isEmpty(): Boolean = polygons.all { it.isEmpty() } + override fun center(): Coordinates = polygons.map { it.center() }.centerOrError() - override fun isEmpty() = polygons.all { it.isEmpty() } + override fun area(): Double = polygons.sumOf { it.area() } } /** A sequence of two or more vertices modelling an OCG style line string. */ @@ -77,9 +90,11 @@ data class MultiPolygon(val polygons: List) : Geometry { @SerialName("line_string") data class LineString(val coordinates: List) : Geometry { + override fun isEmpty(): Boolean = coordinates.isEmpty() + override fun center(): Coordinates = coordinates.centerOrError() - override fun isEmpty() = coordinates.isEmpty() + override fun area(): Double = 0.0 fun isClosed(): Boolean = isClosed(coordinates) @@ -100,9 +115,7 @@ data class LinearRing(val coordinates: List) : Geometry { validate() } - override fun center(): Coordinates = coordinates.centerOrError() - - override fun isEmpty() = coordinates.isEmpty() + override fun isEmpty(): Boolean = coordinates.isEmpty() override fun validate() { // TODO: Check for vertices count > 3 @@ -115,6 +128,10 @@ data class LinearRing(val coordinates: List) : Geometry { } } + override fun center(): Coordinates = coordinates.centerOrError() + + override fun area(): Double = calculateSphericalPolygonArea(coordinates) + /** * Returns *synthetic* coordinates containing the maximum `x` and `y` coordinates of this ring. */ @@ -145,20 +162,10 @@ data class LinearRing(val coordinates: List) : Geometry { } /** - * Returns the center coordinates of the bounding box from the given list of coordinates. This - * mirrors the behavior of `LatLngBounds.center`, but is implemented in pure Kotlin and does not - * depend on Google Maps classes to keep this a pure domain module - * - * Note: This might return an unexpected result for oddly shaped polygons. Check if this can be - * replaced with a centroid. See (#1737) for more info. + * Returns the center coordinates of the bounding box from the given list of coordinates. Properly + * handles anti-meridian crossings. */ private fun List?.centerOrError(): Coordinates { if (this.isNullOrEmpty()) error("missing vertices") - - val minLat = this.minOf { it.lat } - val maxLat = this.maxOf { it.lat } - val minLng = this.minOf { it.lng } - val maxLng = this.maxOf { it.lng } - - return Coordinates(lat = (minLat + maxLat) / 2.0, lng = (minLng + maxLng) / 2.0) + return Bounds.fromCoordinates(this)?.center ?: error("missing vertices") } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/map/Bounds.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/map/Bounds.kt index a03aeb2db8..76819fea43 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/map/Bounds.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/map/Bounds.kt @@ -16,6 +16,12 @@ package org.groundplatform.domain.model.map import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.Geometry +import org.groundplatform.domain.model.geometry.LineString +import org.groundplatform.domain.model.geometry.LinearRing +import org.groundplatform.domain.model.geometry.MultiPolygon +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.geometry.Polygon /** * Represents a rectangular bound on a map. A bounds may be constructed using only southwest and @@ -55,16 +61,132 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { val corners get() = listOf(northwest, southwest, southeast, northeast) + /** Returns the center coordinates of these bounds. */ + val center: Coordinates + get() { + val centerLat = (south + north) / 2.0 + val centerLng = + if (west <= east) { + (west + east) / 2.0 + } else { + var lng = (west + east + 360.0) / 2.0 + if (lng > 180.0) lng -= 360.0 + lng + } + return Coordinates(centerLat, centerLng) + } + /** * Reduce size of bounding box by the specified factor. The width and height are multiplied by the * given value to produce a new bounding box centered on the same centroid as the original. */ fun shrink(factor: Double): Bounds { val latOffset = (north - south) * factor * 0.5 - val lngOffset = (east - west) * factor * 0.5 + val lngSpan = if (west <= east) east - west else 360.0 - (west - east) + val lngOffset = lngSpan * factor * 0.5 + var newWest = west + lngOffset + if (newWest > 180.0) newWest -= 360.0 else if (newWest < -180.0) newWest += 360.0 + var newEast = east - lngOffset + if (newEast > 180.0) newEast -= 360.0 else if (newEast < -180.0) newEast += 360.0 return Bounds( - Coordinates(south + latOffset, west + lngOffset), - Coordinates(north - latOffset, east - lngOffset), + Coordinates(south + latOffset, newWest), + Coordinates(north - latOffset, newEast), ) } + + /** Returns true if the given [Coordinates] is within these bounds. */ + fun contains(coordinates: Coordinates): Boolean { + val lat = coordinates.lat + val lng = coordinates.lng + if (lat !in south..north) return false + return if (west <= east) { + lng in west..east || + (lng == 180.0 && -180.0 in west..east) || + (lng == -180.0 && 180.0 in west..east) + } else { + lng >= west || + lng <= east || + (lng == 180.0 && (-180.0 >= west || -180.0 <= east)) || + (lng == -180.0 && (180.0 >= west || 180.0 <= east)) + } + } + + /** Returns true if any vertex of the given [Geometry] is within these bounds. */ + fun contains(geometry: Geometry): Boolean = + when (geometry) { + is Point -> contains(geometry.coordinates) + is LineString -> geometry.coordinates.any { contains(it) } + is LinearRing -> geometry.coordinates.any { contains(it) } + is Polygon -> geometry.shell.coordinates.any { contains(it) } + is MultiPolygon -> geometry.polygons.any { contains(it) } + } + + companion object { + /** Returns a [Bounds] enclosing the given geometry, or null if empty. */ + fun fromGeometry(geometry: Geometry): Bounds? = + when (geometry) { + is Point -> fromCoordinates(listOf(geometry.coordinates)) + is LineString -> fromCoordinates(geometry.coordinates) + is LinearRing -> fromCoordinates(geometry.coordinates) + is Polygon -> fromCoordinates(geometry.shell.coordinates) + is MultiPolygon -> fromGeometries(geometry.polygons) + } + + private fun isLongitudeContained(lng: Double, west: Double, east: Double): Boolean = + if (west <= east) lng in west..east else lng >= west || lng <= east + + /** + * Returns a [Bounds] enclosing all the given coordinates, or null if the collection is empty. + * + * Expands longitude using the minimal longitudinal span, properly handling bounds that cross + * the 180th meridian (anti-meridian). + */ + fun fromCoordinates(coordinates: Iterable): Bounds? { + val iterator = coordinates.iterator() + if (!iterator.hasNext()) return null + val first = iterator.next() + var minLat = first.lat + var maxLat = first.lat + var west = first.lng + var east = first.lng + + while (iterator.hasNext()) { + val point = iterator.next() + minLat = minOf(minLat, point.lat) + maxLat = maxOf(maxLat, point.lat) + val lng = point.lng + + if (!isLongitudeContained(lng, west, east)) { + val distWest = (west - lng).mod(360.0) + val distEast = (lng - east).mod(360.0) + if (distWest == 0.0 || distEast == 0.0) { + // Point is coincident with west or east boundary modulo 360 (e.g. -180.0 vs 180.0). + continue + } + if (distWest < distEast) { + west = lng + } else { + east = lng + } + } + } + return Bounds(south = minLat, west = west, north = maxLat, east = east) + } + + /** Returns a [Bounds] enclosing all geometries in the collection, or null if empty. */ + fun fromGeometries(geometries: Iterable): Bounds? { + val allCoordinates = mutableListOf() + fun collect(geom: Geometry) { + when (geom) { + is Point -> allCoordinates.add(geom.coordinates) + is LineString -> allCoordinates.addAll(geom.coordinates) + is LinearRing -> allCoordinates.addAll(geom.coordinates) + is Polygon -> allCoordinates.addAll(geom.shell.coordinates) + is MultiPolygon -> geom.polygons.forEach(::collect) + } + } + geometries.forEach(::collect) + return fromCoordinates(allCoordinates) + } + } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/PolygonUtil.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/PolygonUtil.kt index 319c38ebec..bd11f918bd 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/PolygonUtil.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/PolygonUtil.kt @@ -17,43 +17,46 @@ package org.groundplatform.domain.util import kotlin.math.PI import kotlin.math.abs +import kotlin.math.atan2 import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tan import org.groundplatform.domain.model.geometry.Coordinates -/** - * Calculates the area of a polygon using the Shoelace formula. - * - * This function computes the area of a simple, non-self-intersecting polygon based on its vertex - * coordinates. The first coordinate is used as a reference to convert all other points to meters. - * - * @param coordinates A list of [org.groundplatform.domain.model.geometry.Coordinates] representing - * the vertices of the polygon. The list must contain at least three points; otherwise, the - * function returns 0.0. - * @return The area of the polygon in square meters. - */ -fun calculateShoelacePolygonArea(coordinates: List): Double { - if (coordinates.size < 3) return 0.0 - - val reference = coordinates[0] - val points = coordinates.map { toMeters(reference, it) } +/** Earth's mean radius in meters used for spherical calculations. */ +private const val EARTH_RADIUS_METERS = 6371009.0 +private const val DEG_TO_RAD = PI / 180.0 - return abs( - points.indices.sumOf { i -> - val j = (i + 1) % points.size - points[i].first * points[j].second - points[j].first * points[i].second - } - ) / 2.0 +private fun polarTriangleArea(tan1: Double, lng1: Double, tan2: Double, lng2: Double): Double { + val deltaLng = lng1 - lng2 + val t = tan1 * tan2 + val num = t * sin(deltaLng) + val denom = 1.0 + t * cos(deltaLng) + return if (abs(num) < 1e-15 && abs(denom) < 1e-15) 0.0 else 2.0 * atan2(num, denom) } -/** Converts geographic coordinate to meters relative to reference point. */ -private fun toMeters(reference: Coordinates, point: Coordinates): Pair { - val earthRadius = 6378137.0 - val toRad = PI / 180.0 - val avgLat = (reference.lat + point.lat) / 2.0 - - val dX = (point.lng - reference.lng) * earthRadius * cos(avgLat * toRad) * toRad - val dY = (point.lat - reference.lat) * earthRadius * toRad - return Pair(dX, dY) +/** + * Returns the area of a closed path on Earth's surface in square meters, assuming a spherical + * Earth. + */ +fun calculateSphericalPolygonArea( + coordinates: List, + radius: Double = EARTH_RADIUS_METERS, +): Double { + val size = coordinates.size + if (size < 3) return 0.0 + var total = 0.0 + val prev = coordinates[size - 1] + var prevTanLat = tan((PI / 2.0 - prev.lat * DEG_TO_RAD) / 2.0) + var prevLng = prev.lng * DEG_TO_RAD + for (point in coordinates) { + val tanLat = tan((PI / 2.0 - point.lat * DEG_TO_RAD) / 2.0) + val lng = point.lng * DEG_TO_RAD + total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng) + prevTanLat = tanLat + prevLng = lng + } + return abs(total * (radius * radius)) } /** diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/geometry/GeometryTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/geometry/GeometryTest.kt new file mode 100644 index 0000000000..477ccdcd08 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/geometry/GeometryTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.domain.model.geometry + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GeometryTest { + + private val c1 = Coordinates(0.0, 0.0) + private val c2 = Coordinates(0.0, 1.0) + private val c3 = Coordinates(1.0, 1.0) + private val c4 = Coordinates(1.0, 0.0) + + @Test + fun point_properties() { + val point = Point(c1) + assertFalse(point.isEmpty()) + assertEquals(c1, point.center()) + assertEquals(0.0, point.area()) + assertEquals(c1, point.coordinates) + } + + @Test + fun lineString_properties() { + val lineString = LineString.lineStringOf(c1, c2, c3) + assertFalse(lineString.isEmpty()) + assertEquals(0.0, lineString.area()) + assertEquals(listOf(c1, c2, c3), lineString.coordinates) + assertFalse(lineString.isClosed()) + } + + @Test + fun linearRing_and_polygon_area() { + val shell = LinearRing(listOf(c1, c2, c3, c4, c1)) + assertTrue(shell.area() > 0.0) + + val polygon = Polygon(shell) + assertEquals(shell.area(), polygon.area()) + + val hole = + LinearRing( + listOf( + Coordinates(0.2, 0.2), + Coordinates(0.2, 0.8), + Coordinates(0.8, 0.8), + Coordinates(0.8, 0.2), + Coordinates(0.2, 0.2), + ) + ) + val polygonWithHole = Polygon(shell, listOf(hole)) + assertTrue(polygonWithHole.area() < polygon.area()) + assertEquals(shell.area() - hole.area(), polygonWithHole.area(), 1e-6) + + val multiPolygon = MultiPolygon(listOf(polygon, polygonWithHole)) + assertEquals(polygon.area() + polygonWithHole.area(), multiPolygon.area(), 1e-6) + } + + @Test + fun lineString_empty_properties() { + val emptyLineString = LineString(emptyList()) + assertTrue(emptyLineString.isEmpty()) + assertEquals(emptyList(), emptyLineString.coordinates) + assertEquals(0.0, emptyLineString.area()) + } + + @Test + fun linearRing_empty_properties() { + val emptyRing = LinearRing(emptyList()) + assertTrue(emptyRing.isEmpty()) + assertEquals(emptyList(), emptyRing.coordinates) + assertEquals(0.0, emptyRing.area()) + } + + @Test + fun polygon_shell_and_holes_properties() { + val shell = LinearRing(listOf(c1, c2, c3, c4, c1)) + val hole = + LinearRing( + listOf( + Coordinates(0.2, 0.2), + Coordinates(0.2, 0.8), + Coordinates(0.8, 0.8), + Coordinates(0.8, 0.2), + Coordinates(0.2, 0.2), + ) + ) + val polygon = Polygon(shell, listOf(hole)) + + assertEquals(shell.coordinates, polygon.shell.coordinates) + assertEquals(listOf(hole), polygon.holes) + assertFalse(polygon.shell.coordinates.contains(Coordinates(0.2, 0.2))) + } + + @Test + fun polygon_area_holeExceedingShellArea_clampsToZero() { + val shell = LinearRing(listOf(c1, c2, c3, c4, c1)) + val identicalHole = LinearRing(listOf(c1, c2, c3, c4, c1)) + val polygonZeroArea = Polygon(shell, listOf(identicalHole)) + assertEquals(0.0, polygonZeroArea.area()) + + val polygonClamped = Polygon(shell, listOf(identicalHole, identicalHole)) + assertEquals(0.0, polygonClamped.area()) + } + + @Test + fun multiPolygon_empty_properties() { + val emptyMultiPolygon = MultiPolygon(emptyList()) + assertTrue(emptyMultiPolygon.isEmpty()) + assertEquals(emptyList(), emptyMultiPolygon.polygons) + assertEquals(0.0, emptyMultiPolygon.area()) + } + + @Test + fun multiPolygon_properties() { + val shell1 = LinearRing(listOf(c1, c2, c1)) + val shell2 = LinearRing(listOf(c3, c4, c3)) + val p1 = Polygon(shell1) + val p2 = Polygon(shell2) + val multiPolygon = MultiPolygon(listOf(p1, p2)) + + assertEquals(listOf(p1, p2), multiPolygon.polygons) + } +} diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/map/BoundsTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/map/BoundsTest.kt new file mode 100644 index 0000000000..4835d34a74 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/map/BoundsTest.kt @@ -0,0 +1,367 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.domain.model.map + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.LineString +import org.groundplatform.domain.model.geometry.LinearRing +import org.groundplatform.domain.model.geometry.MultiPolygon +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.geometry.Polygon + +class BoundsTest { + + private val bounds = Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0) + + @Test + fun contains_coordinates_insideReturnsTrue() { + assertTrue(bounds.contains(Coordinates(20.0, 30.0))) + } + + @Test + fun contains_coordinates_onBoundaryReturnsTrue() { + assertTrue(bounds.contains(Coordinates(10.0, 20.0))) + assertTrue(bounds.contains(Coordinates(30.0, 40.0))) + } + + @Test + fun contains_coordinates_outsideReturnsFalse() { + assertFalse(bounds.contains(Coordinates(5.0, 30.0))) + assertFalse(bounds.contains(Coordinates(35.0, 30.0))) + assertFalse(bounds.contains(Coordinates(20.0, 15.0))) + assertFalse(bounds.contains(Coordinates(20.0, 45.0))) + } + + @Test + fun contains_coordinates_antiMeridianCrossing() { + val antiMeridianBounds = Bounds(south = -10.0, west = 170.0, north = 10.0, east = -170.0) + + assertTrue(antiMeridianBounds.contains(Coordinates(0.0, 175.0))) + assertTrue(antiMeridianBounds.contains(Coordinates(0.0, -175.0))) + assertFalse(antiMeridianBounds.contains(Coordinates(0.0, 0.0))) + } + + @Test + fun contains_pointGeometry() { + assertTrue(bounds.contains(Point(Coordinates(20.0, 30.0)))) + assertFalse(bounds.contains(Point(Coordinates(0.0, 0.0)))) + } + + @Test + fun contains_lineStringGeometry() { + val insideLine = LineString.lineStringOf(Coordinates(20.0, 30.0), Coordinates(25.0, 35.0)) + val outsideLine = LineString.lineStringOf(Coordinates(0.0, 0.0), Coordinates(5.0, 5.0)) + val intersectingLine = LineString.lineStringOf(Coordinates(0.0, 0.0), Coordinates(20.0, 30.0)) + + assertTrue(bounds.contains(insideLine)) + assertFalse(bounds.contains(outsideLine)) + assertTrue(bounds.contains(intersectingLine)) + } + + @Test + fun contains_polygonGeometry() { + val shellInside = + LinearRing( + listOf( + Coordinates(15.0, 25.0), + Coordinates(25.0, 25.0), + Coordinates(25.0, 35.0), + Coordinates(15.0, 25.0), + ) + ) + val shellOutside = + LinearRing( + listOf( + Coordinates(0.0, 0.0), + Coordinates(5.0, 0.0), + Coordinates(5.0, 5.0), + Coordinates(0.0, 0.0), + ) + ) + + assertTrue(bounds.contains(Polygon(shellInside))) + assertFalse(bounds.contains(Polygon(shellOutside))) + } + + @Test + fun fromGeometry_returnsExpectedBounds() { + val c1 = Coordinates(1.0, 2.0) + val c2 = Coordinates(3.0, 4.0) + val c3 = Coordinates(1.0, 2.0) + + val point = Point(c1) + assertEquals(Bounds(1.0, 2.0, 1.0, 2.0), Bounds.fromGeometry(point)) + + val lineString = LineString.lineStringOf(c1, c2) + assertEquals(Bounds(1.0, 2.0, 3.0, 4.0), Bounds.fromGeometry(lineString)) + + val linearRing = LinearRing(listOf(c1, c2, c3)) + assertEquals(Bounds(1.0, 2.0, 3.0, 4.0), Bounds.fromGeometry(linearRing)) + + val polygon = Polygon(linearRing) + assertEquals(Bounds(1.0, 2.0, 3.0, 4.0), Bounds.fromGeometry(polygon)) + + val multiPolygon = MultiPolygon(listOf(polygon)) + assertEquals(Bounds(1.0, 2.0, 3.0, 4.0), Bounds.fromGeometry(multiPolygon)) + } + + @Test + fun fromGeometries_returnsExpectedBounds() { + val p = Point(Coordinates(1.0, 2.0)) + val ls = LineString.lineStringOf(Coordinates(5.0, 6.0), Coordinates(7.0, 8.0)) + assertEquals(Bounds(1.0, 2.0, 7.0, 8.0), Bounds.fromGeometries(listOf(p, ls))) + assertNull(Bounds.fromGeometries(emptyList())) + } + + @Test + fun center_standardBounds() { + assertEquals(Coordinates(20.0, 30.0), bounds.center) + } + + @Test + fun center_antiMeridianCrossingBounds() { + val antiMeridianBounds = Bounds(south = -10.0, west = 170.0, north = 10.0, east = -170.0) + assertEquals(0.0, antiMeridianBounds.center.lat) + val lng = antiMeridianBounds.center.lng + assertTrue(lng == 180.0 || lng == -180.0) + } + + @Test + fun center_antiMeridianCrossing_lngGreaterThan180SubBranch() { + // west = 170.0, east = -150.0. + // (170.0 + -150.0 + 360.0) / 2.0 = 190.0 > 180.0 -> lng becomes 190.0 - 360.0 = -170.0 + val bounds = Bounds(south = -10.0, west = 170.0, north = 10.0, east = -150.0) + assertEquals(Coordinates(0.0, -170.0), bounds.center) + } + + @Test + fun center_negativeLongitudesAndEquatorCrossing() { + val bounds = Bounds(south = -30.0, west = -120.0, north = 10.0, east = -60.0) + assertEquals(Coordinates(-10.0, -90.0), bounds.center) + } + + @Test + fun center_zeroWidthBounds() { + val singlePointBounds = Bounds(south = 15.0, west = 25.0, north = 15.0, east = 25.0) + assertEquals(Coordinates(15.0, 25.0), singlePointBounds.center) + } + + @Test + fun shrink_standardBounds() { + val shrunk = bounds.shrink(0.5) + // original: south = 10, west = 20, north = 30, east = 40. latSpan = 20, lngSpan = 20. + // offset = 20 * 0.5 * 0.5 = 5. + assertEquals(Bounds(south = 15.0, west = 25.0, north = 25.0, east = 35.0), shrunk) + } + + @Test + fun shrink_antiMeridianCrossingBounds() { + val bounds = Bounds(south = -10.0, west = 170.0, north = 10.0, east = -170.0) + // span = 20. offset = 5. newWest = 175, newEast = -175. + val shrunk = bounds.shrink(0.5) + assertEquals(Bounds(south = -5.0, west = 175.0, north = 5.0, east = -175.0), shrunk) + } + + @Test + fun contains_coordinates_antiMeridianBoundaries() { + val antiMeridianBounds = Bounds(south = -10.0, west = 170.0, north = 10.0, east = -170.0) + + assertTrue(antiMeridianBounds.contains(Coordinates(0.0, 170.0))) + assertTrue(antiMeridianBounds.contains(Coordinates(0.0, -170.0))) + assertTrue(antiMeridianBounds.contains(Coordinates(0.0, 180.0))) + assertTrue(antiMeridianBounds.contains(Coordinates(0.0, -180.0))) + assertFalse(antiMeridianBounds.contains(Coordinates(0.0, 169.9999))) + assertFalse(antiMeridianBounds.contains(Coordinates(0.0, -169.9999))) + + // Latitude out of range when lng is inside + assertFalse(antiMeridianBounds.contains(Coordinates(15.0, 175.0))) + assertFalse(antiMeridianBounds.contains(Coordinates(-15.0, -175.0))) + } + + @Test + fun contains_coordinates_zeroWidthBounds() { + val zeroBounds = Bounds(south = 10.0, west = 20.0, north = 10.0, east = 20.0) + assertTrue(zeroBounds.contains(Coordinates(10.0, 20.0))) + assertFalse(zeroBounds.contains(Coordinates(10.0, 20.001))) + assertFalse(zeroBounds.contains(Coordinates(10.001, 20.0))) + } + + @Test + fun contains_linearRingGeometry() { + val ringInside = + LinearRing(listOf(Coordinates(15.0, 25.0), Coordinates(25.0, 25.0), Coordinates(15.0, 25.0))) + val ringOutside = + LinearRing(listOf(Coordinates(0.0, 0.0), Coordinates(5.0, 0.0), Coordinates(0.0, 0.0))) + val emptyRing = LinearRing(emptyList()) + + assertTrue(bounds.contains(ringInside)) + assertFalse(bounds.contains(ringOutside)) + assertFalse(bounds.contains(emptyRing)) + } + + @Test + fun contains_multiPolygonGeometry() { + val shellOutside = + LinearRing(listOf(Coordinates(0.0, 0.0), Coordinates(5.0, 0.0), Coordinates(0.0, 0.0))) + val shellInside = + LinearRing(listOf(Coordinates(20.0, 30.0), Coordinates(25.0, 30.0), Coordinates(20.0, 30.0))) + + val multiPolygonAllOutside = MultiPolygon(listOf(Polygon(shellOutside))) + val multiPolygonPartialInside = + MultiPolygon(listOf(Polygon(shellOutside), Polygon(shellInside))) + val emptyMultiPolygon = MultiPolygon(emptyList()) + + assertFalse(bounds.contains(multiPolygonAllOutside)) + assertTrue(bounds.contains(multiPolygonPartialInside)) + assertFalse(bounds.contains(emptyMultiPolygon)) + } + + @Test + fun contains_geometry_emptyLineStringReturnsFalse() { + assertFalse(bounds.contains(LineString(emptyList()))) + } + + @Test + fun contains_polygon_holeVerticesInsideIgnored() { + val shellOutside = + LinearRing( + listOf( + Coordinates(0.0, 0.0), + Coordinates(0.0, 5.0), + Coordinates(5.0, 5.0), + Coordinates(5.0, 0.0), + Coordinates(0.0, 0.0), + ) + ) + val holeInside = + LinearRing( + listOf( + Coordinates(20.0, 30.0), + Coordinates(20.0, 35.0), + Coordinates(25.0, 35.0), + Coordinates(20.0, 30.0), + ) + ) + val polygon = Polygon(shellOutside, listOf(holeInside)) + assertFalse(bounds.contains(polygon)) + } + + @Test + fun fromCoordinates_emptyCoordinates_returnsNull() { + assertEquals(null, Bounds.fromCoordinates(emptyList())) + assertEquals(null, Bounds.fromGeometries(emptyList())) + } + + @Test + fun fromCoordinates_singleCoordinate() { + val single = listOf(Coordinates(12.0, 34.0)) + val result = Bounds.fromCoordinates(single) + assertEquals(Bounds(south = 12.0, west = 34.0, north = 12.0, east = 34.0), result) + } + + @Test + fun fromCoordinates_standard() { + val coords = listOf(Coordinates(10.0, 40.0), Coordinates(30.0, 20.0)) + val result = Bounds.fromCoordinates(coords) + assertEquals(Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0), result) + } + + @Test + fun fromCoordinates_containedPointsDoNotExpandBounds() { + val coords = + listOf( + Coordinates(10.0, 20.0), + Coordinates(30.0, 40.0), + Coordinates(20.0, 30.0), + ) + val result = Bounds.fromCoordinates(coords) + assertEquals(Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0), result) + } + + @Test + fun fromCoordinates_antiMeridianCrossing() { + val coords = listOf(Coordinates(0.0, 178.0), Coordinates(0.0, -179.0)) + val result = Bounds.fromCoordinates(coords) + assertEquals(Bounds(south = 0.0, west = 178.0, north = 0.0, east = -179.0), result) + } + + @Test + fun fromCoordinates_antiMeridianContainedPointsAndExpansion() { + val coords = + listOf( + Coordinates(0.0, 175.0), + Coordinates(0.0, -175.0), + Coordinates(0.0, 178.0), + Coordinates(0.0, -178.0), + Coordinates(-10.0, 170.0), + Coordinates(10.0, -170.0), + ) + val result = Bounds.fromCoordinates(coords) + assertEquals(Bounds(south = -10.0, west = 170.0, north = 10.0, east = -170.0), result) + } + + @Test + fun fromCoordinates_antiMeridianDualRepresentationNoExplosion() { + val coords = listOf(Coordinates(0.0, -180.0), Coordinates(0.0, 180.0)) + val result = Bounds.fromCoordinates(coords) + assertEquals(Bounds(south = 0.0, west = -180.0, north = 0.0, east = -180.0), result) + } + + @Test + fun fromCoordinates_exactOppositeLongitudesTieBreaker() { + val coords = listOf(Coordinates(0.0, 0.0), Coordinates(0.0, 180.0)) + val result = Bounds.fromCoordinates(coords) + assertEquals(Bounds(south = 0.0, west = 0.0, north = 0.0, east = 180.0), result) + } + + @Test + fun fromGeometries_geometriesWithNoCoordinatesReturnsNull() { + val emptyGeometries = + listOf(LineString(emptyList()), LinearRing(emptyList()), MultiPolygon(emptyList())) + assertEquals(null, Bounds.fromGeometries(emptyGeometries)) + } + + @Test + fun fromGeometries_heterogeneousCollection() { + val point = Point(Coordinates(-10.0, -20.0)) + val lineString = LineString.lineStringOf(Coordinates(10.0, 20.0), Coordinates(30.0, 40.0)) + val result = Bounds.fromGeometries(listOf(point, lineString)) + assertEquals(Bounds(south = -10.0, west = -20.0, north = 30.0, east = 40.0), result) + } + + @Test + fun fromGeometry_singleGeometry() { + val polygon = + Polygon( + LinearRing( + listOf( + Coordinates(10.0, 20.0), + Coordinates(30.0, 20.0), + Coordinates(30.0, 40.0), + Coordinates(10.0, 20.0), + ) + ) + ) + val result = Bounds.fromGeometry(polygon) + assertEquals(Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0), result) + } +} diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/PolygonUtilTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/PolygonUtilTest.kt index 87b6257f1f..e8c41197e6 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/PolygonUtilTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/PolygonUtilTest.kt @@ -116,26 +116,18 @@ class PolygonUtilTest { } @Test - fun `calculateShoelacePolygonArea should return correct area for simple square`() { + fun calculateSphericalPolygonArea_smallSquare() { val coordinates = listOf( Coordinates(0.0, 0.0), Coordinates(0.0, 0.00001), Coordinates(0.00001, 0.00001), Coordinates(0.00001, 0.0), - Coordinates(0.0, 0.0), // Closing the polygon + Coordinates(0.0, 0.0), ) - val area = calculateShoelacePolygonArea(coordinates) - assertEquals(1.24, area, 0.01) // Allowing minor floating-point error - } - - @Test - fun `calculateShoelacePolygonArea should return 0 for less than 3 points`() { - val coordinates = listOf(Coordinates(24.523740, 73.606673), Coordinates(24.523736, 73.606803)) - - val area = calculateShoelacePolygonArea(coordinates) - assertEquals(0.0, area, 0.01) + val area = calculateSphericalPolygonArea(coordinates) + assertEquals(1.24, area, 0.01) } @Test @@ -170,6 +162,105 @@ class PolygonUtilTest { assertFalse(isClosed(emptyList())) } + @Test + fun calculateSphericalPolygonArea_lessThanThreeCoordinates_returnsZero() { + assertEquals(0.0, calculateSphericalPolygonArea(emptyList())) + assertEquals(0.0, calculateSphericalPolygonArea(listOf(Coordinates(0.0, 0.0)))) + assertEquals( + 0.0, + calculateSphericalPolygonArea(listOf(Coordinates(0.0, 0.0), Coordinates(1.0, 1.0))), + ) + } + + @Test + fun calculateSphericalPolygonArea_unitSphereOctantTriangle() { + val octantVertices = + listOf( + Coordinates(lat = 0.0, lng = 0.0), + Coordinates(lat = 0.0, lng = 90.0), + Coordinates(lat = 90.0, lng = 0.0), + ) + + val areaUnitSphere = calculateSphericalPolygonArea(octantVertices, radius = 1.0) + assertEquals(kotlin.math.PI / 2.0, areaUnitSphere, 1e-9) + + val areaRadiusTwo = calculateSphericalPolygonArea(octantVertices, radius = 2.0) + assertEquals(2.0 * kotlin.math.PI, areaRadiusTwo, 1e-9) + } + + @Test + fun calculateSphericalPolygonArea_orientationInvariance() { + val ccw = + listOf( + Coordinates(0.0, 0.0), + Coordinates(0.0, 90.0), + Coordinates(90.0, 0.0), + ) + val cw = + listOf( + Coordinates(0.0, 0.0), + Coordinates(90.0, 0.0), + Coordinates(0.0, 90.0), + ) + + val areaCcw = calculateSphericalPolygonArea(ccw, radius = 1.0) + val areaCw = calculateSphericalPolygonArea(cw, radius = 1.0) + assertEquals(areaCcw, areaCw, 1e-9) + } + + @Test + fun calculateSphericalPolygonArea_openAndClosedLoopsProduceSameArea() { + val open = + listOf( + Coordinates(0.0, 0.0), + Coordinates(0.0, 1.0), + Coordinates(1.0, 1.0), + Coordinates(1.0, 0.0), + ) + val closed = open + open.first() + + val openArea = calculateSphericalPolygonArea(open) + val closedArea = calculateSphericalPolygonArea(closed) + assertEquals(closedArea, openArea, 1e-6) + } + + @Test + fun calculateSphericalPolygonArea_collinearPoints_returnsZero() { + val collinear = + listOf( + Coordinates(0.0, 0.0), + Coordinates(0.0, 10.0), + Coordinates(0.0, 20.0), + ) + assertEquals(0.0, calculateSphericalPolygonArea(collinear), 1e-9) + } + + @Test + fun calculateSphericalPolygonArea_antiMeridianCrossing() { + val polygon = + listOf( + Coordinates(0.0, 179.0), + Coordinates(0.0, -179.0), + Coordinates(10.0, -179.0), + Coordinates(10.0, 179.0), + ) + val area = calculateSphericalPolygonArea(polygon) + assertTrue(area > 0.0) + } + + @Test + fun calculateSphericalPolygonArea_southernAndWesternHemispheres() { + val polygon = + listOf( + Coordinates(-20.0, -50.0), + Coordinates(-20.0, -40.0), + Coordinates(-10.0, -40.0), + Coordinates(-10.0, -50.0), + ) + val area = calculateSphericalPolygonArea(polygon) + assertTrue(area > 0.0) + } + companion object { val P1 = Coordinates(1.0, 1.0) val P2 = Coordinates(4.0, 4.0) diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt index 79c96b2b6b..f302f79a27 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt @@ -27,7 +27,6 @@ import org.groundplatform.domain.model.geometry.Polygon import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.submission.Submission import org.groundplatform.domain.usecases.user.GetUserSettingsUseCase -import org.groundplatform.domain.util.calculateShoelacePolygonArea import org.groundplatform.feature.pdf.PdfExportService import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer @@ -125,7 +124,7 @@ class LoiReportMapper( ): SubmissionPdfDocument.MapBlock { val areaInSquareMeters = (submissionDetails.geometry as? Polygon)?.let { geometry -> - calculateShoelacePolygonArea(geometry.shell.coordinates).takeIf { it > 0.0 } + geometry.area().takeIf { it > 0.0 } } return SubmissionPdfDocument.MapBlock( geometry = submissionDetails.geometry, diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt index e0b504291f..f8b0aeafa9 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -290,7 +290,7 @@ class LoiReportMapperTest { val mapBlock = mapper.map(report, submission)!!.document.mapBlock!! - assertEquals("2.48 ac", mapBlock.area!!.value) + assertEquals("2.47 ac", mapBlock.area!!.value) } @Test