diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 9b0aa5b2e..5b1f841a4 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -68,7 +68,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.kxml2) testImplementation(libs.mockk) - testImplementation (libs.kotlin.test) + testImplementation(libs.kotlin.test) + testImplementation(libs.truth) implementation(libs.kotlin.stdlib.jdk8) } diff --git a/library/src/main/java/com/google/maps/android/MathUtil.java b/library/src/main/java/com/google/maps/android/MathUtil.kt similarity index 52% rename from library/src/main/java/com/google/maps/android/MathUtil.java rename to library/src/main/java/com/google/maps/android/MathUtil.kt index a7e2c7488..d47a5f505 100644 --- a/library/src/main/java/com/google/maps/android/MathUtil.java +++ b/library/src/main/java/com/google/maps/android/MathUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright 2013 Google Inc. + * Copyright 2023 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,25 +14,34 @@ * limitations under the License. */ -package com.google.maps.android; +package com.google.maps.android -import static java.lang.Math.*; +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan /** * Utility functions that are used my both PolyUtil and SphericalUtil. */ -class MathUtil { +object MathUtil { /** * The earth's radius, in meters. * Mean radius as defined by IUGG. */ - static final double EARTH_RADIUS = 6371009; + const val EARTH_RADIUS = 6_371_009.0 /** * Restrict x to the range [low, high]. */ - static double clamp(double x, double low, double high) { - return x < low ? low : (x > high ? high : x); + @JvmStatic + fun clamp(x: Double, low: Double, high: Double): Double { + return if (x < low) low else if (x > high) high else x } /** @@ -42,8 +51,9 @@ static double clamp(double x, double low, double high) { * @param min The minimum. * @param max The maximum. */ - static double wrap(double n, double min, double max) { - return (n >= min && n < max) ? n : (mod(n - min, max - min) + min); + @JvmStatic + fun wrap(n: Double, min: Double, max: Double): Double { + return if (n >= min && n < max) n else mod(n - min, max - min) + min } /** @@ -52,32 +62,42 @@ static double wrap(double n, double min, double max) { * @param x The operand. * @param m The modulus. */ - static double mod(double x, double m) { - return ((x % m) + m) % m; + @JvmStatic + fun mod(x: Double, m: Double): Double { + return (x % m + m) % m } /** * Returns mercator Y corresponding to latitude. * See http://en.wikipedia.org/wiki/Mercator_projection . */ - static double mercator(double lat) { - return log(tan(lat * 0.5 + PI / 4)); + @JvmStatic + fun mercator(lat: Double): Double { + if (lat > Math.PI / 2 - 1e-9) { + return Double.POSITIVE_INFINITY + } + if (lat < -Math.PI / 2 + 1e-9) { + return Double.NEGATIVE_INFINITY + } + return ln(tan(lat * 0.5 + PI / 4)) } /** * Returns latitude from mercator Y. */ - static double inverseMercator(double y) { - return 2 * atan(exp(y)) - PI / 2; + @JvmStatic + fun inverseMercator(y: Double): Double { + return 2 * atan(exp(y)) - PI / 2 } /** * Returns haversine(angle-in-radians). * hav(x) == (1 - cos(x)) / 2 == sin(x / 2)^2. */ - static double hav(double x) { - double sinHalf = sin(x * 0.5); - return sinHalf * sinHalf; + @JvmStatic + fun hav(x: Double): Double { + val sinHalf = sin(x * 0.5) + return sinHalf * sinHalf } /** @@ -85,32 +105,37 @@ static double hav(double x) { * arcHav(x) == acos(1 - 2 * x) == 2 * asin(sqrt(x)). * The argument must be in [0, 1], and the result is positive. */ - static double arcHav(double x) { - return 2 * asin(sqrt(x)); + @JvmStatic + fun arcHav(x: Double): Double { + return 2 * asin(sqrt(x)) } // Given h==hav(x), returns sin(abs(x)). - static double sinFromHav(double h) { - return 2 * sqrt(h * (1 - h)); + @JvmStatic + fun sinFromHav(h: Double): Double { + return 2 * sqrt(h * (1 - h)) } // Returns hav(asin(x)). - static double havFromSin(double x) { - double x2 = x * x; - return x2 / (1 + sqrt(1 - x2)) * .5; + @JvmStatic + fun havFromSin(x: Double): Double { + val x2 = x * x + return x2 / (1 + sqrt(1 - x2)) * .5 } // Returns sin(arcHav(x) + arcHav(y)). - static double sinSumFromHav(double x, double y) { - double a = sqrt(x * (1 - x)); - double b = sqrt(y * (1 - y)); - return 2 * (a + b - 2 * (a * y + b * x)); + @JvmStatic + fun sinSumFromHav(x: Double, y: Double): Double { + val a = sqrt(x * (1 - x)) + val b = sqrt(y * (1 - y)) + return 2 * (a + b - 2 * (a * y + b * x)) } /** * Returns hav() of distance from (lat1, lng1) to (lat2, lng2) on the unit sphere. */ - static double havDistance(double lat1, double lat2, double dLng) { - return hav(lat1 - lat2) + hav(dLng) * cos(lat1) * cos(lat2); + @JvmStatic + fun havDistance(lat1: Double, lat2: Double, dLng: Double): Double { + return hav(lat1 - lat2) + hav(dLng) * cos(lat1) * cos(lat2) } -} +} \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.java b/library/src/main/java/com/google/maps/android/PolyUtil.java deleted file mode 100644 index 1c32d5a38..000000000 --- a/library/src/main/java/com/google/maps/android/PolyUtil.java +++ /dev/null @@ -1,580 +0,0 @@ -/* - * Copyright 2008, 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 java.util.ArrayList; -import java.util.List; -import java.util.Stack; - -import static com.google.maps.android.MathUtil.EARTH_RADIUS; -import static com.google.maps.android.MathUtil.clamp; -import static com.google.maps.android.MathUtil.hav; -import static com.google.maps.android.MathUtil.havDistance; -import static com.google.maps.android.MathUtil.havFromSin; -import static com.google.maps.android.MathUtil.inverseMercator; -import static com.google.maps.android.MathUtil.mercator; -import static com.google.maps.android.MathUtil.sinFromHav; -import static com.google.maps.android.MathUtil.sinSumFromHav; -import static com.google.maps.android.MathUtil.wrap; -import static com.google.maps.android.SphericalUtil.computeDistanceBetween; -import static java.lang.Math.PI; -import static java.lang.Math.cos; -import static java.lang.Math.max; -import static java.lang.Math.min; -import static java.lang.Math.sin; -import static java.lang.Math.sqrt; -import static java.lang.Math.tan; -import static java.lang.Math.toRadians; - -public class PolyUtil { - - private PolyUtil() { - } - - /** - * Returns tan(latitude-at-lng3) on the great circle (lat1, lng1) to (lat2, lng2). lng1==0. - * See http://williams.best.vwh.net/avform.htm . - */ - private static double tanLatGC(double lat1, double lat2, double lng2, double lng3) { - return (tan(lat1) * sin(lng2 - lng3) + tan(lat2) * sin(lng3)) / sin(lng2); - } - - /** - * Returns mercator(latitude-at-lng3) on the Rhumb line (lat1, lng1) to (lat2, lng2). lng1==0. - */ - private static double mercatorLatRhumb(double lat1, double lat2, double lng2, double lng3) { - return (mercator(lat1) * (lng2 - lng3) + mercator(lat2) * lng3) / lng2; - } - - /** - * Computes whether the vertical segment (lat3, lng3) to South Pole intersects the segment - * (lat1, lng1) to (lat2, lng2). - * Longitudes are offset by -lng1; the implicit lng1 becomes 0. - */ - private static boolean intersects(double lat1, double lat2, double lng2, - double lat3, double lng3, boolean geodesic) { - // Both ends on the same side of lng3. - if ((lng3 >= 0 && lng3 >= lng2) || (lng3 < 0 && lng3 < lng2)) { - return false; - } - // Point is South Pole. - if (lat3 <= -PI / 2) { - return false; - } - // Any segment end is a pole. - if (lat1 <= -PI / 2 || lat2 <= -PI / 2 || lat1 >= PI / 2 || lat2 >= PI / 2) { - return false; - } - if (lng2 <= -PI) { - return false; - } - double linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2; - // Northern hemisphere and point under lat-lng line. - if (lat1 >= 0 && lat2 >= 0 && lat3 < linearLat) { - return false; - } - // Southern hemisphere and point above lat-lng line. - if (lat1 <= 0 && lat2 <= 0 && lat3 >= linearLat) { - return true; - } - // North Pole. - if (lat3 >= PI / 2) { - return true; - } - // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. - // Compare through a strictly-increasing function (tan() or mercator()) as convenient. - return geodesic ? - tan(lat3) >= tanLatGC(lat1, lat2, lng2, lng3) : - mercator(lat3) >= mercatorLatRhumb(lat1, lat2, lng2, lng3); - } - - public static boolean containsLocation(LatLng point, List polygon, boolean geodesic) { - return containsLocation(point.latitude, point.longitude, polygon, geodesic); - } - - /** - * Computes whether the given point lies inside the specified polygon. - * The polygon is always considered closed, regardless of whether the last point equals - * the first or not. - * Inside is defined as not containing the South Pole -- the South Pole is always outside. - * The polygon is formed of great circle segments if geodesic is true, and of rhumb - * (loxodromic) segments otherwise. - */ - public static boolean containsLocation(double latitude, double longitude, List polygon, boolean geodesic) { - final int size = polygon.size(); - if (size == 0) { - return false; - } - double lat3 = toRadians(latitude); - double lng3 = toRadians(longitude); - LatLng prev = polygon.get(size - 1); - double lat1 = toRadians(prev.latitude); - double lng1 = toRadians(prev.longitude); - int nIntersect = 0; - for (LatLng point2 : polygon) { - double dLng3 = wrap(lng3 - lng1, -PI, PI); - // Special case: point equal to vertex is inside. - if (lat3 == lat1 && dLng3 == 0) { - return true; - } - double lat2 = toRadians(point2.latitude); - double lng2 = toRadians(point2.longitude); - // Offset longitudes by -lng1. - if (intersects(lat1, lat2, wrap(lng2 - lng1, -PI, PI), lat3, dLng3, geodesic)) { - ++nIntersect; - } - lat1 = lat2; - lng1 = lng2; - } - return (nIntersect & 1) != 0; - } - - public static final double DEFAULT_TOLERANCE = 0.1; // meters. - - /** - * Computes whether the given point lies on or near the edge of a polygon, within a specified - * tolerance in meters. The polygon edge is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise. The polygon edge is implicitly closed -- the - * closing segment between the first point and the last point is included. - */ - public static boolean isLocationOnEdge(LatLng point, List polygon, boolean geodesic, - double tolerance) { - return isLocationOnEdgeOrPath(point, polygon, true, geodesic, tolerance); - } - - /** - * Same as {@link #isLocationOnEdge(LatLng, List, boolean, double)} - * with a default tolerance of 0.1 meters. - */ - public static boolean isLocationOnEdge(LatLng point, List polygon, boolean geodesic) { - return isLocationOnEdge(point, polygon, geodesic, DEFAULT_TOLERANCE); - } - - /** - * Computes whether the given point lies on or near a polyline, within a specified - * tolerance in meters. The polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise. The polyline is not closed -- the closing - * segment between the first point and the last point is not included. - */ - public static boolean isLocationOnPath(LatLng point, List polyline, - boolean geodesic, double tolerance) { - return isLocationOnEdgeOrPath(point, polyline, false, geodesic, tolerance); - } - - /** - * Same as {@link #isLocationOnPath(LatLng, List, boolean, double)} - *

- * with a default tolerance of 0.1 meters. - */ - public static boolean isLocationOnPath(LatLng point, List polyline, - boolean geodesic) { - return isLocationOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE); - } - - private static boolean isLocationOnEdgeOrPath(LatLng point, List poly, boolean closed, - boolean geodesic, double toleranceEarth) { - int idx = locationIndexOnEdgeOrPath(point, poly, closed, geodesic, toleranceEarth); - - return (idx >= 0); - } - - /** - * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. - * The polyline is not closed -- the closing segment between the first point and the last point is not included. - * - * @param point our needle - * @param poly our haystack - * @param geodesic the polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise - * @param tolerance tolerance (in meters) - * @return -1 if point does not lie on or near the polyline. - * 0 if point is between poly[0] and poly[1] (inclusive), - * 1 if between poly[1] and poly[2], - * ..., - * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] - */ - public static int locationIndexOnPath(LatLng point, List poly, - boolean geodesic, double tolerance) { - return locationIndexOnEdgeOrPath(point, poly, false, geodesic, tolerance); - } - - /** - * Same as {@link #locationIndexOnPath(LatLng, List, boolean, double)} - *

- * with a default tolerance of 0.1 meters. - */ - public static int locationIndexOnPath(LatLng point, List polyline, - boolean geodesic) { - return locationIndexOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE); - } - - /** - * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. - * If closed, the closing segment between the last and first points of the polyline is not considered. - * - * @param point our needle - * @param poly our haystack - * @param closed whether the polyline should be considered closed by a segment connecting the last point back to the first one - * @param geodesic the polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise - * @param toleranceEarth tolerance (in meters) - * @return -1 if point does not lie on or near the polyline. - * 0 if point is between poly[0] and poly[1] (inclusive), - * 1 if between poly[1] and poly[2], - * ..., - * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] - */ - public static int locationIndexOnEdgeOrPath(LatLng point, List poly, boolean closed, - boolean geodesic, double toleranceEarth) { - int size = poly.size(); - if (size == 0) { - return -1; - } - double tolerance = toleranceEarth / EARTH_RADIUS; - double havTolerance = hav(tolerance); - double lat3 = toRadians(point.latitude); - double lng3 = toRadians(point.longitude); - LatLng prev = poly.get(closed ? size - 1 : 0); - double lat1 = toRadians(prev.latitude); - double lng1 = toRadians(prev.longitude); - int idx = 0; - if (geodesic) { - for (LatLng point2 : poly) { - double lat2 = toRadians(point2.latitude); - double lng2 = toRadians(point2.longitude); - if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { - return Math.max(0, idx - 1); - } - lat1 = lat2; - lng1 = lng2; - idx++; - } - } else { - // We project the points to mercator space, where the Rhumb segment is a straight line, - // and compute the geodesic distance between point3 and the closest point on the - // segment. This method is an approximation, because it uses "closest" in mercator - // space which is not "closest" on the sphere -- but the error is small because - // "tolerance" is small. - double minAcceptable = lat3 - tolerance; - double maxAcceptable = lat3 + tolerance; - double y1 = mercator(lat1); - double y3 = mercator(lat3); - double[] xTry = new double[3]; - for (LatLng point2 : poly) { - double lat2 = toRadians(point2.latitude); - double y2 = mercator(lat2); - double lng2 = toRadians(point2.longitude); - if (max(lat1, lat2) >= minAcceptable && min(lat1, lat2) <= maxAcceptable) { - // We offset longitudes by -lng1; the implicit x1 is 0. - double x2 = wrap(lng2 - lng1, -PI, PI); - double x3Base = wrap(lng3 - lng1, -PI, PI); - xTry[0] = x3Base; - // Also explore wrapping of x3Base around the world in both directions. - xTry[1] = x3Base + 2 * PI; - xTry[2] = x3Base - 2 * PI; - for (double x3 : xTry) { - double dy = y2 - y1; - double len2 = x2 * x2 + dy * dy; - double t = len2 <= 0 ? 0 : clamp((x3 * x2 + (y3 - y1) * dy) / len2, 0, 1); - double xClosest = t * x2; - double yClosest = y1 + t * dy; - double latClosest = inverseMercator(yClosest); - double havDist = havDistance(lat3, latClosest, x3 - xClosest); - if (havDist < havTolerance) { - return Math.max(0, idx - 1); - } - } - } - lat1 = lat2; - lng1 = lng2; - y1 = y2; - idx++; - } - } - return -1; - } - - /** - * Returns sin(initial bearing from (lat1,lng1) to (lat3,lng3) minus initial bearing - * from (lat1, lng1) to (lat2,lng2)). - */ - private static double sinDeltaBearing(double lat1, double lng1, double lat2, double lng2, - double lat3, double lng3) { - double sinLat1 = sin(lat1); - double cosLat2 = cos(lat2); - double cosLat3 = cos(lat3); - double lat31 = lat3 - lat1; - double lng31 = lng3 - lng1; - double lat21 = lat2 - lat1; - double lng21 = lng2 - lng1; - double a = sin(lng31) * cosLat3; - double c = sin(lng21) * cosLat2; - double b = sin(lat31) + 2 * sinLat1 * cosLat3 * hav(lng31); - double d = sin(lat21) + 2 * sinLat1 * cosLat2 * hav(lng21); - double denom = (a * a + b * b) * (c * c + d * d); - return denom <= 0 ? 1 : (a * d - b * c) / sqrt(denom); - } - - private static boolean isOnSegmentGC(double lat1, double lng1, double lat2, double lng2, - double lat3, double lng3, double havTolerance) { - double havDist13 = havDistance(lat1, lat3, lng1 - lng3); - if (havDist13 <= havTolerance) { - return true; - } - double havDist23 = havDistance(lat2, lat3, lng2 - lng3); - if (havDist23 <= havTolerance) { - return true; - } - double sinBearing = sinDeltaBearing(lat1, lng1, lat2, lng2, lat3, lng3); - double sinDist13 = sinFromHav(havDist13); - double havCrossTrack = havFromSin(sinDist13 * sinBearing); - if (havCrossTrack > havTolerance) { - return false; - } - double havDist12 = havDistance(lat1, lat2, lng1 - lng2); - double term = havDist12 + havCrossTrack * (1 - 2 * havDist12); - if (havDist13 > term || havDist23 > term) { - return false; - } - if (havDist12 < 0.74) { - return true; - } - double cosCrossTrack = 1 - 2 * havCrossTrack; - double havAlongTrack13 = (havDist13 - havCrossTrack) / cosCrossTrack; - double havAlongTrack23 = (havDist23 - havCrossTrack) / cosCrossTrack; - double sinSumAlongTrack = sinSumFromHav(havAlongTrack13, havAlongTrack23); - return sinSumAlongTrack > 0; // Compare with half-circle == PI using sign of sin(). - } - - /** - * Simplifies the given poly (polyline or polygon) using the Douglas-Peucker decimation - * algorithm. Increasing the tolerance will result in fewer points in the simplified polyline - * or polygon. - *

- * When the providing a polygon as input, the first and last point of the list MUST have the - * same latitude and longitude (i.e., the polygon must be closed). If the input polygon is not - * closed, the resulting polygon may not be fully simplified. - *

- * The time complexity of Douglas-Peucker is O(n^2), so take care that you do not call this - * algorithm too frequently in your code. - * - * @param poly polyline or polygon to be simplified. Polygon should be closed (i.e., - * first and last points should have the same latitude and longitude). - * @param tolerance in meters. Increasing the tolerance will result in fewer points in the - * simplified poly. - * @return a simplified poly produced by the Douglas-Peucker algorithm - */ - public static List simplify(List poly, double tolerance) { - final int n = poly.size(); - if (n < 1) { - throw new IllegalArgumentException("Polyline must have at least 1 point"); - } - if (tolerance <= 0) { - throw new IllegalArgumentException("Tolerance must be greater than zero"); - } - - boolean closedPolygon = isClosedPolygon(poly); - LatLng lastPoint = null; - - // Check if the provided poly is a closed polygon - if (closedPolygon) { - // Add a small offset to the last point for Douglas-Peucker on polygons (see #201) - final double OFFSET = 0.00000000001; - lastPoint = poly.get(poly.size() - 1); - // LatLng.latitude and .longitude are immutable, so replace the last point - poly.remove(poly.size() - 1); - poly.add(new LatLng(lastPoint.latitude + OFFSET, lastPoint.longitude + OFFSET)); - } - - int idx; - int maxIdx = 0; - Stack stack = new Stack<>(); - double[] dists = new double[n]; - dists[0] = 1; - dists[n - 1] = 1; - double maxDist; - double dist = 0.0; - int[] current; - - if (n > 2) { - int[] stackVal = new int[]{0, (n - 1)}; - stack.push(stackVal); - while (stack.size() > 0) { - current = stack.pop(); - maxDist = 0; - for (idx = current[0] + 1; idx < current[1]; ++idx) { - dist = distanceToLine(poly.get(idx), poly.get(current[0]), - poly.get(current[1])); - if (dist > maxDist) { - maxDist = dist; - maxIdx = idx; - } - } - if (maxDist > tolerance) { - dists[maxIdx] = maxDist; - int[] stackValCurMax = {current[0], maxIdx}; - stack.push(stackValCurMax); - int[] stackValMaxCur = {maxIdx, current[1]}; - stack.push(stackValMaxCur); - } - } - } - - if (closedPolygon) { - // Replace last point w/ offset with the original last point to re-close the polygon - poly.remove(poly.size() - 1); - poly.add(lastPoint); - } - - // Generate the simplified line - idx = 0; - ArrayList simplifiedLine = new ArrayList<>(); - for (LatLng l : poly) { - if (dists[idx] != 0) { - simplifiedLine.add(l); - } - idx++; - } - - return simplifiedLine; - } - - /** - * Returns true if the provided list of points is a closed polygon (i.e., the first and last - * points are the same), and false if it is not - * - * @param poly polyline or polygon - * @return true if the provided list of points is a closed polygon (i.e., the first and last - * points are the same), and false if it is not - */ - public static boolean isClosedPolygon(List poly) { - LatLng firstPoint = poly.get(0); - LatLng lastPoint = poly.get(poly.size() - 1); - return firstPoint.equals(lastPoint); - } - - /** - * Computes the distance on the sphere between the point p and the line segment start to end. - * - * @param p the point to be measured - * @param start the beginning of the line segment - * @param end the end of the line segment - * @return the distance in meters (assuming spherical earth) - */ - public static double distanceToLine(final LatLng p, final LatLng start, final LatLng end) { - if (start.equals(end)) { - return computeDistanceBetween(end, p); - } - - // Implementation of http://paulbourke.net/geometry/pointlineplane/ or http://geomalgorithms.com/a02-_lines.html - final double s0lat = toRadians(p.latitude); - final double s0lng = toRadians(p.longitude); - final double s1lat = toRadians(start.latitude); - final double s1lng = toRadians(start.longitude); - final double s2lat = toRadians(end.latitude); - final double s2lng = toRadians(end.longitude); - - double lonCorrection = Math.cos(s1lat); - double s2s1lat = s2lat - s1lat; - double s2s1lng = (s2lng - s1lng) * lonCorrection; - final double u = ((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * lonCorrection * s2s1lng) - / (s2s1lat * s2s1lat + s2s1lng * s2s1lng); - if (u <= 0) { - return computeDistanceBetween(p, start); - } - if (u >= 1) { - return computeDistanceBetween(p, end); - } - LatLng su = new LatLng(start.latitude + u * (end.latitude - start.latitude), start.longitude + u * (end.longitude - start.longitude)); - return computeDistanceBetween(p, su); - } - - /** - * Decodes an encoded path string into a sequence of LatLngs. - */ - public static List decode(final String encodedPath) { - int len = encodedPath.length(); - - // For speed we preallocate to an upper bound on the final length, then - // truncate the array before returning. - final List path = new ArrayList(); - int index = 0; - int lat = 0; - int lng = 0; - - while (index < len) { - int result = 1; - int shift = 0; - int b; - do { - b = encodedPath.charAt(index++) - 63 - 1; - result += b << shift; - shift += 5; - } while (b >= 0x1f); - lat += (result & 1) != 0 ? ~(result >> 1) : (result >> 1); - - result = 1; - shift = 0; - do { - b = encodedPath.charAt(index++) - 63 - 1; - result += b << shift; - shift += 5; - } while (b >= 0x1f); - lng += (result & 1) != 0 ? ~(result >> 1) : (result >> 1); - - path.add(new LatLng(lat * 1e-5, lng * 1e-5)); - } - - return path; - } - - /** - * Encodes a sequence of LatLngs into an encoded path string. - */ - public static String encode(final List path) { - long lastLat = 0; - long lastLng = 0; - - final StringBuffer result = new StringBuffer(); - - for (final LatLng point : path) { - long lat = Math.round(point.latitude * 1e5); - long lng = Math.round(point.longitude * 1e5); - - long dLat = lat - lastLat; - long dLng = lng - lastLng; - - encode(dLat, result); - encode(dLng, result); - - lastLat = lat; - lastLng = lng; - } - return result.toString(); - } - - private static void encode(long v, StringBuffer result) { - v = v < 0 ? ~(v << 1) : v << 1; - while (v >= 0x20) { - result.append(Character.toChars((int) ((0x20 | (v & 0x1f)) + 63))); - v >>= 5; - } - result.append(Character.toChars((int) (v + 63))); - } -} diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.kt b/library/src/main/java/com/google/maps/android/PolyUtil.kt new file mode 100644 index 000000000..e6421b7a3 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/PolyUtil.kt @@ -0,0 +1,617 @@ +/* + * 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 com.google.maps.android.MathUtil.clamp +import com.google.maps.android.MathUtil.hav +import com.google.maps.android.MathUtil.havDistance +import com.google.maps.android.MathUtil.havFromSin +import com.google.maps.android.MathUtil.inverseMercator +import com.google.maps.android.MathUtil.mercator +import com.google.maps.android.MathUtil.sinFromHav +import com.google.maps.android.MathUtil.sinSumFromHav +import com.google.maps.android.MathUtil.wrap +import com.google.maps.android.SphericalUtil.computeDistanceBetween +import java.util.Stack +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +/** + * A utility class containing geometric calculations for polygons and polylines. + * This class provides methods for determining if a point is inside a polygon, + * on the edge of a polygon, simplifying polylines, and encoding/decoding polylines. + * + * The methods in this class are designed to be used with the Google Maps Android API, + * and they operate on {@link LatLng} objects. The calculations can be performed + * using either geodesic (great circle) or rhumb (loxodromic) paths. + */ +object PolyUtil { + private const val DEFAULT_TOLERANCE = 0.1 // meters + + /** + * Computes whether the given point lies inside the specified polygon. + * The polygon is always considered closed, regardless of whether the last point equals + * the first or not. + * Inside is defined as not containing the South Pole -- the South Pole is always outside. + * The polygon is formed of great circle segments if geodesic is true, and of rhumb + * (loxodromic) segments otherwise. + * + * @param point The point to check. + * @param polygon The polygon to check against. + * @param geodesic Whether to treat the polygon segments as geodesic or rhumb lines. + * @return `true` if the point is inside the polygon, `false` otherwise. + */ + @JvmStatic + fun containsLocation(point: LatLng, polygon: List, geodesic: Boolean): Boolean { + return containsLocation(point.latitude, point.longitude, polygon, geodesic) + } + + /** + * Overload of {@link #containsLocation(LatLng, List, boolean)} that takes latitude and + * longitude as separate arguments. + */ + @JvmStatic + fun containsLocation( + latitude: Double, + longitude: Double, + polygon: List, + geodesic: Boolean + ): Boolean { + if (polygon.isEmpty()) { + return false + } + + val lat3 = Math.toRadians(latitude) + val lng3 = Math.toRadians(longitude) + val prev = polygon.last() + var lat1 = Math.toRadians(prev.latitude) + var lng1 = Math.toRadians(prev.longitude) + var nIntersect = 0 + + for (point2 in polygon) { + val dLng3 = wrap(lng3 - lng1, -Math.PI, Math.PI) + // Special case: point equal to vertex is inside. + if (lat3 == lat1 && dLng3 == 0.0) { + return true + } + val lat2 = Math.toRadians(point2.latitude) + val lng2 = Math.toRadians(point2.longitude) + // Offset longitudes by -lng1. + if (intersects(lat1, lat2, wrap(lng2 - lng1, -Math.PI, Math.PI), lat3, dLng3, geodesic)) { + ++nIntersect + } + lat1 = lat2 + lng1 = lng2 + } + return (nIntersect and 1) != 0 + } + + /** + * Computes whether the given point lies on or near the edge of a polygon, within a specified + * tolerance in meters. The polygon edge is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise. The polygon edge is implicitly closed -- the + * closing segment between the first point and the last point is included. + * + * @param point The point to check. + * @param polygon The polygon to check against. + * @param geodesic Whether to treat the polygon segments as geodesic or rhumb lines. + * @param tolerance The tolerance in meters. + * @return `true` if the point is on the edge of the polygon, `false` otherwise. + */ + @JvmStatic + @JvmOverloads + fun isLocationOnEdge( + point: LatLng, + polygon: List, + geodesic: Boolean, + tolerance: Double = DEFAULT_TOLERANCE + ): Boolean { + return isLocationOnEdgeOrPath(point, polygon, true, geodesic, tolerance) + } + + /** + * Computes whether the given point lies on or near a polyline, within a specified + * tolerance in meters. The polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise. The polyline is not closed -- the closing + * segment between the first point and the last point is not included. + * + * @param point The point to check. + * @param polyline The polyline to check against. + * @param geodesic Whether to treat the polyline segments as geodesic or rhumb lines. + * @param tolerance The tolerance in meters. + * @return `true` if the point is on the polyline, `false` otherwise. + */ + @JvmStatic + @JvmOverloads + fun isLocationOnPath( + point: LatLng, + polyline: List, + geodesic: Boolean, + tolerance: Double = DEFAULT_TOLERANCE + ): Boolean { + return isLocationOnEdgeOrPath(point, polyline, false, geodesic, tolerance) + } + + private fun isLocationOnEdgeOrPath( + point: LatLng, + poly: List, + closed: Boolean, + geodesic: Boolean, + toleranceEarth: Double + ): Boolean { + val idx = locationIndexOnEdgeOrPath(point, poly, closed, geodesic, toleranceEarth) + + return (idx >= 0) + } + + /** + * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. + * The polyline is not closed -- the closing segment between the first point and the last point is not included. + * + * @param point our needle + * @param poly our haystack + * @param geodesic the polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise + * @param tolerance tolerance (in meters) + * @return -1 if point does not lie on or near the polyline. + * 0 if point is between poly[0] and poly[1] (inclusive), + * 1 if between poly[1] and poly[2], + * ..., + * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] + */ + @JvmStatic + @JvmOverloads + fun locationIndexOnPath( + point: LatLng, + poly: List, + geodesic: Boolean, + tolerance: Double = DEFAULT_TOLERANCE + ): Int { + return locationIndexOnEdgeOrPath(point, poly, false, geodesic, tolerance) + } + + /** + * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. + * If closed, the closing segment between the last and first points of the polyline is not considered. + * + * @param point our needle + * @param poly our haystack + * @param closed whether the polyline should be considered closed by a segment connecting the last point back to the first one + * @param geodesic the polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise + * @param toleranceEarth tolerance (in meters) + * @return -1 if point does not lie on or near the polyline. + * 0 if point is between poly[0] and poly[1] (inclusive), + * 1 if between poly[1] and poly[2], + * ..., + * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] + */ + @JvmStatic + fun locationIndexOnEdgeOrPath( + point: LatLng, + poly: List, + closed: Boolean, + geodesic: Boolean, + toleranceEarth: Double + ): Int { + if (poly.isEmpty()) { + return -1 + } + val tolerance = toleranceEarth / MathUtil.EARTH_RADIUS + val havTolerance = hav(tolerance) + val lat3 = Math.toRadians(point.latitude) + val lng3 = Math.toRadians(point.longitude) + val prev = poly[if (closed) poly.size - 1 else 0] + var lat1 = Math.toRadians(prev.latitude) + var lng1 = Math.toRadians(prev.longitude) + var idx = 0 + if (geodesic) { + for (point2 in poly) { + val lat2 = Math.toRadians(point2.latitude) + val lng2 = Math.toRadians(point2.longitude) + if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { + return max(0, idx - 1) + } + lat1 = lat2 + lng1 = lng2 + idx++ + } + } else { + // We project the points to mercator space, where the Rhumb segment is a straight line, + // and compute the geodesic distance between point3 and the closest point on the + // segment. This method is an approximation, because it uses "closest" in mercator + // space which is not "closest" on the sphere -- but the error is small because + // "tolerance" is small. + val minAcceptable = lat3 - tolerance + val maxAcceptable = lat3 + tolerance + var y1 = mercator(lat1) + val y3 = mercator(lat3) + val xTry = DoubleArray(3) + for (point2 in poly) { + val lat2 = Math.toRadians(point2.latitude) + val y2 = mercator(lat2) + val lng2 = Math.toRadians(point2.longitude) + if (max(lat1, lat2) >= minAcceptable && min(lat1, lat2) <= maxAcceptable) { + // We offset longitudes by -lng1; the implicit x1 is 0. + val x2 = wrap(lng2 - lng1, -Math.PI, Math.PI) + val x3Base = wrap(lng3 - lng1, -Math.PI, Math.PI) + xTry[0] = x3Base + // Also explore wrapping of x3Base around the world in both directions. + xTry[1] = x3Base + 2 * Math.PI + xTry[2] = x3Base - 2 * Math.PI + for (x3 in xTry) { + val dy = y2 - y1 + val len2 = x2 * x2 + dy * dy + val t = if (len2 <= 0) 0.0 else clamp((x3 * x2 + (y3 - y1) * dy) / len2, 0.0, 1.0) + val xClosest = t * x2 + val yClosest = y1 + t * dy + val latClosest = inverseMercator(yClosest) + val havDist = havDistance(lat3, latClosest, x3 - xClosest) + if (havDist < havTolerance) { + return max(0, idx - 1) + } + } + } + lat1 = lat2 + lng1 = lng2 + y1 = y2 + idx++ + } + } + return -1 + } + + /** + * Simplifies the given poly (polyline or polygon) using the Douglas-Peucker decimation + * algorithm. Increasing the tolerance will result in fewer points in the simplified polyline + * or polygon. + * + * When the providing a polygon as input, the first and last point of the list MUST have the + * same latitude and longitude (i.e., the polygon must be closed). If the input polygon is not + * closed, the resulting polygon may not be fully simplified. + * + * The time complexity of Douglas-Peucker is O(n^2), so take care that you do not call this + * algorithm too frequently in your code. + * + * @param poly polyline or polygon to be simplified. Polygon should be closed (i.e., + * first and last points should have the same latitude and longitude). + * @param tolerance in meters. Increasing the tolerance will result in fewer points in the + * simplified poly. + * @return a simplified poly produced by the Douglas-Peucker algorithm + */ + @JvmStatic + fun simplify(poly: MutableList, tolerance: Double): List { + val n = poly.size + require(n >= 1) { "Polyline must have at least 1 point" } + require(tolerance > 0) { "Tolerance must be greater than zero" } + + val closedPolygon = isClosedPolygon(poly) + var lastPoint: LatLng? = null + + // Check if the provided poly is a closed polygon + if (closedPolygon) { + // Add a small offset to the last point for Douglas-Peucker on polygons (see #201) + val OFFSET = 0.00000000001 + lastPoint = poly.last() + poly.removeAt(poly.size - 1) + poly.add(LatLng(lastPoint.latitude + OFFSET, lastPoint.longitude + OFFSET)) + } + + var maxIdx = 0 + val stack = Stack() + val dists = DoubleArray(n) + dists[0] = 1.0 + dists[n - 1] = 1.0 + var maxDist: Double + var dist: Double + var current: IntArray + + if (n > 2) { + val stackVal = intArrayOf(0, n - 1) + stack.push(stackVal) + while (stack.isNotEmpty()) { + current = stack.pop() + maxDist = 0.0 + for (idx in current[0] + 1 until current[1]) { + dist = distanceToLine(poly[idx], poly[current[0]], poly[current[1]]) + if (dist > maxDist) { + maxDist = dist + maxIdx = idx + } + } + if (maxDist > tolerance) { + dists[maxIdx] = maxDist + val stackValCurMax = intArrayOf(current[0], maxIdx) + stack.push(stackValCurMax) + val stackValMaxCur = intArrayOf(maxIdx, current[1]) + stack.push(stackValMaxCur) + } + } + } + + if (closedPolygon) { + // Replace last point w/ offset with the original last point to re-close the polygon + poly.removeAt(poly.size - 1) + if (lastPoint != null) { + poly.add(lastPoint) + } + } + + // Generate the simplified line + return poly.filterIndexed { idx, _ -> dists[idx] != 0.0 } + } + + /** + * Returns true if the provided list of points is a closed polygon (i.e., the first and last + * points are the same), and false if it is not + * + * @param poly polyline or polygon + * @return true if the provided list of points is a closed polygon (i.e., the first and last + * points are the same), and false if it is not + */ + @JvmStatic + fun isClosedPolygon(poly: List): Boolean { + return poly.isNotEmpty() && poly.first() == poly.last() + } + + /** + * Computes the distance on the sphere between the point p and the line segment start to end. + * + * @param p the point to be measured + * @param start the beginning of the line segment + * @param end the end of the line segment + * @return the distance in meters (assuming spherical earth) + */ + @JvmStatic + fun distanceToLine(p: LatLng, start: LatLng, end: LatLng): Double { + if (start == end) { + return computeDistanceBetween(end, p) + } + + val s0lat = Math.toRadians(p.latitude) + val s0lng = Math.toRadians(p.longitude) + val s1lat = Math.toRadians(start.latitude) + val s1lng = Math.toRadians(start.longitude) + val s2lat = Math.toRadians(end.latitude) + val s2lng = Math.toRadians(end.longitude) + + val lonCorrection = cos(s1lat) + val s2s1lat = s2lat - s1lat + val s2s1lng = (s2lng - s1lng) * lonCorrection + val u = ((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * lonCorrection * s2s1lng) / + (s2s1lat * s2s1lat + s2s1lng * s2s1lng) + + if (u <= 0) { + return computeDistanceBetween(p, start) + } + if (u >= 1) { + return computeDistanceBetween(p, end) + } + + val su = LatLng( + start.latitude + u * (end.latitude - start.latitude), + start.longitude + u * (end.longitude - start.longitude) + ) + return computeDistanceBetween(p, su) + } + + /** + * Decodes an encoded path string into a sequence of LatLngs. + */ + @JvmStatic + fun decode(encodedPath: String): List { + val len = encodedPath.length + val path = mutableListOf() + var index = 0 + var lat = 0 + var lng = 0 + + while (index < len) { + var result = 1 + var shift = 0 + var b: Int + do { + b = encodedPath[index++].code - 63 - 1 + result += b shl shift + shift += 5 + } while (b >= 0x1f) + lat += if ((result and 1) != 0) (result shr 1).inv() else (result shr 1) + + result = 1 + shift = 0 + do { + b = encodedPath[index++].code - 63 - 1 + result += b shl shift + shift += 5 + } while (b >= 0x1f) + lng += if ((result and 1) != 0) (result shr 1).inv() else (result shr 1) + + path.add(LatLng(lat * 1e-5, lng * 1e-5)) + } + + return path + } + + /** + * Encodes a sequence of LatLngs into an encoded path string. + */ + @JvmStatic + fun encode(path: List): String { + var lastLat: Long = 0 + var lastLng: Long = 0 + val result = StringBuilder() + + for (point in path) { + val lat = round(point.latitude * 1e5).toLong() + val lng = round(point.longitude * 1e5).toLong() + val dLat = lat - lastLat + val dLng = lng - lastLng + + encode(dLat, result) + encode(dLng, result) + + lastLat = lat + lastLng = lng + } + return result.toString() + } + + private fun encode(v: Long, result: StringBuilder) { + var value = if (v < 0) (v shl 1).inv() else (v shl 1) + while (value >= 0x20) { + result.append(Character.toChars(((0x20 or (value and 0x1f).toInt()) + 63))) + value = value shr 5 + } + result.append(Character.toChars((value + 63).toInt())) + } + + /** + * Returns tan(latitude-at-lng3) on the great circle (lat1, lng1) to (lat2, lng2). lng1==0. + * See http://williams.best.vwh.net/avform.htm . + */ + private fun tanLatGC(lat1: Double, lat2: Double, lng2: Double, lng3: Double): Double { + return (tan(lat1) * sin(lng2 - lng3) + tan(lat2) * sin(lng3)) / sin(lng2) + } + + /** + * Returns mercator(latitude-at-lng3) on the Rhumb line (lat1, lng1) to (lat2, lng2). lng1==0. + */ + private fun mercatorLatRhumb(lat1: Double, lat2: Double, lng2: Double, lng3: Double): Double { + return (mercator(lat1) * (lng2 - lng3) + mercator(lat2) * lng3) / lng2 + } + + /** + * Computes whether the vertical segment (lat3, lng3) to South Pole intersects the segment + * (lat1, lng1) to (lat2, lng2). + * Longitudes are offset by -lng1; the implicit lng1 becomes 0. + */ + private fun intersects( + lat1: Double, + lat2: Double, + lng2: Double, + lat3: Double, + lng3: Double, + geodesic: Boolean + ): Boolean { + // Both ends on the same side of lng3. + if ((lng3 >= 0 && lng3 >= lng2) || (lng3 < 0 && lng3 < lng2)) { + return false + } + // Point is South Pole. + if (lat3 <= -Math.PI / 2) { + return false + } + // Any segment end is a pole. + if (lat1 <= -Math.PI / 2 || lat2 <= -Math.PI / 2 || lat1 >= Math.PI / 2 || lat2 >= Math.PI / 2) { + return false + } + if (lng2 <= -Math.PI) { + return false + } + val linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2 + // Northern hemisphere and point under lat-lng line. + if (lat1 >= 0 && lat2 >= 0 && lat3 < linearLat) { + return false + } + // Southern hemisphere and point above lat-lng line. + if (lat1 <= 0 && lat2 <= 0 && lat3 >= linearLat) { + return true + } + // North Pole. + if (lat3 >= Math.PI / 2) { + return true + } + // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. + // Compare through a strictly-increasing function (tan() or mercator()) as convenient. + return if (geodesic) { + tan(lat3) >= tanLatGC(lat1, lat2, lng2, lng3) + } else { + mercator(lat3) >= mercatorLatRhumb(lat1, lat2, lng2, lng3) + } + } + + /** + * Returns sin(initial bearing from (lat1,lng1) to (lat3,lng3) minus initial bearing + * from (lat1, lng1) to (lat2,lng2)). + */ + private fun sinDeltaBearing( + lat1: Double, + lng1: Double, + lat2: Double, + lng2: Double, + lat3: Double, + lng3: Double + ): Double { + val sinLat1 = sin(lat1) + val cosLat2 = cos(lat2) + val cosLat3 = cos(lat3) + val lat31 = lat3 - lat1 + val lng31 = lng3 - lng1 + val lat21 = lat2 - lat1 + val lng21 = lng2 - lng1 + val a = sin(lng31) * cosLat3 + val c = sin(lng21) * cosLat2 + val b = sin(lat31) + 2 * sinLat1 * cosLat3 * hav(lng31) + val d = sin(lat21) + 2 * sinLat1 * cosLat2 * hav(lng21) + val denom = (a * a + b * b) * (c * c + d * d) + return if (denom <= 0) 1.0 else (a * d - b * c) / sqrt(denom) + } + + private fun isOnSegmentGC( + lat1: Double, + lng1: Double, + lat2: Double, + lng2: Double, + lat3: Double, + lng3: Double, + havTolerance: Double + ): Boolean { + val havDist13 = havDistance(lat1, lat3, lng1 - lng3) + if (havDist13 <= havTolerance) { + return true + } + val havDist23 = havDistance(lat2, lat3, lng2 - lng3) + if (havDist23 <= havTolerance) { + return true + } + val sinBearing = sinDeltaBearing(lat1, lng1, lat2, lng2, lat3, lng3) + val sinDist13 = sinFromHav(havDist13) + val havCrossTrack = havFromSin(sinDist13 * sinBearing) + if (havCrossTrack > havTolerance) { + return false + } + val havDist12 = havDistance(lat1, lat2, lng1 - lng2) + val term = havDist12 + havCrossTrack * (1 - 2 * havDist12) + if (havDist13 > term || havDist23 > term) { + return false + } + if (havDist12 < 0.74) { + return true + } + val cosCrossTrack = 1 - 2 * havCrossTrack + val havAlongTrack13 = (havDist13 - havCrossTrack) / cosCrossTrack + val havAlongTrack23 = (havDist23 - havCrossTrack) / cosCrossTrack + val sinSumAlongTrack = sinSumFromHav(havAlongTrack13, havAlongTrack23) + return sinSumAlongTrack > 0 // Compare with half-circle == PI using sign of sin(). + } +} diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.java b/library/src/main/java/com/google/maps/android/SphericalUtil.java deleted file mode 100644 index 53e03ecda..000000000 --- a/library/src/main/java/com/google/maps/android/SphericalUtil.java +++ /dev/null @@ -1,265 +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 java.util.List; - -import static com.google.maps.android.MathUtil.*; -import static java.lang.Math.*; - -public class SphericalUtil { - - private SphericalUtil() { - } - - /** - * Returns the heading from one LatLng to another LatLng. Headings are - * expressed in degrees clockwise from North within the range [-180,180). - * - * @return The heading in degrees clockwise from north. - */ - public static double computeHeading(LatLng from, LatLng to) { - // http://williams.best.vwh.net/avform.htm#Crs - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double toLat = toRadians(to.latitude); - double toLng = toRadians(to.longitude); - double dLng = toLng - fromLng; - double heading = atan2( - sin(dLng) * cos(toLat), - cos(fromLat) * sin(toLat) - sin(fromLat) * cos(toLat) * cos(dLng)); - return wrap(toDegrees(heading), -180, 180); - } - - /** - * Returns the LatLng resulting from moving a distance from an origin - * in the specified heading (expressed in degrees clockwise from north). - * - * @param from The LatLng from which to start. - * @param distance The distance to travel. - * @param heading The heading in degrees clockwise from north. - */ - public static LatLng computeOffset(LatLng from, double distance, double heading) { - distance /= EARTH_RADIUS; - heading = toRadians(heading); - // http://williams.best.vwh.net/avform.htm#LL - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double cosDistance = cos(distance); - double sinDistance = sin(distance); - double sinFromLat = sin(fromLat); - double cosFromLat = cos(fromLat); - double sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(heading); - double dLng = atan2( - sinDistance * cosFromLat * sin(heading), - cosDistance - sinFromLat * sinLat); - return new LatLng(toDegrees(asin(sinLat)), toDegrees(fromLng + dLng)); - } - - /** - * Returns the location of origin when provided with a LatLng destination, - * meters travelled and original heading. Headings are expressed in degrees - * clockwise from North. This function returns null when no solution is - * available. - * - * @param to The destination LatLng. - * @param distance The distance travelled, in meters. - * @param heading The heading in degrees clockwise from north. - */ - public static LatLng computeOffsetOrigin(LatLng to, double distance, double heading) { - heading = toRadians(heading); - distance /= EARTH_RADIUS; - // http://lists.maptools.org/pipermail/proj/2008-October/003939.html - double n1 = cos(distance); - double n2 = sin(distance) * cos(heading); - double n3 = sin(distance) * sin(heading); - double n4 = sin(toRadians(to.latitude)); - // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results - // in the latitude outside the [-90, 90] range. We first try one solution and - // back off to the other if we are outside that range. - double n12 = n1 * n1; - double discriminant = n2 * n2 * n12 + n12 * n12 - n12 * n4 * n4; - if (discriminant < 0) { - // No real solution which would make sense in LatLng-space. - return null; - } - double b = n2 * n4 + sqrt(discriminant); - b /= n1 * n1 + n2 * n2; - double a = (n4 - n2 * b) / n1; - double fromLatRadians = atan2(a, b); - if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { - b = n2 * n4 - sqrt(discriminant); - b /= n1 * n1 + n2 * n2; - fromLatRadians = atan2(a, b); - } - if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { - // No solution which would make sense in LatLng-space. - return null; - } - double fromLngRadians = toRadians(to.longitude) - - atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)); - return new LatLng(toDegrees(fromLatRadians), toDegrees(fromLngRadians)); - } - - /** - * Returns the LatLng which lies the given fraction of the way between the - * origin LatLng and the destination LatLng. - * - * @param from The LatLng from which to start. - * @param to The LatLng toward which to travel. - * @param fraction A fraction of the distance to travel. - * @return The interpolated LatLng. - */ - public static LatLng interpolate(LatLng from, LatLng to, double fraction) { - // http://en.wikipedia.org/wiki/Slerp - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double toLat = toRadians(to.latitude); - double toLng = toRadians(to.longitude); - double cosFromLat = cos(fromLat); - double cosToLat = cos(toLat); - - // Computes Spherical interpolation coefficients. - double angle = computeAngleBetween(from, to); - double sinAngle = sin(angle); - if (sinAngle < 1E-6) { - return new LatLng( - from.latitude + fraction * (to.latitude - from.latitude), - from.longitude + fraction * (to.longitude - from.longitude)); - } - double a = sin((1 - fraction) * angle) / sinAngle; - double b = sin(fraction * angle) / sinAngle; - - // Converts from polar to vector and interpolate. - double x = a * cosFromLat * cos(fromLng) + b * cosToLat * cos(toLng); - double y = a * cosFromLat * sin(fromLng) + b * cosToLat * sin(toLng); - double z = a * sin(fromLat) + b * sin(toLat); - - // Converts interpolated vector back to polar. - double lat = atan2(z, sqrt(x * x + y * y)); - double lng = atan2(y, x); - return new LatLng(toDegrees(lat), toDegrees(lng)); - } - - /** - * Returns distance on the unit sphere; the arguments are in radians. - */ - private static double distanceRadians(double lat1, double lng1, double lat2, double lng2) { - return arcHav(havDistance(lat1, lat2, lng1 - lng2)); - } - - /** - * Returns the angle between two LatLngs, in radians. This is the same as the distance - * on the unit sphere. - */ - static double computeAngleBetween(LatLng from, LatLng to) { - return distanceRadians(toRadians(from.latitude), toRadians(from.longitude), - toRadians(to.latitude), toRadians(to.longitude)); - } - - /** - * Returns the distance between two LatLngs, in meters. - */ - public static double computeDistanceBetween(LatLng from, LatLng to) { - return computeAngleBetween(from, to) * EARTH_RADIUS; - } - - /** - * Returns the length of the given path, in meters, on Earth. - */ - public static double computeLength(List path) { - if (path.size() < 2) { - return 0; - } - double length = 0; - LatLng prev = null; - for (LatLng point : path) { - if (prev != null) { - double prevLat = toRadians(prev.latitude); - double prevLng = toRadians(prev.longitude); - double lat = toRadians(point.latitude); - double lng = toRadians(point.longitude); - length += distanceRadians(prevLat, prevLng, lat, lng); - } - prev = point; - } - return length * EARTH_RADIUS; - } - - /** - * Returns the area of a closed path on Earth. - * - * @param path A closed path. - * @return The path's area in square meters. - */ - public static double computeArea(List path) { - return abs(computeSignedArea(path)); - } - - /** - * Returns the signed area of a closed path on Earth. The sign of the area may be used to - * determine the orientation of the path. - * "inside" is the surface that does not contain the South Pole. - * - * @param path A closed path. - * @return The loop's area in square meters. - */ - public static double computeSignedArea(List path) { - return computeSignedArea(path, EARTH_RADIUS); - } - - /** - * Returns the signed area of a closed path on a sphere of given radius. - * The computed area uses the same units as the radius squared. - * Used by SphericalUtilTest. - */ - static double computeSignedArea(List path, double radius) { - int size = path.size(); - if (size < 3) { - return 0; - } - double total = 0; - LatLng prev = path.get(size - 1); - double prevTanLat = tan((PI / 2 - toRadians(prev.latitude)) / 2); - double prevLng = toRadians(prev.longitude); - // For each edge, accumulate the signed area of the triangle formed by the North Pole - // and that edge ("polar triangle"). - for (LatLng point : path) { - double tanLat = tan((PI / 2 - toRadians(point.latitude)) / 2); - double lng = toRadians(point.longitude); - total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng); - prevTanLat = tanLat; - prevLng = lng; - } - return total * (radius * radius); - } - - /** - * Returns the signed area of a triangle which has North Pole as a vertex. - * Formula derived from "Area of a spherical triangle given two edges and the included angle" - * as per "Spherical Trigonometry" by Todhunter, page 71, section 103, point 2. - * See http://books.google.com/books?id=3uBHAAAAIAAJ&pg=PA71 - * The arguments named "tan" are tan((pi/2 - latitude)/2). - */ - private static double polarTriangleArea(double tan1, double lng1, double tan2, double lng2) { - double deltaLng = lng1 - lng2; - double t = tan1 * tan2; - return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng)); - } -} diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.kt b/library/src/main/java/com/google/maps/android/SphericalUtil.kt new file mode 100644 index 000000000..0557b47de --- /dev/null +++ b/library/src/main/java/com/google/maps/android/SphericalUtil.kt @@ -0,0 +1,287 @@ +/* + * 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 com.google.maps.android.MathUtil.EARTH_RADIUS +import com.google.maps.android.MathUtil.arcHav +import com.google.maps.android.MathUtil.havDistance +import com.google.maps.android.MathUtil.wrap +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +object SphericalUtil { + /** + * Returns the heading from one LatLng to another LatLng. Headings are + * expressed in degrees clockwise from North within the range [-180,180). + * + * @return The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeHeading(from: LatLng, to: LatLng): Double { + // http://williams.best.vwh.net/avform.htm#Crs + val fromLat = Math.toRadians(from.latitude) + val fromLng = Math.toRadians(from.longitude) + val toLat = Math.toRadians(to.latitude) + val toLng = Math.toRadians(to.longitude) + val dLng = toLng - fromLng + val heading = atan2( + sin(dLng) * cos(toLat), + cos(fromLat) * sin(toLat) - sin(fromLat) * cos(toLat) * cos(dLng) + ) + return wrap(Math.toDegrees(heading), -180.0, 180.0) + } + + /** + * Returns the LatLng resulting from moving a distance from an origin + * in the specified heading (expressed in degrees clockwise from north). + * + * @param from The LatLng from which to start. + * @param distance The distance to travel. + * @param heading The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeOffset(from: LatLng, distance: Double, heading: Double): LatLng { + var distance = distance + var heading = heading + distance /= EARTH_RADIUS + heading = Math.toRadians(heading) + // http://williams.best.vwh.net/avform.htm#LL + val fromLat = Math.toRadians(from.latitude) + val fromLng = Math.toRadians(from.longitude) + val cosDistance = cos(distance) + val sinDistance = sin(distance) + val sinFromLat = sin(fromLat) + val cosFromLat = cos(fromLat) + val sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(heading) + val dLng = atan2( + sinDistance * cosFromLat * sin(heading), + cosDistance - sinFromLat * sinLat + ) + return LatLng(Math.toDegrees(asin(sinLat)), Math.toDegrees(fromLng + dLng)) + } + + /** + * Returns the location of origin when provided with a LatLng destination, + * meters travelled and original heading. Headings are expressed in degrees + * clockwise from North. This function returns null when no solution is + * available. + * + * @param to The destination LatLng. + * @param distance The distance travelled, in meters. + * @param heading The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeOffsetOrigin(to: LatLng, distance: Double, heading: Double): LatLng? { + var distance = distance + var heading = heading + heading = Math.toRadians(heading) + distance /= EARTH_RADIUS + // http://lists.maptools.org/pipermail/proj/2008-October/003939.html + val n1 = cos(distance) + val n2 = sin(distance) * cos(heading) + val n3 = sin(distance) * sin(heading) + val n4 = sin(Math.toRadians(to.latitude)) + // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results + // in the latitude outside the [-90, 90] range. We first try one solution and + // back off to the other if we are outside that range. + val n12 = n1 * n1 + val discriminant = n2 * n2 * n12 + n12 * n12 - n12 * n4 * n4 + if (discriminant < 0) { + // No real solution which would make sense in LatLng-space. + return null + } + var b = n2 * n4 + sqrt(discriminant) + b /= n1 * n1 + n2 * n2 + val a = (n4 - n2 * b) / n1 + var fromLatRadians = atan2(a, b) + if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { + b = n2 * n4 - sqrt(discriminant) + b /= n1 * n1 + n2 * n2 + fromLatRadians = atan2(a, b) + } + if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { + // No solution which would make sense in LatLng-space. + return null + } + val fromLngRadians = Math.toRadians(to.longitude) - + atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)) + return LatLng(Math.toDegrees(fromLatRadians), Math.toDegrees(fromLngRadians)) + } + + /** + * Returns the LatLng which lies the given fraction of the way between the + * origin LatLng and the destination LatLng. + * + * @param from The LatLng from which to start. + * @param to The LatLng toward which to travel. + * @param fraction A fraction of the distance to travel. + * @return The interpolated LatLng. + */ + @JvmStatic + fun interpolate(from: LatLng, to: LatLng, fraction: Double): LatLng { + // http://en.wikipedia.org/wiki/Slerp + val fromLat = Math.toRadians(from.latitude) + val fromLng = Math.toRadians(from.longitude) + val toLat = Math.toRadians(to.latitude) + val toLng = Math.toRadians(to.longitude) + val cosFromLat = cos(fromLat) + val cosToLat = cos(toLat) + + // Computes Spherical interpolation coefficients. + val angle = computeAngleBetween(from, to) + val sinAngle = sin(angle) + if (sinAngle < 1E-6) { + return LatLng( + from.latitude + fraction * (to.latitude - from.latitude), + from.longitude + fraction * (to.longitude - from.longitude) + ) + } + val a = sin((1 - fraction) * angle) / sinAngle + val b = sin(fraction * angle) / sinAngle + + // Converts from polar to vector and interpolate. + val x = a * cosFromLat * cos(fromLng) + b * cosToLat * cos(toLng) + val y = a * cosFromLat * sin(fromLng) + b * cosToLat * sin(toLng) + val z = a * sin(fromLat) + b * sin(toLat) + + // Converts interpolated vector back to polar. + val lat = atan2(z, sqrt(x * x + y * y)) + val lng = atan2(y, x) + return LatLng(Math.toDegrees(lat), Math.toDegrees(lng)) + } + + /** + * Returns distance on the unit sphere; the arguments are in radians. + */ + private fun distanceRadians(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { + return arcHav(havDistance(lat1, lat2, lng1 - lng2)) + } + + /** + * Returns the angle between two LatLngs, in radians. This is the same as the distance + * on the unit sphere. + */ + @JvmStatic + fun computeAngleBetween(from: LatLng, to: LatLng): Double { + return distanceRadians( + Math.toRadians(from.latitude), Math.toRadians(from.longitude), + Math.toRadians(to.latitude), Math.toRadians(to.longitude) + ) + } + + /** + * Returns the distance between two LatLngs, in meters. + */ + @JvmStatic + fun computeDistanceBetween(from: LatLng, to: LatLng): Double { + return computeAngleBetween(from, to) * EARTH_RADIUS + } + + /** + * Returns the length of the given path, in meters, on Earth. + */ + @JvmStatic + fun computeLength(path: List): Double { + if (path.size < 2) { + return 0.0 + } + var length = 0.0 + var prev: LatLng? = null + for (point in path) { + if (prev != null) { + val prevLat = Math.toRadians(prev.latitude) + val prevLng = Math.toRadians(prev.longitude) + val lat = Math.toRadians(point.latitude) + val lng = Math.toRadians(point.longitude) + length += distanceRadians(prevLat, prevLng, lat, lng) + } + prev = point + } + return length * EARTH_RADIUS + } + + /** + * Returns the area of a closed path on Earth. + * + * @param path A closed path. + * @return The path's area in square meters. + */ + @JvmStatic + fun computeArea(path: List): Double { + return abs(computeSignedArea(path)) + } + + /** + * Returns the signed area of a closed path on Earth. The sign of the area may be used to + * determine the orientation of the path. + * "inside" is the surface that does not contain the South Pole. + * + * @param path A closed path. + * @return The loop's area in square meters. + */ + @JvmStatic + fun computeSignedArea(path: List): Double { + return computeSignedArea(path, EARTH_RADIUS) + } + + /** + * Returns the signed area of a closed path on a sphere of given radius. + * The computed area uses the same units as the radius squared. + * Used by SphericalUtilTest. + */ + @JvmStatic + fun computeSignedArea(path: List, radius: Double): Double { + val size = path.size + if (size < 3) { + return 0.0 + } + var total = 0.0 + val prev = path[size - 1] + var prevTanLat = tan((PI / 2 - Math.toRadians(prev.latitude)) / 2) + var prevLng = Math.toRadians(prev.longitude) + // For each edge, accumulate the signed area of the triangle formed by the North Pole + // and that edge ("polar triangle"). + for (point in path) { + val tanLat = tan((PI / 2 - Math.toRadians(point.latitude)) / 2) + val lng = Math.toRadians(point.longitude) + total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng) + prevTanLat = tanLat + prevLng = lng + } + return total * (radius * radius) + } + + /** + * Returns the signed area of a triangle which has North Pole as a vertex. + * Formula derived from "Area of a spherical triangle given two edges and the included angle" + * as per "Spherical Trigonometry" by Todhunter, page 71, section 103, point 2. + * See http://books.google.com/books?id=3uBHAAAAIAAJ&pg=PA71 + * The arguments named "tan" are tan((pi/2 - latitude)/2). + */ + private fun polarTriangleArea(tan1: Double, lng1: Double, tan2: Double, lng2: Double): Double { + val deltaLng = lng1 - lng2 + val t = tan1 * tan2 + return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng)) + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/MathUtilTest.java b/library/src/test/java/com/google/maps/android/MathUtilTest.java new file mode 100644 index 000000000..496196850 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/MathUtilTest.java @@ -0,0 +1,100 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class MathUtilTest { + private static final double DELTA = 1e-15; + + @Test + public void testClamp() { + assertThat(MathUtil.clamp(1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.clamp(-1.0, 0.0, 2.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.clamp(3.0, 0.0, 2.0)).isWithin(DELTA).of(2.0); + } + + @Test + public void testWrap() { + assertThat(MathUtil.wrap(1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.wrap(3.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.wrap(-1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + } + + @Test + public void testMod() { + assertThat(MathUtil.mod(1.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.mod(3.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.mod(-1.0, 2.0)).isWithin(DELTA).of(1.0); + } + + @Test + public void testMercator() { + assertThat(MathUtil.mercator(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.mercator(Math.PI / 2)).isPositiveInfinity(); + assertThat(MathUtil.mercator(-Math.PI / 2)).isNegativeInfinity(); + } + + @Test + public void testInverseMercator() { + assertThat(MathUtil.inverseMercator(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.inverseMercator(Double.POSITIVE_INFINITY)).isWithin(DELTA).of(Math.PI / 2); + assertThat(MathUtil.inverseMercator(Double.NEGATIVE_INFINITY)).isWithin(DELTA).of(-Math.PI / 2); + } + + @Test + public void testHav() { + assertThat(MathUtil.hav(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.hav(Math.PI)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.hav(Math.PI / 2)).isWithin(DELTA).of(0.5); + } + + @Test + public void testArcHav() { + assertThat(MathUtil.arcHav(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.arcHav(1.0)).isWithin(DELTA).of(Math.PI); + assertThat(MathUtil.arcHav(0.5)).isWithin(DELTA).of(Math.PI / 2); + } + + @Test + public void testSinFromHav() { + assertThat(MathUtil.sinFromHav(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.sinFromHav(1.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.sinFromHav(0.5)).isWithin(DELTA).of(1.0); + } + + @Test + public void testHavFromSin() { + assertThat(MathUtil.havFromSin(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.havFromSin(1.0)).isWithin(DELTA).of(0.5); + } + + @Test + public void testSinSumFromHav() { + assertThat(MathUtil.sinSumFromHav(0.0, 0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.sinSumFromHav(0.5, 0.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.sinSumFromHav(0.0, 0.5)).isWithin(DELTA).of(1.0); + } + + @Test + public void testHavDistance() { + assertThat(MathUtil.havDistance(0.0, 0.0, 0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.havDistance(0.0, Math.PI, 0.0)).isWithin(DELTA).of(1.0); + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/PolyUtilTest.java b/library/src/test/java/com/google/maps/android/PolyUtilTest.java index cff8c538e..5091f7192 100644 --- a/library/src/test/java/com/google/maps/android/PolyUtilTest.java +++ b/library/src/test/java/com/google/maps/android/PolyUtilTest.java @@ -1,8 +1,9 @@ /* - * Copyright 2013 Google Inc. + * Copyright 2013 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 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 @@ -17,21 +18,34 @@ package com.google.maps.android; import com.google.android.gms.maps.model.LatLng; - import org.junit.Test; import java.util.ArrayList; import java.util.List; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - +import static com.google.common.truth.Truth.assertThat; + +/** + * This class defines a series of tests for the {@link PolyUtil} class. + * Each test is designed to verify the correctness of a specific geometric utility function + * provided by {@link PolyUtil}, such as checking if a point is contained within a polygon, + * if it lies on an edge, or simplifying a polyline. + *

+ * The tests are structured to cover a wide range of scenarios, including edge cases like + * empty polygons, polygons that cross the international date line, and polygons near the poles. + * This comprehensive testing ensures that the geometric calculations are robust and reliable. + */ public class PolyUtilTest { private static final String TEST_LINE = - "_cqeFf~cjVf@p@fA}AtAoB`ArAx@hA`GbIvDiFv@gAh@t@X\\|@z@`@Z\\Xf@Vf@VpA\\tATJ@NBBkC"; + "_cqeFf~cjVf@p@fA}AtAoB`ArAx@hA`GbIvDiFv@gAh@t@X\\|@z@`@Z\\Xf@Vf@VpA\\tATJ@NBBkC"; + /** + * A helper method to construct a {@link List} of {@link LatLng} objects from a series of + * latitude and longitude coordinates. This simplifies the creation of test polygons and polylines. + * + * @param coords A varargs array of doubles, representing latitude and longitude pairs. + * @return A {@link List} of {@link LatLng} objects. + */ private static List makeList(double... coords) { int size = coords.length / 2; List list = new ArrayList<>(size); @@ -41,54 +55,111 @@ private static List makeList(double... coords) { return list; } + /** + * A helper method to test the {@link PolyUtil#containsLocation(LatLng, List, boolean)} method. + * It asserts that all points in the {@code yes} list are contained within the polygon, + * and all points in the {@code no} list are not. This is tested for both geodesic and rhumb line paths. + * + * @param poly The polygon to test against. + * @param yes A list of points that are expected to be inside the polygon. + * @param no A list of points that are expected to be outside the polygon. + */ private static void containsCase(List poly, List yes, List no) { for (LatLng point : yes) { - assertTrue(PolyUtil.containsLocation(point, poly, true)); - assertTrue(PolyUtil.containsLocation(point, poly, false)); + assertThat(PolyUtil.containsLocation(point, poly, true)).isTrue(); + assertThat(PolyUtil.containsLocation(point, poly, false)).isTrue(); } for (LatLng point : no) { - assertFalse(PolyUtil.containsLocation(point, poly, true)); - assertFalse(PolyUtil.containsLocation(point, poly, false)); + assertThat(PolyUtil.containsLocation(point, poly, true)).isFalse(); + assertThat(PolyUtil.containsLocation(point, poly, false)).isFalse(); } } + /** + * A helper method to test the {@link PolyUtil#isLocationOnEdge(LatLng, List, boolean)} and + * {@link PolyUtil#isLocationOnPath(LatLng, List, boolean)} methods. + * It asserts that all points in the {@code yes} list are on the edge of the polygon, + * and all points in the {@code no} list are not. + * + * @param geodesic Whether to use geodesic or rhumb line paths. + * @param poly The polygon or polyline to test against. + * @param yes A list of points that are expected to be on the edge. + * @param no A list of points that are expected to not be on the edge. + */ private static void onEdgeCase( - boolean geodesic, List poly, List yes, List no) { + boolean geodesic, List poly, List yes, List no) { for (LatLng point : yes) { - assertTrue(PolyUtil.isLocationOnEdge(point, poly, geodesic)); - assertTrue(PolyUtil.isLocationOnPath(point, poly, geodesic)); + assertThat(PolyUtil.isLocationOnEdge(point, poly, geodesic)).isTrue(); + assertThat(PolyUtil.isLocationOnPath(point, poly, geodesic)).isTrue(); } for (LatLng point : no) { - assertFalse(PolyUtil.isLocationOnEdge(point, poly, geodesic)); - assertFalse(PolyUtil.isLocationOnPath(point, poly, geodesic)); + assertThat(PolyUtil.isLocationOnEdge(point, poly, geodesic)).isFalse(); + assertThat(PolyUtil.isLocationOnPath(point, poly, geodesic)).isFalse(); } } + /** + * Overloaded helper method for {@link #onEdgeCase(boolean, List, List, List)} that tests for both + * geodesic and rhumb line paths. + */ private static void onEdgeCase(List poly, List yes, List no) { onEdgeCase(true, poly, yes, no); onEdgeCase(false, poly, yes, no); } + /** + * A helper method to test the {@link PolyUtil#locationIndexOnPath(LatLng, List, boolean)}. + * It asserts that the returned index for a given point on a polyline is as expected. + * + * @param geodesic Whether to use geodesic or rhumb line paths. + * @param poly The polyline to test against. + * @param point The point to find the index for. + * @param idx The expected index. + */ private static void locationIndexCase( - boolean geodesic, List poly, LatLng point, int idx) { - assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic)); + boolean geodesic, List poly, LatLng point, int idx) { + assertThat(PolyUtil.locationIndexOnPath(point, poly, geodesic)).isEqualTo(idx); } + /** + * Overloaded helper method for {@link #locationIndexCase(boolean, List, LatLng, int)} that tests for both + * geodesic and rhumb line paths. + */ private static void locationIndexCase(List poly, LatLng point, int idx) { locationIndexCase(true, poly, point, idx); locationIndexCase(false, poly, point, idx); } + /** + * A helper method to test {@link PolyUtil#locationIndexOnPath(LatLng, List, boolean, double)} + * with a specific tolerance. + * + * @param geodesic Whether to use geodesic or rhumb line paths. + * @param poly The polyline to test against. + * @param point The point to find the index for. + * @param idx The expected index. + */ private static void locationIndexToleranceCase( - boolean geodesic, List poly, LatLng point, int idx) { - assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic, 0.1)); + boolean geodesic, List poly, LatLng point, int idx) { + assertThat(PolyUtil.locationIndexOnPath(point, poly, geodesic, 0.1)).isEqualTo(idx); } + /** + * Overloaded helper method for {@link #locationIndexToleranceCase(boolean, List, LatLng, int)} that tests for both + * geodesic and rhumb line paths. + */ private static void locationIndexToleranceCase(List poly, LatLng point, int idx) { locationIndexToleranceCase(true, poly, point, idx); locationIndexToleranceCase(false, poly, point, idx); } + /** + * This test verifies the behavior of the `isLocationOnEdge` and `isLocationOnPath` methods. + * It covers a variety of scenarios, including empty polylines, endpoints, and segments on the equator, + * meridians, and slanted lines. It also tests cases near the poles and with long arcs. + * The test uses a small tolerance to check for points that are very close to the edge, and a larger + * tolerance to check for points that are further away. + */ @Test public void testOnEdge() { // Empty @@ -103,68 +174,73 @@ public void testOnEdge() { // On equator. onEdgeCase( - makeList(0, 90, 0, 180), - makeList(0, 90 - small, 0, 90 + small, 0 - small, 90, 0, 135, small, 135), - makeList(0, 90 - big, 0, 0, 0, -90, big, 135)); + makeList(0, 90, 0, 180), + makeList(0, 90 - small, 0, 90 + small, 0 - small, 90, 0, 135, small, 135), + makeList(0, 90 - big, 0, 0, 0, -90, big, 135)); // Ends on same latitude. onEdgeCase( - makeList(-45, -180, -45, -small), - makeList(-45, 180 + small, -45, 180 - small, -45 - small, 180 - small, -45, 0), - makeList(-45, big, -45, 180 - big, -45 + big, -90, -45, 90)); + makeList(-45, -180, -45, -small), + makeList(-45, 180 + small, -45, 180 - small, -45 - small, 180 - small, -45, 0), + makeList(-45, big, -45, 180 - big, -45 + big, -90, -45, 90)); // Meridian. onEdgeCase( - makeList(-10, 30, 45, 30), - makeList(10, 30 - small, 20, 30 + small, -10 - small, 30 + small), - makeList(-10 - big, 30, 10, -150, 0, 30 - big)); + makeList(-10, 30, 45, 30), + makeList(10, 30 - small, 20, 30 + small, -10 - small, 30 + small), + makeList(-10 - big, 30, 10, -150, 0, 30 - big)); // Slanted close to meridian, close to North pole. onEdgeCase( - makeList(0, 0, 90 - small, 0 + big), - makeList(1, 0 + small, 2, 0 - small, 90 - small, -90, 90 - small, 10), - makeList(-big, 0, 90 - big, 180, 10, big)); + makeList(0, 0, 90 - small, 0 + big), + makeList(1, 0 + small, 2, 0 - small, 90 - small, -90, 90 - small, 10), + makeList(-big, 0, 90 - big, 180, 10, big)); // Arc > 120 deg. onEdgeCase( - makeList(0, 0, 0, 179.999), - makeList(0, 90, 0, small, 0, 179, small, 90), - makeList(0, -90, small, -100, 0, 180, 0, -big, 90, 0, -90, 180)); + makeList(0, 0, 0, 179.999), + makeList(0, 90, 0, small, 0, 179, small, 90), + makeList(0, -90, small, -100, 0, 180, 0, -big, 90, 0, -90, 180)); onEdgeCase( - makeList(10, 5, 30, 15), - makeList(10 + 2 * big, 5 + big, 10 + big, 5 + big / 2, 30 - 2 * big, 15 - big), - makeList( - 20, - 10, - 10 - big, - 5 - big / 2, - 30 + 2 * big, - 15 + big, - 10 + 2 * big, - 5, - 10, - 5 + big)); + makeList(10, 5, 30, 15), + makeList(10 + 2 * big, 5 + big, 10 + big, 5 + big / 2, 30 - 2 * big, 15 - big), + makeList( + 20, + 10, + 10 - big, + 5 - big / 2, + 30 + 2 * big, + 15 + big, + 10 + 2 * big, + 5, + 10, + 5 + big)); onEdgeCase( - makeList(90 - small, 0, 0, 180 - small / 2), - makeList(big, -180 + small / 2, big, 180 - small / 4, big, 180 - small), - makeList(-big, -180 + small / 2, -big, 180, -big, 180 - small)); + makeList(90 - small, 0, 0, 180 - small / 2), + makeList(big, -180 + small / 2, big, 180 - small / 4, big, 180 - small), + makeList(-big, -180 + small / 2, -big, 180, -big, 180 - small)); // Reaching close to North pole. onEdgeCase( - true, - makeList(80, 0, 80, 180 - small), - makeList(90 - small, -90, 90, -135, 80 - small, 0, 80 + small, 0), - makeList(80, 90, 79, big)); + true, + makeList(80, 0, 80, 180 - small), + makeList(90 - small, -90, 90, -135, 80 - small, 0, 80 + small, 0), + makeList(80, 90, 79, big)); onEdgeCase( - false, - makeList(80, 0, 80, 180 - small), - makeList(80 - small, 0, 80 + small, 0, 80, 90), - makeList(79, big, 90 - small, -90, 90, -135)); + false, + makeList(80, 0, 80, 180 - small), + makeList(80 - small, 0, 80 + small, 0, 80, 90), + makeList(79, big, 90 - small, -90, 90, -135)); } + /** + * This test verifies the `locationIndexOnPath` method, which determines the index of the segment + * a point lies on. It tests empty polylines, single-point polylines, and multi-segment polylines, + * ensuring that the correct segment index is returned for points on and off the path. + */ @Test public void testLocationIndex() { // Empty. @@ -188,6 +264,11 @@ public void testLocationIndex() { locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 110), -1); } + /** + * This test specifically focuses on the tolerance parameter of the `locationIndexOnPath` method. + * It verifies that the method correctly identifies points as being on a path segment within a given + * tolerance, and correctly identifies points as being off the path if they are outside the tolerance. + */ @Test public void testLocationIndexTolerance() { final double small = 5e-7; // About 5cm on equator, half the default tolerance. @@ -195,20 +276,26 @@ public void testLocationIndexTolerance() { // Test tolerance. locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90), 0); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90), 0); locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + small), 0); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + small), 0); locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 2 * small), 1); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 2 * small), 1); locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 3 * small), -1); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 3 * small), -1); locationIndexToleranceCase(makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90), 0); locationIndexToleranceCase( - makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + big), 1); + makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + big), 1); locationIndexToleranceCase( - makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + 2 * big), -1); + makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + 2 * big), -1); } + /** + * This test verifies the `containsLocation` method, which checks if a point is inside a polygon. + * It includes tests for empty polygons, single-point polygons, and various shapes of polygons. + * Special attention is given to polygons that are near the North and South poles, as these can be + * tricky edge cases for geometric calculations. + */ @Test public void testContainsLocation() { // Empty. @@ -222,38 +309,46 @@ public void testContainsLocation() { // Some arbitrary triangle. containsCase( - makeList(0., 0., 10., 12., 20., 5.), - makeList(10., 12., 10, 11, 19, 5), - makeList(0, 1, 11, 12, 30, 5, 0, -180, 0, 90)); + makeList(0., 0., 10., 12., 20., 5.), + makeList(10., 12., 10, 11, 19, 5), + makeList(0, 1, 11, 12, 30, 5, 0, -180, 0, 90)); // Around North Pole. containsCase( - makeList(89, 0, 89, 120, 89, -120), - makeList(90, 0, 90, 180, 90, -90), - makeList(-90, 0, 0, 0)); + makeList(89, 0, 89, 120, 89, -120), + makeList(90, 0, 90, 180, 90, -90), + makeList(-90, 0, 0, 0)); // Around South Pole. containsCase( - makeList(-89, 0, -89, 120, -89, -120), - makeList(90, 0, 90, 180, 90, -90, 0, 0), - makeList(-90, 0, -90, 90)); + makeList(-89, 0, -89, 120, -89, -120), + makeList(90, 0, 90, 180, 90, -90, 0, 0), + makeList(-90, 0, -90, 90)); // Over/under segment on meridian and equator. containsCase( - makeList(5, 10, 10, 10, 0, 20, 0, -10), - makeList(2.5, 10, 1, 0), - makeList(15, 10, 0, -15, 0, 25, -1, 0)); + makeList(5, 10, 10, 10, 0, 20, 0, -10), + makeList(2.5, 10, 1, 0), + makeList(15, 10, 0, -15, 0, 25, -1, 0)); } + /** + * This test verifies the `simplify` method, which uses the Douglas-Peucker algorithm to reduce + * the number of points in a polyline or polygon. The test checks the simplification at various + * tolerance levels, from small to large, and asserts that the simplified line has the expected + * number of points. It also verifies that the endpoints of the simplified line are the same as + * the original, that the simplified points are a subset of the original points, and that the + * length of the simplified line is less than or equal to the original. + */ @Test public void testSimplify() { /* * Polyline */ final String LINE = - "elfjD~a}uNOnFN~Em@fJv@tEMhGDjDe@hG^nF??@lA?n@IvAC`Ay@A{@DwCA{CF_EC{CEi@PBTFDJBJ?V?n@?D@?A@?@?F?F?LAf@?n@@`@@T@~@FpA?fA?p@?r@?vAH`@OR@^ETFJCLD?JA^?J?P?fAC`B@d@?b@A\\@`@Ad@@\\?`@?f@?V?H?DD@DDBBDBD?D?B?B@B@@@B@B@B@D?D?JAF@H@FCLADBDBDCFAN?b@Af@@x@@"; + "elfjD~a}uNOnFN~Em@fJv@tEMhGDjDe@hG^nF??@lA?n@IvAC`Ay@A{@DwCA{CF_EC{CEi@PBTFDJBJ?V?n@?D@?A@?@?F?F?LAf@?n@@`@@T@~@FpA?fA?p@?r@?vAH`@OR@^ETFJCLD?JA^?J?P?fAC`B@d@?b@A\\@`@Ad@@\\?`@?f@?V?H?DD@DDBBDBD?D?B?B@B@@@B@B@B@D?D?JAF@H@FCLADBDBDCFAN?b@Af@@x@@"; List line = PolyUtil.decode(LINE); - assertEquals(95, line.size()); + assertThat(line.size()).isEqualTo(95); List simplifiedLine; List copy; @@ -261,7 +356,7 @@ public void testSimplify() { double tolerance = 5; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(20, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(20); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -270,7 +365,7 @@ public void testSimplify() { tolerance = 10; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(14, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(14); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -279,7 +374,7 @@ public void testSimplify() { tolerance = 15; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(10, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(10); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -288,7 +383,7 @@ public void testSimplify() { tolerance = 20; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(8, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(8); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -297,7 +392,7 @@ public void testSimplify() { tolerance = 50; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(6, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(6); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -306,7 +401,7 @@ public void testSimplify() { tolerance = 500; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(3, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(3); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -315,7 +410,7 @@ public void testSimplify() { tolerance = 1000; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(2, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(2); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -332,12 +427,12 @@ public void testSimplify() { triangle.add(new LatLng(28.06125, -82.40850)); triangle.add(new LatLng(28.06035, -82.40834)); triangle.add(new LatLng(28.06038, -82.40924)); - assertFalse(PolyUtil.isClosedPolygon(triangle)); + assertThat(PolyUtil.isClosedPolygon(triangle)).isFalse(); copy = new ArrayList<>(triangle); tolerance = 88; // meters List simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertEquals(4, simplifiedTriangle.size()); + assertThat(simplifiedTriangle.size()).isEqualTo(4); assertEndPoints(triangle, simplifiedTriangle); assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); assertLineLength(triangle, simplifiedTriangle); @@ -347,12 +442,12 @@ public void testSimplify() { LatLng p = triangle.get(0); LatLng closePoint = new LatLng(p.latitude, p.longitude); triangle.add(closePoint); - assertTrue(PolyUtil.isClosedPolygon(triangle)); + assertThat(PolyUtil.isClosedPolygon(triangle)).isTrue(); copy = new ArrayList<>(triangle); tolerance = 88; // meters simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertEquals(4, simplifiedTriangle.size()); + assertThat(simplifiedTriangle.size()).isEqualTo(4); assertEndPoints(triangle, simplifiedTriangle); assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); assertLineLength(triangle, simplifiedTriangle); @@ -360,14 +455,14 @@ public void testSimplify() { // Open oval final String OVAL_POLYGON = - "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZ"; + "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZ"; List oval = PolyUtil.decode(OVAL_POLYGON); - assertFalse(PolyUtil.isClosedPolygon(oval)); + assertThat(PolyUtil.isClosedPolygon(oval)).isFalse(); copy = new ArrayList<>(oval); tolerance = 10; // meters List simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertEquals(13, simplifiedOval.size()); + assertThat(simplifiedOval.size()).isEqualTo(13); assertEndPoints(oval, simplifiedOval); assertSimplifiedPointsFromLine(oval, simplifiedOval); assertLineLength(oval, simplifiedOval); @@ -377,12 +472,12 @@ public void testSimplify() { p = oval.get(0); closePoint = new LatLng(p.latitude, p.longitude); oval.add(closePoint); - assertTrue(PolyUtil.isClosedPolygon(oval)); + assertThat(PolyUtil.isClosedPolygon(oval)).isTrue(); copy = new ArrayList<>(oval); tolerance = 10; // meters simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertEquals(13, simplifiedOval.size()); + assertThat(simplifiedOval.size()).isEqualTo(13); assertEndPoints(oval, simplifiedOval); assertSimplifiedPointsFromLine(oval, simplifiedOval); assertLineLength(oval, simplifiedOval); @@ -398,8 +493,8 @@ public void testSimplify() { * @param simplifiedLine simplified line */ private void assertEndPoints(List line, List simplifiedLine) { - assertEquals(line.get(0), simplifiedLine.get(0)); - assertEquals(line.get(line.size() - 1), simplifiedLine.get(simplifiedLine.size() - 1)); + assertThat(simplifiedLine.get(0)).isEqualTo(line.get(0)); + assertThat(simplifiedLine.get(simplifiedLine.size() - 1)).isEqualTo(line.get(line.size() - 1)); } /** @@ -410,7 +505,7 @@ private void assertEndPoints(List line, List simplifiedLine) { */ private void assertSimplifiedPointsFromLine(List line, List simplifiedLine) { for (LatLng l : simplifiedLine) { - assertTrue(line.contains(l)); + assertThat(line).contains(l); } } @@ -424,16 +519,14 @@ private void assertSimplifiedPointsFromLine(List line, List simp private void assertLineLength(List line, List simplifiedLine) { if (line.size() == simplifiedLine.size()) { // If no points were eliminated, then the length of both lines should be the same - assertEquals( - SphericalUtil.computeLength(simplifiedLine), - SphericalUtil.computeLength(line), - 0.0); + assertThat(SphericalUtil.computeLength(simplifiedLine)) + .isWithin(0.0) + .of(SphericalUtil.computeLength(line)); } else { - assertTrue(simplifiedLine.size() < line.size()); + assertThat(simplifiedLine.size()).isLessThan(line.size()); // If points were eliminated, then the simplified line should always be shorter - assertTrue( - SphericalUtil.computeLength(simplifiedLine) - < SphericalUtil.computeLength(line)); + assertThat(SphericalUtil.computeLength(simplifiedLine)) + .isLessThan(SphericalUtil.computeLength(line)); } } @@ -448,14 +541,18 @@ private void assertLineLength(List line, List simplifiedLine) { */ private void assertInputUnchanged(List afterInput, List beforeInput) { // Check values - assertEquals(beforeInput, afterInput); + assertThat(afterInput).isEqualTo(beforeInput); // Check references for (int i = 0; i < beforeInput.size(); i++) { - assertSame(afterInput.get(i), beforeInput.get(i)); + assertThat(afterInput.get(i)).isSameInstanceAs(beforeInput.get(i)); } } + /** + * This test verifies the `isClosedPolygon` method. It checks that the method correctly + * identifies a polygon as closed only when its first and last points are identical. + */ @Test public void testIsClosedPolygon() { ArrayList poly = new ArrayList<>(); @@ -465,20 +562,20 @@ public void testIsClosedPolygon() { poly.add(new LatLng(28.06125, -82.40850)); poly.add(new LatLng(28.06035, -82.40834)); - assertFalse(PolyUtil.isClosedPolygon(poly)); + assertThat(PolyUtil.isClosedPolygon(poly)).isFalse(); // Add the closing point that's same as the first poly.add(new LatLng(28.06025, -82.41030)); - assertTrue(PolyUtil.isClosedPolygon(poly)); + assertThat(PolyUtil.isClosedPolygon(poly)).isTrue(); } /** * The following method checks whether {@link PolyUtil#distanceToLine(LatLng, LatLng, LatLng) distanceToLine()} } * is determining the distance between a point and a segment accurately. - * + *

* Currently there are tests for different orders of magnitude (i.e., 1X, 10X, 100X, 1000X), as well as a test * where the segment and the point lie in different hemispheres. - * + *

* If further tests need to be added here, make sure that the distance has been verified with QGIS. * * @see QGIS @@ -490,46 +587,51 @@ public void testDistanceToLine() { LatLng p = new LatLng(28.05342, -82.41594); double distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(37.94596795917082, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(37.94596795917082); startLine = new LatLng(49.321045, 12.097749); endLine = new LatLng(49.321016, 12.097795); p = new LatLng(49.3210674, 12.0978238); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(5.559443879999753, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(5.559443879999753); startLine = new LatLng(48.125961, 11.548998); endLine = new LatLng(48.125918, 11.549005); p = new LatLng(48.125941, 11.549028); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(1.9733966358947437, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(1.9733966358947437); startLine = new LatLng(78.924669, 11.925521); endLine = new LatLng(78.924707, 11.929060); p = new LatLng(78.923164, 11.924029); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(170.35662670453187, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(170.35662670453187); startLine = new LatLng(69.664036, 18.957124); endLine = new LatLng(69.664029, 18.957109); p = new LatLng(69.672901, 18.967911); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(1070.222749990837, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(1070.222749990837); startLine = new LatLng(-0.018200, 109.343282); endLine = new LatLng(-0.017877, 109.343537); p = new LatLng(0.058299, 109.408054); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(11100.157563150981, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(11100.157563150981); } + /** + * This test ensures that the distance from a point to a line segment is always less than or equal + * to the distance from the point to either of the segment's endpoints. This is a fundamental + * property of Euclidean geometry that should also hold true for spherical geometry for short distances. + */ @Test - public void testDistanceToLineLessThanDistanceToExtrems() { + public void testDistanceToLineLessThanDistanceToExtremes() { LatLng startLine = new LatLng(28.05359, -82.41632); LatLng endLine = new LatLng(28.05310, -82.41634); LatLng p = new LatLng(28.05342, -82.41594); @@ -538,25 +640,37 @@ public void testDistanceToLineLessThanDistanceToExtrems() { double distanceToStart = SphericalUtil.computeDistanceBetween(p, startLine); double distanceToEnd = SphericalUtil.computeDistanceBetween(p, endLine); - assertTrue("Wrong distance.", distance <= distanceToStart && distance <= distanceToEnd); + assertThat(distance).isAtMost(distanceToStart); + assertThat(distance).isAtMost(distanceToEnd); } + /** + * This test verifies the `decode` method, which decodes an encoded polyline string into a list + * of `LatLng` points. It checks that the decoded path has the correct number of points and that + * the last point has the expected latitude and longitude. + */ @Test public void testDecodePath() { List latLngs = PolyUtil.decode(TEST_LINE); int expectedLength = 21; - assertEquals("Wrong length.", expectedLength, latLngs.size()); + assertThat(latLngs.size()).isEqualTo(expectedLength); LatLng lastPoint = latLngs.get(expectedLength - 1); - assertEquals(37.76953, lastPoint.latitude, 1e-6); - assertEquals(-122.41488, lastPoint.longitude, 1e-6); + assertThat(lastPoint.latitude).isWithin(1e-6).of(37.76953); + assertThat(lastPoint.longitude).isWithin(1e-6).of(-122.41488); } + /** + * This test verifies the `encode` method, which encodes a list of `LatLng` points into a + * polyline string. It first decodes a test string, then re-encodes the resulting list of points, + * and finally asserts that the re-encoded string is identical to the original. This ensures the + * encode and decode methods are inverse operations. + */ @Test public void testEncodePath() { List path = PolyUtil.decode(TEST_LINE); String encoded = PolyUtil.encode(path); - assertEquals(TEST_LINE, encoded); + assertThat(encoded).isEqualTo(TEST_LINE); } } diff --git a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java index 895bccd25..c0071331c 100644 --- a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java +++ b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 Google Inc. + * Copyright 2023 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,16 +18,16 @@ import com.google.android.gms.maps.model.LatLng; +import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; +import static com.google.common.truth.Truth.assertThat; 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 @@ -42,10 +42,10 @@ public class SphericalUtilTest { * Tests for approximate equality. */ private static void expectLatLngApproxEquals(LatLng actual, LatLng expected) { - assertEquals(actual.latitude, expected.latitude, 1e-6); + assertThat(actual.latitude).isWithin(1e-6).of(expected.latitude); // 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); + assertThat(cosLat * actual.longitude).isWithin(1e-6).of(cosLat * expected.longitude); } private static double computeSignedTriangleArea(LatLng a, LatLng b, LatLng c) { @@ -64,95 +64,83 @@ private static int isCCW(LatLng a, LatLng b, LatLng c) { @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); + assertThat(SphericalUtil.computeAngleBetween(up, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(down, down)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(left, left)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(right, right)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(front, front)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(back, back)).isWithin(1e-6).of(0); // 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); + assertThat(SphericalUtil.computeAngleBetween(up, front)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(up, right)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(up, back)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(up, left)).isWithin(1e-6).of(Math.PI / 2); - 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); + assertThat(SphericalUtil.computeAngleBetween(down, front)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(down, right)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(down, back)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(down, left)).isWithin(1e-6).of(Math.PI / 2); - 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); + assertThat(SphericalUtil.computeAngleBetween(back, up)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(back, right)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(back, down)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(back, left)).isWithin(1e-6).of(Math.PI / 2); // 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); + assertThat(SphericalUtil.computeAngleBetween(up, down)).isWithin(1e-6).of(Math.PI); + assertThat(SphericalUtil.computeAngleBetween(front, back)).isWithin(1e-6).of(Math.PI); + assertThat(SphericalUtil.computeAngleBetween(left, right)).isWithin(1e-6).of(Math.PI); } @Test public void testDistances() { - assertEquals(SphericalUtil.computeDistanceBetween(up, down), Math.PI * EARTH_RADIUS, 1e-6); + assertThat(SphericalUtil.computeDistanceBetween(up, down)).isWithin(1e-6).of(Math.PI * EARTH_RADIUS); } @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); + assertThat(SphericalUtil.computeHeading(up, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(down, up)).isWithin(1e-6).of(0); // 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); + assertThat(SphericalUtil.computeHeading(front, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeHeading(right, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeHeading(back, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeHeading(down, up)).isWithin(1e-6).of(0); - 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); + assertThat(SphericalUtil.computeHeading(front, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(right, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(back, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(left, down)).isWithin(1e-6).of(-180); - assertEquals(SphericalUtil.computeHeading(right, front), -90, 1e-6); - assertEquals(SphericalUtil.computeHeading(left, front), 90, 1e-6); + assertThat(SphericalUtil.computeHeading(right, front)).isWithin(1e-6).of(-90); + assertThat(SphericalUtil.computeHeading(left, front)).isWithin(1e-6).of(90); - assertEquals(SphericalUtil.computeHeading(front, right), 90, 1e-6); - assertEquals(SphericalUtil.computeHeading(back, right), -90, 1e-6); + assertThat(SphericalUtil.computeHeading(front, right)).isWithin(1e-6).of(90); + assertThat(SphericalUtil.computeHeading(back, right)).isWithin(1e-6).of(-90); } @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)); + 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)); + 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. @@ -160,37 +148,19 @@ public void testComputeOffset() { @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)); */ + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(front, 0, 0))); + + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(0, 45), Math.PI * EARTH_RADIUS / 4, 90))); + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(0, -45), Math.PI * EARTH_RADIUS / 4, -90))); + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(45, 0), Math.PI * EARTH_RADIUS / 4, 0))); + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(-45, 0), Math.PI * EARTH_RADIUS / 4, 180))); + // Situations with no solution, should return null. // // First 'over' the pole. - assertNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 180)); + assertThat(SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 180)).isNull(); // Second a distance that doesn't fit on the earth. - assertNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 90)); + assertThat(SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 90)).isNull(); } @Test @@ -203,37 +173,33 @@ public void testComputeOffsetAndBackToOrigin() { // 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)); + expectLatLngApproxEquals(start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); heading = -37; end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); + expectLatLngApproxEquals(start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); distance = 3.8e+7; end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); + expectLatLngApproxEquals(start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); start = new LatLng(-21, -73); end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); + expectLatLngApproxEquals(start, Objects.requireNonNull(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)); + start = SphericalUtil.computeOffsetOrigin(new LatLng(0, 90), Math.PI * EARTH_RADIUS / 2, 90); + Assert.assertNotNull(start); + 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)); + Assert.assertNotNull(start); + expectLatLngApproxEquals(new LatLng(90, 0), SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 4, 0)); } @Test @@ -246,35 +212,24 @@ public void testInterpolate() { // 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(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)); + 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)); + 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)); + 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)); @@ -282,91 +237,66 @@ public void testInterpolate() { // 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)); + 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); + assertThat(SphericalUtil.computeLength(Collections.emptyList())).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeLength(List.of(new LatLng(0, 0)))).isWithin(1e-6).of(0); 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); + assertThat(SphericalUtil.computeLength(latLngs)).isWithin(1).of(Math.toRadians(0.1) * Math.sqrt(2) * EARTH_RADIUS); 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); + assertThat(SphericalUtil.computeLength(latLngs)).isWithin(1e-6).of(Math.PI * EARTH_RADIUS); } @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)); + assertThat(isCCW(right, up, front)).isEqualTo(1); + assertThat(isCCW(up, front, right)).isEqualTo(1); + assertThat(isCCW(front, right, up)).isEqualTo(1); + assertThat(isCCW(front, up, right)).isEqualTo(-1); + assertThat(isCCW(up, right, front)).isEqualTo(-1); + assertThat(isCCW(right, front, up)).isEqualTo(-1); } @Test public void testComputeTriangleArea() { - assertEquals(computeTriangleArea(right, up, front), Math.PI / 2, 1e-6); - assertEquals(computeTriangleArea(front, up, right), Math.PI / 2, 1e-6); + assertThat(computeTriangleArea(right, up, front)).isWithin(1e-6).of(Math.PI / 2); + assertThat(computeTriangleArea(front, up, right)).isWithin(1e-6).of(Math.PI / 2); // 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 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); + assertThat(Math.abs(expectedArea - area)).isLessThan(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); + assertThat(computeSignedTriangleArea(new LatLng(0, 0), new LatLng(0, 0.1), new LatLng(0.1, 0.1))).isWithin(1e-6).of(Math.toRadians(0.1) * Math.toRadians(0.1) / 2); - assertEquals(computeSignedTriangleArea(right, up, front), Math.PI / 2, 1e-6); + assertThat(computeSignedTriangleArea(right, up, front)).isWithin(1e-6).of(Math.PI / 2); - assertEquals(computeSignedTriangleArea(front, up, right), -Math.PI / 2, 1e-6); + assertThat(computeSignedTriangleArea(front, up, right)).isWithin(1e-6).of(-Math.PI / 2); } @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); + assertThat(SphericalUtil.computeArea(Arrays.asList(right, up, front, down, right))).isWithin(.4).of(Math.PI * EARTH_RADIUS * EARTH_RADIUS); + + assertThat(SphericalUtil.computeArea(Arrays.asList(right, down, front, up, right))).isWithin(.4).of(Math.PI * EARTH_RADIUS * EARTH_RADIUS); } @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); + assertThat(-SphericalUtil.computeSignedArea(path)).isWithin(0).of(SphericalUtil.computeSignedArea(pathReversed)); } -} +} \ No newline at end of file