From ef7d6137544ca3e3766f1e15e8d0aa233fe20def Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 28 Aug 2026 09:38:37 +0530 Subject: [PATCH 1/6] refactor: move geometry and bounds calculations to core domain and remove GmsExt dependencies --- .../LocationOfInterestRepository.kt | 1 - .../android/system/GeocodingManager.kt | 1 - .../android/ui/common/BaseMapViewModel.kt | 7 +- .../tasks/polygon/DrawAreaTaskMapFragment.kt | 4 +- .../HomeScreenMapContainerViewModel.kt | 1 - .../android/ui/map/gms/GmsExt.kt | 72 ------- .../map/gms/features/FeatureClusterManager.kt | 3 +- .../local/LocalLocationOfInterestStoreTest.kt | 1 - .../android/model/geometry/GeometryTest.kt | 1 - .../android/system/GeocodingManagerTest.kt | 1 - .../polygon/DrawAreaTaskViewModelTest.kt | 1 - .../domain/model/geometry/Geometry.kt | 33 +++- .../groundplatform/domain/model/map/Bounds.kt | 72 +++++++ .../groundplatform/domain/util/PolygonUtil.kt | 37 ++++ .../domain/model/geometry/GeometryTest.kt | 74 +++++++ .../domain/model/map/BoundsTest.kt | 180 ++++++++++++++++++ 16 files changed, 402 insertions(+), 87 deletions(-) delete mode 100644 app/src/main/java/org/groundplatform/android/ui/map/gms/GmsExt.kt create mode 100644 core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/geometry/GeometryTest.kt create mode 100644 core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/map/BoundsTest.kt 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..22f8e2c774 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 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..9873070e19 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.fromCoordinates(geometry.getShellCoordinates()) ?: return moveToBounds(bounds, padding = 200, shouldAnimate = false) } 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..42fe6e5843 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 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..69525aba9e 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 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..b7e7660a11 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 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..0761338860 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,8 +15,10 @@ */ package org.groundplatform.domain.model.geometry +import kotlin.math.max import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.groundplatform.domain.util.calculateSphericalPolygonArea import org.groundplatform.domain.util.isClosed /** A common ancestor for all geometry types. */ @@ -31,6 +33,15 @@ sealed interface Geometry { /** Returns true if there are one or more vertices in the geometry. */ fun isEmpty(): Boolean + /** Returns the list of [Coordinates] in the geometry or in the outer shell of the geometry. */ + fun getShellCoordinates(): List + + /** + * Returns the geodesic area of the geometry in square meters, or 0.0 for 0-dimensional and + * 1-dimensional geometries. + */ + fun area(): Double + /** Validates that the current [Geometry] is well-formed. */ fun validate() { // default no-op implementation @@ -49,7 +60,9 @@ data class Polygon(val shell: LinearRing, val holes: List = listOf() override fun isEmpty() = shell.isEmpty() - fun getShellCoordinates() = shell.coordinates + override fun getShellCoordinates(): List = shell.coordinates + + override fun area(): Double = max(0.0, shell.area() - holes.sumOf { it.area() }) } /** Represents a single point. */ @@ -60,6 +73,10 @@ data class Point(val coordinates: Coordinates) : Geometry { override fun center(): Coordinates = coordinates override fun isEmpty() = false + + override fun getShellCoordinates(): List = listOf(coordinates) + + override fun area(): Double = 0.0 } /** A collection of [Polygon]s. */ @@ -70,6 +87,12 @@ data class MultiPolygon(val polygons: List) : Geometry { override fun center(): Coordinates = polygons.map { it.center() }.centerOrError() override fun isEmpty() = polygons.all { it.isEmpty() } + + override fun getShellCoordinates(): List = polygons.flatMap { + it.getShellCoordinates() + } + + override fun area(): Double = polygons.sumOf { it.area() } } /** A sequence of two or more vertices modelling an OCG style line string. */ @@ -81,6 +104,10 @@ data class LineString(val coordinates: List) : Geometry { override fun isEmpty() = coordinates.isEmpty() + override fun getShellCoordinates(): List = coordinates + + override fun area(): Double = 0.0 + fun isClosed(): Boolean = isClosed(coordinates) companion object { @@ -104,6 +131,10 @@ data class LinearRing(val coordinates: List) : Geometry { override fun isEmpty() = coordinates.isEmpty() + override fun getShellCoordinates(): List = coordinates + + override fun area(): Double = calculateSphericalPolygonArea(coordinates) + override fun validate() { // TODO: Check for vertices count > 3 // Issue URL: https://github.com/google/ground-android/issues/1647 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..2bd5d53c1b 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,7 @@ package org.groundplatform.domain.model.map import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.Geometry /** * Represents a rectangular bound on a map. A bounds may be constructed using only southwest and @@ -55,6 +56,21 @@ 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. @@ -67,4 +83,60 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { Coordinates(north - latOffset, east - lngOffset), ) } + + /** Returns true if the given [Coordinates] is within these bounds. */ + fun contains(coordinates: Coordinates): Boolean { + val lat = coordinates.lat + val lng = coordinates.lng + val latInRange = lat in south..north + val lngInRange = if (west <= east) lng in west..east else lng >= west || lng <= east + return latInRange && lngInRange + } + + /** Returns true if any vertex of the given [Geometry] is within these bounds. */ + fun contains(geometry: Geometry): Boolean = geometry.getShellCoordinates().any { contains(it) } + + /** Returns the center coordinates of these bounds. */ + fun center(): Coordinates = center + + companion object { + /** + * 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 + + val isContained = if (west <= east) lng in west..east else lng >= west || lng <= east + if (!isContained) { + val distWest = (west - lng).mod(360.0) + val distEast = (lng - east).mod(360.0) + 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? = + fromCoordinates(geometries.flatMap { it.getShellCoordinates() }) + } } 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..09441dbe96 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,9 +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 +/** 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 + +private fun polarTriangleArea(tan1: Double, lng1: Double, tan2: Double, lng2: Double): Double { + val deltaLng = lng1 - lng2 + val t = tan1 * tan2 + return 2.0 * atan2(t * sin(deltaLng), 1.0 + t * cos(deltaLng)) +} + +/** + * 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)) +} + /** * Calculates the area of a polygon using the Shoelace formula. * 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..6108ee1bbb --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/geometry/GeometryTest.kt @@ -0,0 +1,74 @@ +/* + * 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(listOf(c1), point.getShellCoordinates()) + } + + @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.getShellCoordinates()) + 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) + } +} 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..ee8bdc9d44 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/map/BoundsTest.kt @@ -0,0 +1,180 @@ +/* + * 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.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 getShellCoordinates_returnsExpectedCoordinates() { + 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(listOf(c1), point.getShellCoordinates()) + + val lineString = LineString.lineStringOf(c1, c2) + assertEquals(listOf(c1, c2), lineString.getShellCoordinates()) + + val linearRing = LinearRing(listOf(c1, c2, c3)) + assertEquals(listOf(c1, c2, c3), linearRing.getShellCoordinates()) + + val polygon = Polygon(linearRing) + assertEquals(listOf(c1, c2, c3), polygon.getShellCoordinates()) + + val multiPolygon = MultiPolygon(listOf(polygon)) + assertEquals(listOf(c1, c2, c3), multiPolygon.getShellCoordinates()) + } + + @Test + fun center_standardBounds() { + assertEquals(Coordinates(20.0, 30.0), bounds.center) + 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 fromCoordinates_emptyCoordinates_returnsNull() { + assertEquals(null, Bounds.fromCoordinates(emptyList())) + assertEquals(null, Bounds.fromGeometries(emptyList())) + } + + @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_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 fromGeometries_geometry() { + 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.fromCoordinates(polygon.getShellCoordinates()) + assertEquals(Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0), result) + assertEquals( + Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0), + Bounds.fromGeometries(listOf(polygon)), + ) + } +} From 2a9efb5bd62a478bce081d8f4673fa6f2dc412ed Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 28 Aug 2026 09:51:29 +0530 Subject: [PATCH 2/6] refactor: improve Bounds logic for antimeridian support and geometry containment --- .../android/system/GeocodingManager.kt | 2 +- .../tasks/polygon/DrawAreaTaskMapFragment.kt | 2 +- .../android/system/GeocodingManagerTest.kt | 2 +- .../groundplatform/domain/model/map/Bounds.kt | 53 ++++- .../groundplatform/domain/util/PolygonUtil.kt | 4 +- .../domain/model/geometry/GeometryTest.kt | 65 ++++++ .../domain/model/map/BoundsTest.kt | 194 +++++++++++++++++- .../domain/util/PolygonUtilTest.kt | 99 +++++++++ 8 files changed, 401 insertions(+), 20 deletions(-) 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 22f8e2c774..791cfea82d 100644 --- a/app/src/main/java/org/groundplatform/android/system/GeocodingManager.kt +++ b/app/src/main/java/org/groundplatform/android/system/GeocodingManager.kt @@ -50,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/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt index 9873070e19..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 @@ -58,7 +58,7 @@ class DrawAreaTaskMapFragment @Inject constructor() : override fun setDefaultViewPort() { val feature = taskViewModel.draftArea.value val geometry = feature?.geometry ?: return - val bounds = Bounds.fromCoordinates(geometry.getShellCoordinates()) ?: return + val bounds = Bounds.fromGeometry(geometry) ?: return moveToBounds(bounds, padding = 200, shouldAnimate = false) } 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 69525aba9e..e0c432ec81 100644 --- a/app/src/test/java/org/groundplatform/android/system/GeocodingManagerTest.kt +++ b/app/src/test/java/org/groundplatform/android/system/GeocodingManagerTest.kt @@ -69,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/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 2bd5d53c1b..f8ccc56190 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 @@ -17,6 +17,11 @@ 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 @@ -77,10 +82,15 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { */ 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), ) } @@ -88,18 +98,40 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { fun contains(coordinates: Coordinates): Boolean { val lat = coordinates.lat val lng = coordinates.lng - val latInRange = lat in south..north - val lngInRange = if (west <= east) lng in west..east else lng >= west || lng <= east - return latInRange && lngInRange + 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 = geometry.getShellCoordinates().any { contains(it) } + 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) } + } /** Returns the center coordinates of these bounds. */ + @Deprecated("Use center property instead", ReplaceWith("center")) fun center(): Coordinates = center companion object { + /** Returns a [Bounds] enclosing the given geometry, or null if empty. */ + fun fromGeometry(geometry: Geometry): Bounds? = fromCoordinates(geometry.getShellCoordinates()) + + 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. * @@ -121,10 +153,13 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { maxLat = maxOf(maxLat, point.lat) val lng = point.lng - val isContained = if (west <= east) lng in west..east else lng >= west || lng <= east - if (!isContained) { + 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 { 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 09441dbe96..49f90b5625 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 @@ -30,7 +30,9 @@ private const val DEG_TO_RAD = PI / 180.0 private fun polarTriangleArea(tan1: Double, lng1: Double, tan2: Double, lng2: Double): Double { val deltaLng = lng1 - lng2 val t = tan1 * tan2 - return 2.0 * atan2(t * sin(deltaLng), 1.0 + t * cos(deltaLng)) + 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) } /** 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 index 6108ee1bbb..4851232a12 100644 --- 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 @@ -71,4 +71,69 @@ class GeometryTest { 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.getShellCoordinates()) + assertEquals(0.0, emptyLineString.area()) + } + + @Test + fun linearRing_empty_properties() { + val emptyRing = LinearRing(emptyList()) + assertTrue(emptyRing.isEmpty()) + assertEquals(emptyList(), emptyRing.getShellCoordinates()) + assertEquals(0.0, emptyRing.area()) + } + + @Test + fun polygon_getShellCoordinates_excludesHoles() { + 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.getShellCoordinates()) + assertFalse(polygon.getShellCoordinates().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.getShellCoordinates()) + assertEquals(0.0, emptyMultiPolygon.area()) + } + + @Test + fun multiPolygon_getShellCoordinates_flattensMultiplePolygons() { + 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(shell1.coordinates + shell2.coordinates, multiPolygon.getShellCoordinates()) + } } 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 index ee8bdc9d44..82f489a48f 100644 --- 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 @@ -126,7 +126,7 @@ class BoundsTest { @Test fun center_standardBounds() { assertEquals(Coordinates(20.0, 30.0), bounds.center) - assertEquals(Coordinates(20.0, 30.0), bounds.center()) + @Suppress("DEPRECATION") assertEquals(Coordinates(20.0, 30.0), bounds.center()) } @Test @@ -137,12 +137,140 @@ class BoundsTest { 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) + @Suppress("DEPRECATION") 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)) @@ -150,6 +278,18 @@ class BoundsTest { 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)) @@ -158,7 +298,51 @@ class BoundsTest { } @Test - fun fromGeometries_geometry() { + 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( @@ -170,11 +354,7 @@ class BoundsTest { ) ) ) - val result = Bounds.fromCoordinates(polygon.getShellCoordinates()) + val result = Bounds.fromGeometry(polygon) assertEquals(Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0), result) - assertEquals( - Bounds(south = 10.0, west = 20.0, north = 30.0, east = 40.0), - Bounds.fromGeometries(listOf(polygon)), - ) } } 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..a95a4d5f5a 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 @@ -170,6 +170,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) From 009a06025f5814263bcfb05d4d3966f9a3e55e3b Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 28 Aug 2026 10:06:11 +0530 Subject: [PATCH 3/6] refactor: replace getShellCoordinates() with shellCoordinates property and improve Geometry interface --- .../tasks/polygon/DrawAreaTaskViewModel.kt | 2 +- .../local/LocalLocationOfInterestStoreTest.kt | 2 +- .../polygon/DrawAreaTaskViewModelTest.kt | 4 +- .../domain/model/geometry/Geometry.kt | 82 +++++++++---------- .../groundplatform/domain/model/map/Bounds.kt | 4 +- .../domain/model/geometry/GeometryTest.kt | 16 ++-- .../domain/model/map/BoundsTest.kt | 12 +-- 7 files changed, 58 insertions(+), 64 deletions(-) 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..af46622441 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 @@ -143,7 +143,7 @@ internal constructor( updateVertices(taskData.lineString.coordinates) } is DrawAreaTaskData -> { - updateVertices(taskData.area.getShellCoordinates()) + updateVertices(taskData.area.shellCoordinates) try { completePolygon() } catch (e: IllegalStateException) { 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 42fe6e5843..d97af350e2 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 @@ -174,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?.shellCoordinates).isEqualTo(TEST_POLYGON_2) } @Test 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 b7e7660a11..5f389d1984 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 @@ -653,8 +653,8 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { val geometry = featureTestObserver.value()?.geometry assertNotNull(geometry) - assertWithMessage(geometry.getShellCoordinates().toString()) - .that(geometry.getShellCoordinates().size) + assertWithMessage(geometry.shellCoordinates.toString()) + .that(geometry.shellCoordinates.size) .isEqualTo(expectedVerticesCount) assertThat(geometry) .isInstanceOf( 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 0761338860..b08c493a36 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 @@ -18,34 +18,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 { + /** The list of [Coordinates] in the geometry or forming the outer shell of the geometry. */ + val shellCoordinates: List + + /** 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 true if there are one or more vertices in the geometry. */ - fun isEmpty(): Boolean - - /** Returns the list of [Coordinates] in the geometry or in the outer shell of the geometry. */ - fun getShellCoordinates(): List - /** * Returns the geodesic area of the geometry in square meters, or 0.0 for 0-dimensional and * 1-dimensional geometries. */ fun area(): Double - - /** Validates that the current [Geometry] is well-formed. */ - fun validate() { - // default no-op implementation - } } /** @@ -56,11 +57,12 @@ sealed interface Geometry { @SerialName("polygon") data class Polygon(val shell: LinearRing, val holes: List = listOf()) : Geometry { - override fun center(): Coordinates = shell.center() + override val shellCoordinates: List + get() = shell.coordinates - override fun isEmpty() = shell.isEmpty() + override fun isEmpty(): Boolean = shell.isEmpty() - override fun getShellCoordinates(): List = shell.coordinates + override fun center(): Coordinates = shell.center() override fun area(): Double = max(0.0, shell.area() - holes.sumOf { it.area() }) } @@ -70,11 +72,12 @@ data class Polygon(val shell: LinearRing, val holes: List = listOf() @SerialName("point") data class Point(val coordinates: Coordinates) : Geometry { - override fun center(): Coordinates = coordinates + override val shellCoordinates: List + get() = listOf(coordinates) - override fun isEmpty() = false + override fun isEmpty(): Boolean = false - override fun getShellCoordinates(): List = listOf(coordinates) + override fun center(): Coordinates = coordinates override fun area(): Double = 0.0 } @@ -84,13 +87,12 @@ data class Point(val coordinates: Coordinates) : Geometry { @SerialName("multi_polygon") data class MultiPolygon(val polygons: List) : Geometry { - override fun center(): Coordinates = polygons.map { it.center() }.centerOrError() + override val shellCoordinates: List + get() = polygons.flatMap { it.shellCoordinates } - override fun isEmpty() = polygons.all { it.isEmpty() } + override fun isEmpty(): Boolean = polygons.all { it.isEmpty() } - override fun getShellCoordinates(): List = polygons.flatMap { - it.getShellCoordinates() - } + override fun center(): Coordinates = polygons.map { it.center() }.centerOrError() override fun area(): Double = polygons.sumOf { it.area() } } @@ -100,11 +102,12 @@ data class MultiPolygon(val polygons: List) : Geometry { @SerialName("line_string") data class LineString(val coordinates: List) : Geometry { - override fun center(): Coordinates = coordinates.centerOrError() + override val shellCoordinates: List + get() = coordinates - override fun isEmpty() = coordinates.isEmpty() + override fun isEmpty(): Boolean = coordinates.isEmpty() - override fun getShellCoordinates(): List = coordinates + override fun center(): Coordinates = coordinates.centerOrError() override fun area(): Double = 0.0 @@ -127,13 +130,10 @@ data class LinearRing(val coordinates: List) : Geometry { validate() } - override fun center(): Coordinates = coordinates.centerOrError() - - override fun isEmpty() = coordinates.isEmpty() + override val shellCoordinates: List + get() = coordinates - override fun getShellCoordinates(): List = coordinates - - override fun area(): Double = calculateSphericalPolygonArea(coordinates) + override fun isEmpty(): Boolean = coordinates.isEmpty() override fun validate() { // TODO: Check for vertices count > 3 @@ -146,6 +146,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. */ @@ -176,20 +180,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 f8ccc56190..7237e47b39 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 @@ -127,7 +127,7 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { companion object { /** Returns a [Bounds] enclosing the given geometry, or null if empty. */ - fun fromGeometry(geometry: Geometry): Bounds? = fromCoordinates(geometry.getShellCoordinates()) + fun fromGeometry(geometry: Geometry): Bounds? = fromCoordinates(geometry.shellCoordinates) private fun isLongitudeContained(lng: Double, west: Double, east: Double): Boolean = if (west <= east) lng in west..east else lng >= west || lng <= east @@ -172,6 +172,6 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { /** Returns a [Bounds] enclosing all geometries in the collection, or null if empty. */ fun fromGeometries(geometries: Iterable): Bounds? = - fromCoordinates(geometries.flatMap { it.getShellCoordinates() }) + fromCoordinates(geometries.flatMap { it.shellCoordinates }) } } 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 index 4851232a12..2b28e6aaa3 100644 --- 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 @@ -34,7 +34,7 @@ class GeometryTest { assertFalse(point.isEmpty()) assertEquals(c1, point.center()) assertEquals(0.0, point.area()) - assertEquals(listOf(c1), point.getShellCoordinates()) + assertEquals(listOf(c1), point.shellCoordinates) } @Test @@ -42,7 +42,7 @@ class GeometryTest { val lineString = LineString.lineStringOf(c1, c2, c3) assertFalse(lineString.isEmpty()) assertEquals(0.0, lineString.area()) - assertEquals(listOf(c1, c2, c3), lineString.getShellCoordinates()) + assertEquals(listOf(c1, c2, c3), lineString.shellCoordinates) assertFalse(lineString.isClosed()) } @@ -76,7 +76,7 @@ class GeometryTest { fun lineString_empty_properties() { val emptyLineString = LineString(emptyList()) assertTrue(emptyLineString.isEmpty()) - assertEquals(emptyList(), emptyLineString.getShellCoordinates()) + assertEquals(emptyList(), emptyLineString.shellCoordinates) assertEquals(0.0, emptyLineString.area()) } @@ -84,7 +84,7 @@ class GeometryTest { fun linearRing_empty_properties() { val emptyRing = LinearRing(emptyList()) assertTrue(emptyRing.isEmpty()) - assertEquals(emptyList(), emptyRing.getShellCoordinates()) + assertEquals(emptyList(), emptyRing.shellCoordinates) assertEquals(0.0, emptyRing.area()) } @@ -103,8 +103,8 @@ class GeometryTest { ) val polygon = Polygon(shell, listOf(hole)) - assertEquals(shell.coordinates, polygon.getShellCoordinates()) - assertFalse(polygon.getShellCoordinates().contains(Coordinates(0.2, 0.2))) + assertEquals(shell.coordinates, polygon.shellCoordinates) + assertFalse(polygon.shellCoordinates.contains(Coordinates(0.2, 0.2))) } @Test @@ -122,7 +122,7 @@ class GeometryTest { fun multiPolygon_empty_properties() { val emptyMultiPolygon = MultiPolygon(emptyList()) assertTrue(emptyMultiPolygon.isEmpty()) - assertEquals(emptyList(), emptyMultiPolygon.getShellCoordinates()) + assertEquals(emptyList(), emptyMultiPolygon.shellCoordinates) assertEquals(0.0, emptyMultiPolygon.area()) } @@ -134,6 +134,6 @@ class GeometryTest { val p2 = Polygon(shell2) val multiPolygon = MultiPolygon(listOf(p1, p2)) - assertEquals(shell1.coordinates + shell2.coordinates, multiPolygon.getShellCoordinates()) + assertEquals(shell1.coordinates + shell2.coordinates, multiPolygon.shellCoordinates) } } 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 index 82f489a48f..2c41957bab 100644 --- 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 @@ -102,25 +102,25 @@ class BoundsTest { } @Test - fun getShellCoordinates_returnsExpectedCoordinates() { + fun shellCoordinates_returnsExpectedCoordinates() { 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(listOf(c1), point.getShellCoordinates()) + assertEquals(listOf(c1), point.shellCoordinates) val lineString = LineString.lineStringOf(c1, c2) - assertEquals(listOf(c1, c2), lineString.getShellCoordinates()) + assertEquals(listOf(c1, c2), lineString.shellCoordinates) val linearRing = LinearRing(listOf(c1, c2, c3)) - assertEquals(listOf(c1, c2, c3), linearRing.getShellCoordinates()) + assertEquals(listOf(c1, c2, c3), linearRing.shellCoordinates) val polygon = Polygon(linearRing) - assertEquals(listOf(c1, c2, c3), polygon.getShellCoordinates()) + assertEquals(listOf(c1, c2, c3), polygon.shellCoordinates) val multiPolygon = MultiPolygon(listOf(polygon)) - assertEquals(listOf(c1, c2, c3), multiPolygon.getShellCoordinates()) + assertEquals(listOf(c1, c2, c3), multiPolygon.shellCoordinates) } @Test From c3fde38e8b6316d1d792d8b31588f77f957e52e4 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 28 Aug 2026 10:12:01 +0530 Subject: [PATCH 4/6] refactor: remove shellCoordinates from Geometry interface in favor of pattern matching --- .../tasks/polygon/DrawAreaTaskViewModel.kt | 2 +- .../local/LocalLocationOfInterestStoreTest.kt | 2 +- .../polygon/DrawAreaTaskViewModelTest.kt | 11 +++++--- .../domain/model/geometry/Geometry.kt | 18 ------------- .../groundplatform/domain/model/map/Bounds.kt | 25 ++++++++++++++++--- .../domain/model/geometry/GeometryTest.kt | 21 ++++++++-------- .../domain/model/map/BoundsTest.kt | 21 +++++++++++----- 7 files changed, 58 insertions(+), 42 deletions(-) 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 af46622441..396df9ec3e 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 @@ -143,7 +143,7 @@ internal constructor( updateVertices(taskData.lineString.coordinates) } is DrawAreaTaskData -> { - updateVertices(taskData.area.shellCoordinates) + updateVertices(taskData.area.shell.coordinates) try { completePolygon() } catch (e: IllegalStateException) { 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 d97af350e2..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 @@ -174,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?.shellCoordinates).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/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt index 5f389d1984..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 @@ -653,9 +653,14 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { val geometry = featureTestObserver.value()?.geometry assertNotNull(geometry) - assertWithMessage(geometry.shellCoordinates.toString()) - .that(geometry.shellCoordinates.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 b08c493a36..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 @@ -25,9 +25,6 @@ import org.groundplatform.domain.util.isClosed /** A common ancestor for all geometry types. */ @Serializable sealed interface Geometry { - /** The list of [Coordinates] in the geometry or forming the outer shell of the geometry. */ - val shellCoordinates: List - /** Returns true if this geometry contains no coordinates or vertices. */ fun isEmpty(): Boolean @@ -57,9 +54,6 @@ sealed interface Geometry { @SerialName("polygon") data class Polygon(val shell: LinearRing, val holes: List = listOf()) : Geometry { - override val shellCoordinates: List - get() = shell.coordinates - override fun isEmpty(): Boolean = shell.isEmpty() override fun center(): Coordinates = shell.center() @@ -72,9 +66,6 @@ data class Polygon(val shell: LinearRing, val holes: List = listOf() @SerialName("point") data class Point(val coordinates: Coordinates) : Geometry { - override val shellCoordinates: List - get() = listOf(coordinates) - override fun isEmpty(): Boolean = false override fun center(): Coordinates = coordinates @@ -87,9 +78,6 @@ data class Point(val coordinates: Coordinates) : Geometry { @SerialName("multi_polygon") data class MultiPolygon(val polygons: List) : Geometry { - override val shellCoordinates: List - get() = polygons.flatMap { it.shellCoordinates } - override fun isEmpty(): Boolean = polygons.all { it.isEmpty() } override fun center(): Coordinates = polygons.map { it.center() }.centerOrError() @@ -102,9 +90,6 @@ data class MultiPolygon(val polygons: List) : Geometry { @SerialName("line_string") data class LineString(val coordinates: List) : Geometry { - override val shellCoordinates: List - get() = coordinates - override fun isEmpty(): Boolean = coordinates.isEmpty() override fun center(): Coordinates = coordinates.centerOrError() @@ -130,9 +115,6 @@ data class LinearRing(val coordinates: List) : Geometry { validate() } - override val shellCoordinates: List - get() = coordinates - override fun isEmpty(): Boolean = coordinates.isEmpty() override fun validate() { 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 7237e47b39..785bb57c4a 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 @@ -127,7 +127,14 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { companion object { /** Returns a [Bounds] enclosing the given geometry, or null if empty. */ - fun fromGeometry(geometry: Geometry): Bounds? = fromCoordinates(geometry.shellCoordinates) + 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 @@ -171,7 +178,19 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { } /** Returns a [Bounds] enclosing all geometries in the collection, or null if empty. */ - fun fromGeometries(geometries: Iterable): Bounds? = - fromCoordinates(geometries.flatMap { it.shellCoordinates }) + 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/commonTest/kotlin/org/groundplatform/domain/model/geometry/GeometryTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/geometry/GeometryTest.kt index 2b28e6aaa3..477ccdcd08 100644 --- 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 @@ -34,7 +34,7 @@ class GeometryTest { assertFalse(point.isEmpty()) assertEquals(c1, point.center()) assertEquals(0.0, point.area()) - assertEquals(listOf(c1), point.shellCoordinates) + assertEquals(c1, point.coordinates) } @Test @@ -42,7 +42,7 @@ class GeometryTest { val lineString = LineString.lineStringOf(c1, c2, c3) assertFalse(lineString.isEmpty()) assertEquals(0.0, lineString.area()) - assertEquals(listOf(c1, c2, c3), lineString.shellCoordinates) + assertEquals(listOf(c1, c2, c3), lineString.coordinates) assertFalse(lineString.isClosed()) } @@ -76,7 +76,7 @@ class GeometryTest { fun lineString_empty_properties() { val emptyLineString = LineString(emptyList()) assertTrue(emptyLineString.isEmpty()) - assertEquals(emptyList(), emptyLineString.shellCoordinates) + assertEquals(emptyList(), emptyLineString.coordinates) assertEquals(0.0, emptyLineString.area()) } @@ -84,12 +84,12 @@ class GeometryTest { fun linearRing_empty_properties() { val emptyRing = LinearRing(emptyList()) assertTrue(emptyRing.isEmpty()) - assertEquals(emptyList(), emptyRing.shellCoordinates) + assertEquals(emptyList(), emptyRing.coordinates) assertEquals(0.0, emptyRing.area()) } @Test - fun polygon_getShellCoordinates_excludesHoles() { + fun polygon_shell_and_holes_properties() { val shell = LinearRing(listOf(c1, c2, c3, c4, c1)) val hole = LinearRing( @@ -103,8 +103,9 @@ class GeometryTest { ) val polygon = Polygon(shell, listOf(hole)) - assertEquals(shell.coordinates, polygon.shellCoordinates) - assertFalse(polygon.shellCoordinates.contains(Coordinates(0.2, 0.2))) + assertEquals(shell.coordinates, polygon.shell.coordinates) + assertEquals(listOf(hole), polygon.holes) + assertFalse(polygon.shell.coordinates.contains(Coordinates(0.2, 0.2))) } @Test @@ -122,18 +123,18 @@ class GeometryTest { fun multiPolygon_empty_properties() { val emptyMultiPolygon = MultiPolygon(emptyList()) assertTrue(emptyMultiPolygon.isEmpty()) - assertEquals(emptyList(), emptyMultiPolygon.shellCoordinates) + assertEquals(emptyList(), emptyMultiPolygon.polygons) assertEquals(0.0, emptyMultiPolygon.area()) } @Test - fun multiPolygon_getShellCoordinates_flattensMultiplePolygons() { + 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(shell1.coordinates + shell2.coordinates, multiPolygon.shellCoordinates) + 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 index 2c41957bab..628d255213 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -102,25 +103,33 @@ class BoundsTest { } @Test - fun shellCoordinates_returnsExpectedCoordinates() { + 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(listOf(c1), point.shellCoordinates) + assertEquals(Bounds(1.0, 2.0, 1.0, 2.0), Bounds.fromGeometry(point)) val lineString = LineString.lineStringOf(c1, c2) - assertEquals(listOf(c1, c2), lineString.shellCoordinates) + assertEquals(Bounds(1.0, 2.0, 3.0, 4.0), Bounds.fromGeometry(lineString)) val linearRing = LinearRing(listOf(c1, c2, c3)) - assertEquals(listOf(c1, c2, c3), linearRing.shellCoordinates) + assertEquals(Bounds(1.0, 2.0, 3.0, 4.0), Bounds.fromGeometry(linearRing)) val polygon = Polygon(linearRing) - assertEquals(listOf(c1, c2, c3), polygon.shellCoordinates) + assertEquals(Bounds(1.0, 2.0, 3.0, 4.0), Bounds.fromGeometry(polygon)) val multiPolygon = MultiPolygon(listOf(polygon)) - assertEquals(listOf(c1, c2, c3), multiPolygon.shellCoordinates) + 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 From 61cc8dc77683b52a16a734ed2f25dc4b05361dd2 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 28 Aug 2026 10:16:07 +0530 Subject: [PATCH 5/6] Remove deprecated Bounds.center() method and corresponding tests --- .../kotlin/org/groundplatform/domain/model/map/Bounds.kt | 4 ---- .../kotlin/org/groundplatform/domain/model/map/BoundsTest.kt | 2 -- 2 files changed, 6 deletions(-) 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 785bb57c4a..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 @@ -121,10 +121,6 @@ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { is MultiPolygon -> geometry.polygons.any { contains(it) } } - /** Returns the center coordinates of these bounds. */ - @Deprecated("Use center property instead", ReplaceWith("center")) - fun center(): Coordinates = center - companion object { /** Returns a [Bounds] enclosing the given geometry, or null if empty. */ fun fromGeometry(geometry: Geometry): Bounds? = 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 index 628d255213..4835d34a74 100644 --- 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 @@ -135,7 +135,6 @@ class BoundsTest { @Test fun center_standardBounds() { assertEquals(Coordinates(20.0, 30.0), bounds.center) - @Suppress("DEPRECATION") assertEquals(Coordinates(20.0, 30.0), bounds.center()) } @Test @@ -152,7 +151,6 @@ class BoundsTest { // (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) - @Suppress("DEPRECATION") assertEquals(Coordinates(0.0, -170.0), bounds.center()) } @Test From e86dfd047b374eea041e9506836fdb0fbc99bf9e Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Fri, 28 Aug 2026 17:15:38 +0530 Subject: [PATCH 6/6] refactor: replace shoelace area calculation with Polygon.area() method --- .../tasks/polygon/DrawAreaTaskViewModel.kt | 7 ++-- .../groundplatform/domain/util/PolygonUtil.kt | 36 ------------------- .../domain/util/PolygonUtilTest.kt | 16 +++------ .../feature/pdf/mapper/LoiReportMapper.kt | 3 +- .../feature/pdf/mapper/LoiReportMapperTest.kt | 2 +- 5 files changed, 9 insertions(+), 55 deletions(-) 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 396df9ec3e..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 @@ -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/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/PolygonUtil.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/PolygonUtil.kt index 49f90b5625..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 @@ -59,42 +59,6 @@ fun calculateSphericalPolygonArea( return abs(total * (radius * radius)) } -/** - * 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) } - - 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 -} - -/** 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) -} - /** * Checks if a polygon is self-intersecting. * 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 a95a4d5f5a..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 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