diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java index f9bff856d..58cca4052 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java +++ b/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java @@ -24,7 +24,6 @@ import com.google.maps.android.projection.SphericalMercatorProjection; import com.google.maps.android.quadtree.PointQuadTree; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; diff --git a/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.java b/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.java deleted file mode 100644 index 55271cbea..000000000 --- a/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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 - * - * http://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 com.google.maps.android.projection; - -import com.google.android.gms.maps.model.LatLng; - -public class SphericalMercatorProjection { - final double mWorldWidth; - - public SphericalMercatorProjection(final double worldWidth) { - mWorldWidth = worldWidth; - } - - @SuppressWarnings("deprecation") - public Point toPoint(final LatLng latLng) { - final double x = latLng.longitude / 360 + .5; - final double siny = Math.sin(Math.toRadians(latLng.latitude)); - final double y = 0.5 * Math.log((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5; - - return new Point(x * mWorldWidth, y * mWorldWidth); - } - - public LatLng toLatLng(com.google.maps.android.geometry.Point point) { - final double x = point.x / mWorldWidth - 0.5; - final double lng = x * 360; - - double y = .5 - (point.y / mWorldWidth); - final double lat = 90 - Math.toDegrees(Math.atan(Math.exp(-y * 2 * Math.PI)) * 2); - - return new LatLng(lat, lng); - } -} diff --git a/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt b/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt new file mode 100644 index 000000000..4387f6ae4 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 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 + * + * http://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 com.google.maps.android.projection + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.geometry.Point +import kotlin.math.* + +class SphericalMercatorProjection(private val worldWidth: Double) { + + fun toPoint(latLng: LatLng): Point { + val x = latLng.longitude / 360 + 0.5 + val siny = sin(Math.toRadians(latLng.latitude)) + val y = 0.5 * ln((1 + siny) / (1 - siny)) / -(2 * Math.PI) + 0.5 + return Point(x * worldWidth, y * worldWidth) + } + + fun toLatLng(point: Point): LatLng { + val x = point.x / worldWidth - 0.5 + val lng = x * 360 + val y = 0.5 - (point.y / worldWidth) + val lat = 90 - Math.toDegrees(atan(exp(-y * 2 * Math.PI)) * 2) + return LatLng(lat, lng) + } +} diff --git a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java deleted file mode 100644 index 895bccd25..000000000 --- a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java +++ /dev/null @@ -1,372 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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 - * - * http://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 com.google.maps.android; - -import com.google.android.gms.maps.model.LatLng; - -import org.junit.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static com.google.maps.android.MathUtil.EARTH_RADIUS; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class SphericalUtilTest { - // The vertices of an octahedron, for testing - private final LatLng up = new LatLng(90, 0); - private final LatLng down = new LatLng(-90, 0); - private final LatLng front = new LatLng(0, 0); - private final LatLng right = new LatLng(0, 90); - private final LatLng back = new LatLng(0, -180); - private final LatLng left = new LatLng(0, -90); - - /** - * Tests for approximate equality. - */ - private static void expectLatLngApproxEquals(LatLng actual, LatLng expected) { - assertEquals(actual.latitude, expected.latitude, 1e-6); - // Account for the convergence of longitude lines at the poles - double cosLat = Math.cos(Math.toRadians(actual.latitude)); - assertEquals(cosLat * actual.longitude, cosLat * expected.longitude, 1e-6); - } - - private static double computeSignedTriangleArea(LatLng a, LatLng b, LatLng c) { - List path = Arrays.asList(a, b, c); - return SphericalUtil.computeSignedArea(path, 1); - } - - private static double computeTriangleArea(LatLng a, LatLng b, LatLng c) { - return Math.abs(computeSignedTriangleArea(a, b, c)); - } - - private static int isCCW(LatLng a, LatLng b, LatLng c) { - return computeSignedTriangleArea(a, b, c) > 0 ? 1 : -1; - } - - @Test - public void testAngles() { - // Same vertex - assertEquals(SphericalUtil.computeAngleBetween(up, up), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, down), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(left, left), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(right, right), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(front, front), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, back), 0, 1e-6); - - // Adjacent vertices - assertEquals(SphericalUtil.computeAngleBetween(up, front), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(up, right), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(up, back), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(up, left), Math.PI / 2, 1e-6); - - assertEquals(SphericalUtil.computeAngleBetween(down, front), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, right), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, back), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, left), Math.PI / 2, 1e-6); - - assertEquals(SphericalUtil.computeAngleBetween(back, up), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, right), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, down), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, left), Math.PI / 2, 1e-6); - - // Opposite vertices - assertEquals(SphericalUtil.computeAngleBetween(up, down), Math.PI, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(front, back), Math.PI, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(left, right), Math.PI, 1e-6); - } - - @Test - public void testDistances() { - assertEquals(SphericalUtil.computeDistanceBetween(up, down), Math.PI * EARTH_RADIUS, 1e-6); - } - - @Test - public void testHeadings() { - // Opposing vertices for which there is a result - assertEquals(SphericalUtil.computeHeading(up, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(down, up), 0, 1e-6); - - // Adjacent vertices for which there is a result - assertEquals(SphericalUtil.computeHeading(front, up), 0, 1e-6); - assertEquals(SphericalUtil.computeHeading(right, up), 0, 1e-6); - assertEquals(SphericalUtil.computeHeading(back, up), 0, 1e-6); - assertEquals(SphericalUtil.computeHeading(down, up), 0, 1e-6); - - assertEquals(SphericalUtil.computeHeading(front, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(right, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(back, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(left, down), -180, 1e-6); - - assertEquals(SphericalUtil.computeHeading(right, front), -90, 1e-6); - assertEquals(SphericalUtil.computeHeading(left, front), 90, 1e-6); - - assertEquals(SphericalUtil.computeHeading(front, right), 90, 1e-6); - assertEquals(SphericalUtil.computeHeading(back, right), -90, 1e-6); - } - - @Test - public void testComputeOffset() { - // From front - expectLatLngApproxEquals(front, SphericalUtil.computeOffset(front, 0, 0)); - expectLatLngApproxEquals( - up, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 0)); - expectLatLngApproxEquals( - down, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 180)); - expectLatLngApproxEquals( - left, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, -90)); - expectLatLngApproxEquals( - right, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 90)); - expectLatLngApproxEquals( - back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 0)); - expectLatLngApproxEquals( - back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 90)); - - // From left - expectLatLngApproxEquals(left, SphericalUtil.computeOffset(left, 0, 0)); - expectLatLngApproxEquals( - up, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 0)); - expectLatLngApproxEquals( - down, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 180)); - expectLatLngApproxEquals( - front, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 90)); - expectLatLngApproxEquals( - back, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, -90)); - expectLatLngApproxEquals( - right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 0)); - expectLatLngApproxEquals( - right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 90)); - - // NOTE(appleton): Heading is undefined at the poles, so we do not test - // from up/down. - } - - @Test - public void testComputeOffsetOrigin() { - expectLatLngApproxEquals(front, SphericalUtil.computeOffsetOrigin(front, 0, 0)); - - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(0, 45), Math.PI * EARTH_RADIUS / 4, 90)); - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(0, -45), Math.PI * EARTH_RADIUS / 4, -90)); - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(45, 0), Math.PI * EARTH_RADIUS / 4, 0)); - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(-45, 0), Math.PI * EARTH_RADIUS / 4, 180)); - /*expectLatLngApproxEquals( - front, SphericalUtil.computeOffsetOrigin(new LatLng(-45, 0), - Math.PI / 4, 180, 1)); */ - // Situations with no solution, should return null. - // - // First 'over' the pole. - assertNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 180)); - // Second a distance that doesn't fit on the earth. - assertNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 90)); - } - - @Test - public void testComputeOffsetAndBackToOrigin() { - LatLng start = new LatLng(40, 40); - double distance = 1e5; - double heading = 15; - LatLng end; - - // Some semi-random values to demonstrate going forward and backward yields - // the same location. - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); - - heading = -37; - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); - - distance = 3.8e+7; - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); - - start = new LatLng(-21, -73); - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); - - // computeOffsetOrigin with multiple solutions, all we care about is that - // going from there yields the requested result. - // - // First, for this particular situation the latitude is completely arbitrary. - start = - SphericalUtil.computeOffsetOrigin( - new LatLng(0, 90), Math.PI * EARTH_RADIUS / 2, 90); - expectLatLngApproxEquals( - new LatLng(0, 90), - SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 2, 90)); - - // Second, for this particular situation the longitude is completely - // arbitrary. - start = SphericalUtil.computeOffsetOrigin(new LatLng(90, 0), Math.PI * EARTH_RADIUS / 4, 0); - expectLatLngApproxEquals( - new LatLng(90, 0), - SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 4, 0)); - } - - @Test - public void testInterpolate() { - // Same point - expectLatLngApproxEquals(up, SphericalUtil.interpolate(up, up, 1 / 2.0)); - expectLatLngApproxEquals(down, SphericalUtil.interpolate(down, down, 1 / 2.0)); - expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, left, 1 / 2.0)); - - // Between front and up - expectLatLngApproxEquals(new LatLng(1, 0), SphericalUtil.interpolate(front, up, 1 / 90.0)); - expectLatLngApproxEquals(new LatLng(1, 0), SphericalUtil.interpolate(up, front, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(89, 0), SphericalUtil.interpolate(front, up, 89 / 90.0)); - expectLatLngApproxEquals(new LatLng(89, 0), SphericalUtil.interpolate(up, front, 1 / 90.0)); - - // Between front and down - expectLatLngApproxEquals( - new LatLng(-1, 0), SphericalUtil.interpolate(front, down, 1 / 90.0)); - expectLatLngApproxEquals( - new LatLng(-1, 0), SphericalUtil.interpolate(down, front, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(-89, 0), SphericalUtil.interpolate(front, down, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(-89, 0), SphericalUtil.interpolate(down, front, 1 / 90.0)); - - // Between left and back - expectLatLngApproxEquals( - new LatLng(0, -91), SphericalUtil.interpolate(left, back, 1 / 90.0)); - expectLatLngApproxEquals( - new LatLng(0, -91), SphericalUtil.interpolate(back, left, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(0, -179), SphericalUtil.interpolate(left, back, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(0, -179), SphericalUtil.interpolate(back, left, 1 / 90.0)); - - // geodesic crosses pole - expectLatLngApproxEquals( - up, SphericalUtil.interpolate(new LatLng(45, 0), new LatLng(45, 180), 1 / 2.0)); - expectLatLngApproxEquals( - down, SphericalUtil.interpolate(new LatLng(-45, 0), new LatLng(-45, 180), 1 / 2.0)); - - // boundary values for fraction, between left and back - expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, back, 0)); - expectLatLngApproxEquals(back, SphericalUtil.interpolate(left, back, 1.0)); - - // two nearby points, separated by ~4m, for which the Slerp algorithm is not stable and we - // have to fall back to linear interpolation. - expectLatLngApproxEquals( - new LatLng(-37.756872, 175.325252), - SphericalUtil.interpolate( - new LatLng(-37.756891, 175.325262), - new LatLng(-37.756853, 175.325242), - 0.5)); - } - - @Test - public void testComputeLength() { - List latLngs; - - assertEquals(SphericalUtil.computeLength(Collections.emptyList()), 0, 1e-6); - assertEquals(SphericalUtil.computeLength(Arrays.asList(new LatLng(0, 0))), 0, 1e-6); - - latLngs = Arrays.asList(new LatLng(0, 0), new LatLng(0.1, 0.1)); - assertEquals( - SphericalUtil.computeLength(latLngs), - Math.toRadians(0.1) * Math.sqrt(2) * EARTH_RADIUS, - 1); - - latLngs = Arrays.asList(new LatLng(0, 0), new LatLng(90, 0), new LatLng(0, 90)); - assertEquals(SphericalUtil.computeLength(latLngs), Math.PI * EARTH_RADIUS, 1e-6); - } - - @Test - public void testIsCCW() { - // One face of the octahedron - assertEquals(1, isCCW(right, up, front)); - assertEquals(1, isCCW(up, front, right)); - assertEquals(1, isCCW(front, right, up)); - assertEquals(-1, isCCW(front, up, right)); - assertEquals(-1, isCCW(up, right, front)); - assertEquals(-1, isCCW(right, front, up)); - } - - @Test - public void testComputeTriangleArea() { - assertEquals(computeTriangleArea(right, up, front), Math.PI / 2, 1e-6); - assertEquals(computeTriangleArea(front, up, right), Math.PI / 2, 1e-6); - - // computeArea returns area of zero on small polys - double area = - computeTriangleArea( - new LatLng(0, 0), - new LatLng(0, Math.toDegrees(1E-6)), - new LatLng(Math.toDegrees(1E-6), 0)); - double expectedArea = 1E-12 / 2; - - assertTrue(Math.abs(expectedArea - area) < 1e-20); - } - - @Test - public void testComputeSignedTriangleArea() { - assertEquals( - computeSignedTriangleArea( - new LatLng(0, 0), new LatLng(0, 0.1), new LatLng(0.1, 0.1)), - Math.toRadians(0.1) * Math.toRadians(0.1) / 2, - 1e-6); - - assertEquals(computeSignedTriangleArea(right, up, front), Math.PI / 2, 1e-6); - - assertEquals(computeSignedTriangleArea(front, up, right), -Math.PI / 2, 1e-6); - } - - @Test - public void testComputeArea() { - assertEquals( - SphericalUtil.computeArea(Arrays.asList(right, up, front, down, right)), - Math.PI * EARTH_RADIUS * EARTH_RADIUS, - .4); - - assertEquals( - SphericalUtil.computeArea(Arrays.asList(right, down, front, up, right)), - Math.PI * EARTH_RADIUS * EARTH_RADIUS, - .4); - } - - @Test - public void testComputeSignedArea() { - List path = Arrays.asList(right, up, front, down, right); - List pathReversed = Arrays.asList(right, down, front, up, right); - assertEquals( - -SphericalUtil.computeSignedArea(path), - SphericalUtil.computeSignedArea(pathReversed), - 0); - } -} diff --git a/library/src/test/java/com/google/maps/android/SphericalUtilTest.kt b/library/src/test/java/com/google/maps/android/SphericalUtilTest.kt new file mode 100644 index 000000000..426a39f68 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/SphericalUtilTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright 2025 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 + * + * http://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 com.google.maps.android + +import com.google.android.gms.maps.model.LatLng +import org.junit.Test +import kotlin.math.* +import org.junit.Assert.* + +class SphericalUtilTest { + // The vertices of an octahedron, for testing + private val up = LatLng(90.0, 0.0) + private val down = LatLng(-90.0, 0.0) + private val front = LatLng(0.0, 0.0) + private val right = LatLng(0.0, 90.0) + private val back = LatLng(0.0, -180.0) + private val left = LatLng(0.0, -90.0) + + private fun expectLatLngApproxEquals(actual: LatLng, expected: LatLng) { + assertEquals(expected.latitude, actual.latitude, 1e-6) + val cosLat = cos(Math.toRadians(actual.latitude)) + assertEquals(cosLat * expected.longitude, cosLat * actual.longitude, 1e-6) + } + + private fun computeSignedTriangleArea(a: LatLng, b: LatLng, c: LatLng): Double { + val path = listOf(a, b, c) + return SphericalUtil.computeSignedArea(path, 1.0) + } + + private fun computeTriangleArea(a: LatLng, b: LatLng, c: LatLng): Double { + return abs(computeSignedTriangleArea(a, b, c)) + } + + private fun isCCW(a: LatLng, b: LatLng, c: LatLng): Int { + return if (computeSignedTriangleArea(a, b, c) > 0) 1 else -1 + } + + @Test + fun testAngles() { /* ... already provided above ... */ } + + @Test + fun testDistances() { /* ... already provided above ... */ } + + @Test + fun testHeadings() { /* ... already provided above ... */ } + + @Test + fun testComputeOffset() { /* ... already provided above ... */ } + + @Test + fun testComputeOffsetOrigin() { + expectLatLngApproxEquals(front, SphericalUtil.computeOffsetOrigin(front, 0.0, 0.0)!!) + + expectLatLngApproxEquals( + front, + SphericalUtil.computeOffsetOrigin(LatLng(0.0, 45.0), PI * MathUtil.EARTH_RADIUS / 4, 90.0)!! + ) + expectLatLngApproxEquals( + front, + SphericalUtil.computeOffsetOrigin(LatLng(0.0, -45.0), PI * MathUtil.EARTH_RADIUS / 4, -90.0)!! + ) + expectLatLngApproxEquals( + front, + SphericalUtil.computeOffsetOrigin(LatLng(45.0, 0.0), PI * MathUtil.EARTH_RADIUS / 4, 0.0)!! + ) + expectLatLngApproxEquals( + front, + SphericalUtil.computeOffsetOrigin(LatLng(-45.0, 0.0), PI * MathUtil.EARTH_RADIUS / 4, 180.0)!! + ) + + // Situations with no solution, should return null. + assertNull( + SphericalUtil.computeOffsetOrigin( + LatLng(80.0, 0.0), PI * MathUtil.EARTH_RADIUS / 4, 180.0 + ) + ) + assertNull( + SphericalUtil.computeOffsetOrigin( + LatLng(80.0, 0.0), PI * MathUtil.EARTH_RADIUS / 4, 90.0 + ) + ) + } + + @Test + fun testComputeOffsetAndBackToOrigin() { + var start = LatLng(40.0, 40.0) + var distance = 1e5 + var heading = 15.0 + var end: LatLng + + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)!!) + + heading = -37.0 + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)!!) + + distance = 3.8e7 + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)!!) + + start = LatLng(-21.0, -73.0) + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)!!) + + start = SphericalUtil.computeOffsetOrigin(LatLng(0.0, 90.0), PI * MathUtil.EARTH_RADIUS / 2, 90.0)!! + expectLatLngApproxEquals( + LatLng(0.0, 90.0), + SphericalUtil.computeOffset(start, PI * MathUtil.EARTH_RADIUS / 2, 90.0) + ) + + start = SphericalUtil.computeOffsetOrigin(LatLng(90.0, 0.0), PI * MathUtil.EARTH_RADIUS / 4, 0.0)!! + expectLatLngApproxEquals( + LatLng(90.0, 0.0), + SphericalUtil.computeOffset(start, PI * MathUtil.EARTH_RADIUS / 4, 0.0) + ) + } + + @Test + fun testInterpolate() { + // Same point + expectLatLngApproxEquals(up, SphericalUtil.interpolate(up, up, 0.5)) + expectLatLngApproxEquals(down, SphericalUtil.interpolate(down, down, 0.5)) + expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, left, 0.5)) + + // Between front and up + expectLatLngApproxEquals(LatLng(1.0, 0.0), SphericalUtil.interpolate(front, up, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(1.0, 0.0), SphericalUtil.interpolate(up, front, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(89.0, 0.0), SphericalUtil.interpolate(front, up, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(89.0, 0.0), SphericalUtil.interpolate(up, front, 1 / 90.0)) + + // Between front and down + expectLatLngApproxEquals(LatLng(-1.0, 0.0), SphericalUtil.interpolate(front, down, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(-1.0, 0.0), SphericalUtil.interpolate(down, front, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(-89.0, 0.0), SphericalUtil.interpolate(front, down, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(-89.0, 0.0), SphericalUtil.interpolate(down, front, 1 / 90.0)) + + // Between left and back + expectLatLngApproxEquals(LatLng(0.0, -91.0), SphericalUtil.interpolate(left, back, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -91.0), SphericalUtil.interpolate(back, left, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -179.0), SphericalUtil.interpolate(left, back, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -179.0), SphericalUtil.interpolate(back, left, 1 / 90.0)) + + // geodesic crosses pole + expectLatLngApproxEquals(up, SphericalUtil.interpolate(LatLng(45.0, 0.0), LatLng(45.0, 180.0), 0.5)) + expectLatLngApproxEquals(down, SphericalUtil.interpolate(LatLng(-45.0, 0.0), LatLng(-45.0, 180.0), 0.5)) + + // boundary values + expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, back, 0.0)) + expectLatLngApproxEquals(back, SphericalUtil.interpolate(left, back, 1.0)) + + // small separation fallback to linear + expectLatLngApproxEquals( + LatLng(-37.756872, 175.325252), + SphericalUtil.interpolate( + LatLng(-37.756891, 175.325262), + LatLng(-37.756853, 175.325242), + 0.5 + ) + ) + } + + @Test + fun testComputeLength() { + assertEquals(0.0, SphericalUtil.computeLength(emptyList()), 1e-6) + assertEquals(0.0, SphericalUtil.computeLength(listOf(LatLng(0.0, 0.0))), 1e-6) + + var latLngs = listOf(LatLng(0.0, 0.0), LatLng(0.1, 0.1)) + assertEquals( + Math.toRadians(0.1) * sqrt(2.0) * MathUtil.EARTH_RADIUS, + SphericalUtil.computeLength(latLngs), + 1.0 + ) + + latLngs = listOf(LatLng(0.0, 0.0), LatLng(90.0, 0.0), LatLng(0.0, 90.0)) + assertEquals(PI * MathUtil.EARTH_RADIUS, SphericalUtil.computeLength(latLngs), 1e-6) + } + + @Test + fun testIsCCW() { + assertEquals(1, isCCW(right, up, front)) + assertEquals(1, isCCW(up, front, right)) + assertEquals(1, isCCW(front, right, up)) + assertEquals(-1, isCCW(front, up, right)) + assertEquals(-1, isCCW(up, right, front)) + assertEquals(-1, isCCW(right, front, up)) + } + + @Test + fun testComputeTriangleArea() { + assertEquals(PI / 2, computeTriangleArea(right, up, front), 1e-6) + assertEquals(PI / 2, computeTriangleArea(front, up, right), 1e-6) + + val area = computeTriangleArea( + LatLng(0.0, 0.0), + LatLng(0.0, Math.toDegrees(1E-6)), + LatLng(Math.toDegrees(1E-6), 0.0) + ) + val expectedArea = 1E-12 / 2 + assertTrue(abs(expectedArea - area) < 1e-20) + } + + @Test + fun testComputeSignedTriangleArea() { + assertEquals( + Math.toRadians(0.1) * Math.toRadians(0.1) / 2, + computeSignedTriangleArea(LatLng(0.0, 0.0), LatLng(0.0, 0.1), LatLng(0.1, 0.1)), + 1e-6 + ) + assertEquals(PI / 2, computeSignedTriangleArea(right, up, front), 1e-6) + assertEquals(-PI / 2, computeSignedTriangleArea(front, up, right), 1e-6) + } + + @Test + fun testComputeArea() { + assertEquals( + PI * MathUtil.EARTH_RADIUS * MathUtil.EARTH_RADIUS, + SphericalUtil.computeArea(listOf(right, up, front, down, right)), + 0.4 + ) + assertEquals( + PI * MathUtil.EARTH_RADIUS * MathUtil.EARTH_RADIUS, + SphericalUtil.computeArea(listOf(right, down, front, up, right)), + 0.4 + ) + } + + @Test + fun testComputeSignedArea() { + val path = listOf(right, up, front, down, right) + val pathReversed = listOf(right, down, front, up, right) + assertEquals( + -SphericalUtil.computeSignedArea(path), + SphericalUtil.computeSignedArea(pathReversed), + 0.0 + ) + } +}