PolygonArea.java
/*
* Copyright (C) 2018 Alberto Irurueta Carro (alberto@irurueta.com)
*
* 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.irurueta.navigation.geodesic;
/**
* Polygon areas.
* This computes the area of a geodesic polygon using the method given Section 6 of
* <ul>
* <li>
* C. F. F. Karney, <a href="https://doi.org/10.1007/s00190-012-0578-z">Algorithms for
* geodesics</a>,
* J. Geodesy <b>87</b>, 43–55 (2013)
* </li>
* </ul>
* This class lets you add vertices one at a time to the polygon. The area and perimeter are
* accumulated at two times the standard floating point precision to guard against the loss of
* accuracy with many-sided polygons.
* At any point you can ask for the perimeter and area so far. There's an option to treat the
* points as defining a polyline instead of a polygon; in that case, only the perimeter is
* computed.
* Example of use:
* <pre>
* {@code
* // Compute the area of a geodesic polygon.
*
* // This program reads lines with lat, lon for each vertex of a polygon.
* // At the end of input, the program prints the number of vertices,
* // the perimeter of the polygon and its area (for the WGS84 ellipsoid).
*
* import java.util.*;
* import com.irurueta.navigation.geodesic.*;
*
* public class Planimeter {
* public static void main(String[] args) {
* PolygonArea p = new PolygonArea(Geodesic.WGS84, false);
* try {
* Scanner in = new Scanner(System.in);
* while (true) {
* double lat = in.nextDouble(), lon = in.nextDouble();
* p.AddPoint(lat, lon);
* }
* }
* catch (Exception e) {}
* PolygonResult r = p.Compute();
* System.out.println(r.num + " " + r.perimeter + " " + r.area);
* }
* }}</pre>
*/
@SuppressWarnings("DuplicatedCode")
public class PolygonArea {
private final Geodesic earth;
// full ellipsoid area
private final double area0;
// assume polyline (don't close and skip area)
private final boolean polyline;
private final int mask;
private int num;
private int crossings;
private Accumulator areasum;
private final Accumulator perimetersum;
private double lat0;
private double lon0;
private double lat1;
private double lon1;
/**
* Constructor for PolygonArea.
*
* @param earth the Geodesic object to use for geodesic calculations.
* @param polyline if true that treat the points as defining a polyline instead of a polygon.
*/
public PolygonArea(final Geodesic earth, final boolean polyline) {
this.earth = earth;
area0 = this.earth.getEllipsoidArea();
this.polyline = polyline;
mask = GeodesicMask.LATITUDE | GeodesicMask.LONGITUDE | GeodesicMask.DISTANCE |
(this.polyline ? GeodesicMask.NONE : GeodesicMask.AREA | GeodesicMask.LONG_UNROLL);
perimetersum = new Accumulator(0);
if (!this.polyline) {
areasum = new Accumulator(0);
}
clear();
}
/**
* Clear PolygonArea, allowing a new polygon to be started.
*/
public void clear() {
num = 0;
crossings = 0;
perimetersum.set(0);
if (!polyline) {
areasum.set(0);
}
lat0 = lon0 = lat1 = lon1 = Double.NaN;
}
/**
* Add a point to the polygon or polyline.
* <i>lat</i> should be in the range [−90°, 90°].
*
* @param lat the latitude of the point (degrees).
* @param lon the latitude of the point (degrees).
*/
public void addPoint(final double lat, double lon) {
lon = GeoMath.angNormalize(lon);
if (num == 0) {
lat0 = lat1 = lat;
lon0 = lon1 = lon;
} else {
final var g = earth.inverse(lat1, lon1, lat, lon, mask);
perimetersum.add(g.getS12());
if (!polyline) {
areasum.add(g.getAreaS12());
crossings += transit(lon1, lon);
}
lat1 = lat;
lon1 = lon;
}
++num;
}
/**
* Add an edge to the polygon or polyline.
* This does nothing if no points have been added yet. Use PolygonArea.getCurrentPoint to
* determine the position of the new vertex.
*
* @param azi azimuth at current point (degrees).
* @param s distance from current point to next point (meters).
*/
public void addEdge(final double azi, final double s) {
// do nothing if mNum is zero
if (num > 0) {
final var g = earth.direct(lat1, lon1, azi, s, mask);
perimetersum.add(g.getS12());
if (!polyline) {
areasum.add(g.getAreaS12());
crossings += transitDirect(lon1, g.getLon2());
}
lat1 = g.getLat2();
lon1 = g.getLon2();
++num;
}
}
/**
* Return the results so far.
* Counter-clockwise traversal counts as a positive area.
*
* @return PolygonResult(<i>num</i>, <i>perimeter</i>, <i>area</i>) where
* <i>num</i> is the number of vertices, <i>perimeter</i> is the perimeter of
* the polygon or the length of the polyline (meters), and <i>area</i> is the
* area of the polygon (meters<sup>2</sup>) or Double.NaN of <i>polyline</i>
* is true in the constructor.
*/
public PolygonResult compute() {
return compute(false, true);
}
/**
* Return the results so far.
* More points can be added to the polygon after this call.
*
* @param reverse if true then clockwise (instead of counter-clockwise) traversal counts as
* a positive area.
* @param sign if true then return a signed result for the area if the polygon is traversed
* in the "wrong" direction instead of returning the area for the rest of the
* earth.
* @return PolygonResult(<i>num</i>, <i>perimeter</i>, <i>area</i>) where
* <i>num</i> is the number of vertices, <i>perimeter</i> is the perimeter of the polygon
* or the length of the polyline (meters), and <i>area</i> is the area of the polygon
* (meters<sup>2</sup>) or Double.NaN of <i>polyline</i> is true in the constructor.
*/
public PolygonResult compute(final boolean reverse, final boolean sign) {
if (num < 2) {
return new PolygonResult(num, 0, polyline ? Double.NaN : 0);
}
if (polyline) {
return new PolygonResult(num, perimetersum.getSum(), Double.NaN);
}
final var g = earth.inverse(lat1, lon1, lat0, lon0, mask);
final var tempsum = new Accumulator(areasum);
tempsum.add(g.getAreaS12());
final var tcrossings = this.crossings + transit(lon1, lon0);
if ((tcrossings & 1) != 0) {
tempsum.add((tempsum.getSum() < 0 ? 1 : -1) * area0 / 2);
}
// area is with the clockwise sense. If !reverse convert to counter-clockwise convention
if (!reverse) {
tempsum.negate();
}
// if sign put area in (-rea0/2, area0/2], else put area in [0, area0)
if (sign) {
if (tempsum.getSum() > area0 / 2) {
tempsum.add(-area0);
} else if (tempsum.getSum() <= -area0 / 2) {
tempsum.add(+area0);
}
} else {
if (tempsum.getSum() >= area0) {
tempsum.add(-area0);
} else if (tempsum.getSum() < 0) {
tempsum.add(+area0);
}
}
return new PolygonResult(num, perimetersum.sum(g.getS12()), 0 + tempsum.getSum());
}
/**
* Return the results assuming a tentative final test point is added;
* however, the data for the test point is not saved. This lets you report a running result
* for the perimeter and area as the user moves the mouse cursor. Ordinary floating point
* arithmetic is used to accumulate the data for the test point; thus the area and perimeter
* returned are less accurate than if addPoint and compute are used.
* <i>lat</i> should be in the range [−90°, 90°].
*
* @param lat the latitude of the test point (degrees).
* @param lon the longitude of the test point (degrees).
* @param reverse if true then clockwise (instead of counter-clockwise) traversal counts as
* a positive area.
* @param sign if true then return a signed result for the area if the polygon is traversed
* in the "wrong" direction instead of returning the area for the rest of the
* earth.
* @return PolygonResult(<i>num</i>, <i>perimeter</i>, <i>area</i>) where <i>num</i> is
* the number of vertices, <i>perimeter</i> is the perimeter of the polygon or the length
* of the polyline (meters), and <i>area</i> is the area of the polygon (meters<sup>2</sup>)
* or Double.NaN of <i>polyline</i> is true in the constructor.
*/
public PolygonResult testPoint(final double lat, final double lon, final boolean reverse, final boolean sign) {
if (num == 0) {
return new PolygonResult(1, 0, polyline ? Double.NaN : 0);
}
var perimeter = perimetersum.getSum();
var tempsum = polyline ? 0 : areasum.getSum();
var tcrossings = this.crossings;
final var tnum = this.num + 1;
for (var i = 0; i < (polyline ? 1 : 2); ++i) {
final var g = earth.inverse(i == 0 ? lat1 : lat, i == 0 ? lon1 : lon, i != 0 ? lat0 : lat,
i != 0 ? lon0 : lon, mask);
perimeter += g.getS12();
if (!polyline) {
tempsum += g.getAreaS12();
tcrossings += transit(i == 0 ? lon1 : lon, i != 0 ? lon0 : lon);
}
}
if (polyline) {
return new PolygonResult(tnum, perimeter, Double.NaN);
}
if ((tcrossings & 1) != 0) {
tempsum += (tempsum < 0 ? 1 : -1) * area0 / 2;
}
// area is with the clockwise sense. If !reverse convert to counter-clockwise convention
if (!reverse) {
tempsum *= -1;
}
// if sign put area in (-area0/2, area0/2], else put area in [0, area0)
if (sign) {
if (tempsum > area0 / 2) {
tempsum -= area0;
} else if (tempsum <= -area0 / 2) {
tempsum += area0;
}
} else {
if (tempsum >= area0) {
tempsum -= area0;
} else if (tempsum < 0) {
tempsum += area0;
}
}
return new PolygonResult(tnum, perimeter, 0 + tempsum);
}
/**
* Return the results assuming a tentative final test point is added via an azimuth and distance;
* however, the data for the test point is not saved.
* This lets you report a running result for the perimeter and area as the user moves the mouse
* cursor. Ordinary floating point arithmetic is used to accumulate the data for the test point;
* thus the area and perimeter returned are less accurate than if addPoint and compute are used.
*
* @param azi azimuth at current point (degrees).
* @param s distance from current point to final test point (meters).
* @param reverse if true then clockwise (instead of counter-clockwise) traversal counts as a
* positive area.
* @param sign if true then return a signed result for the area if the polygon is traversed in
* the "wrong" direction instead of returning the area for the rest of the earth.
* @return PolygonResult(<i>num</i>, <i>perimeter</i>, <i>area</i>) where <i>num</i> is the
* number of vertices, <i>perimeter</i> is the perimeter of the polygon or the length of the
* polyline (meters), and <i>area</i> is the area of the polygon (meters<sup>2</sup>) or
* Double.NaN of <i>polyline</i> is true in the constructor.
*/
public PolygonResult testEdge(
final double azi, final double s, final boolean reverse, final boolean sign) {
// we don't have a starting point!
if (num == 0) {
return new PolygonResult(0, Double.NaN, Double.NaN);
}
final var tnum = this.num + 1;
var perimeter = perimetersum.getSum() + s;
if (polyline) {
return new PolygonResult(tnum, perimeter, Double.NaN);
}
var tempsum = areasum.getSum();
var tcrossings = this.crossings;
var g = earth.direct(lat1, lon1, azi, false, s, mask);
tempsum += g.getAreaS12();
tcrossings += transitDirect(lon1, g.getLon2());
g = earth.inverse(g.getLat2(), g.getLon2(), lat0, lon0, mask);
perimeter += g.getS12();
tempsum += g.getAreaS12();
tcrossings += transit(g.getLon2(), lon0);
if ((tcrossings & 1) != 0) {
tempsum += (tempsum < 0 ? 1 : -1) * area0 / 2;
}
// area is with the clockwise sense. If !reverse convert to counter-clockwise convention.
if (!reverse) {
tempsum *= -1;
}
// if sign put area in (-area0/2, area0/2], else put area in [0, area0)
if (sign) {
if (tempsum > area0 / 2) {
tempsum -= area0;
} else if (tempsum <= -area0 / 2) {
tempsum += area0;
}
} else {
if (tempsum >= area0) {
tempsum -= area0;
} else if (tempsum < 0) {
tempsum += area0;
}
}
return new PolygonResult(tnum, perimeter, 0 + tempsum);
}
/**
* Gets the equatorial radius of the ellipsoid (meters).
*
* @return <i>a</i> the equatorial radius of the ellipsoid (meters). This is the value inherited
* from the Geodesic object used in the constructor.
*/
public double getMajorRadius() {
return earth.getMajorRadius();
}
/**
* Gets the flattening of the ellipsoid.
*
* @return <i>f</i> the flattening of the ellipsoid. This is the value inherited from the Geodesic
* object used in the constructor.
*/
public double getFlattening() {
return earth.getFlattening();
}
/**
* Report the previous vertex added to the polygon or polyline.
* If no points have been added, then Double.NaN is returned. Otherwise, <i>lon</i> will be
* in the range [−180°, 180°].
*
* @return Pair(<i>lat</i>, <i>lon</i>), the current latitude and longitude.
*/
public Pair getCurrentPoint() {
return new Pair(lat1, lon1);
}
private static int transit(double lon1, double lon2) {
// return 1 or -1 if crossing prime meridian in east or west direction.
// Otherwise, return zero.
// Compute lon12 the same way as Geodesic.inverse.
lon1 = GeoMath.angNormalize(lon1);
lon2 = GeoMath.angNormalize(lon2);
final var lon12 = GeoMath.angDiff(lon1, lon2).getFirst();
if (lon1 <= 0 && lon2 > 0 && lon12 > 0) {
return 1;
} else {
return lon2 <= 0 && lon1 > 0 && lon12 < 0 ? -1 : 0;
}
}
// an alternate version of transit to deal with longitudes in the direct problem.
private static int transitDirect(double lon1, double lon2) {
// we want to compute exactly
// int(floor(lon2 / 360)) - int(floor(lon1 / 360))
lon1 = lon1 % 720.0;
lon2 = lon2 % 720.0;
return (((lon2 >= 0 && lon2 < 360) || lon2 < -360 ? 0 : 1) -
((lon1 >= 0 && lon1 < 360) || lon1 < -360 ? 0 : 1));
}
}