Merge remote-tracking branch 'origin/master' into 1.0-beta1-release; including changes made to master since rc2
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalException.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalError.java
similarity index 93%
rename from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalException.java
rename to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalError.java
index caf209e..bc52f62 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalException.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalError.java
@@ -20,7 +20,7 @@
  * This class is not intended to be part of the public API and should
  * never be seen by the user when algorithms are functioning correctly.
  */
-public class GeometryInternalException extends IllegalStateException {
+public class GeometryInternalError extends IllegalStateException {
 
     /** Error message used for exceptions of this type. */
     private static final String ERROR_MSG = "An internal geometry error occurred. This most often indicates an " +
@@ -32,7 +32,7 @@
 
     /** Simple constructor with a default error message.
      */
-    public GeometryInternalException() {
+    public GeometryInternalError() {
         super(ERROR_MSG);
     }
 }
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/GeometryInternalExceptionTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/GeometryInternalErrorTest.java
similarity index 90%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/GeometryInternalExceptionTest.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/GeometryInternalErrorTest.java
index 4780365..87198e1 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/GeometryInternalExceptionTest.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/GeometryInternalErrorTest.java
@@ -19,12 +19,12 @@
 import org.junit.Assert;
 import org.junit.Test;
 
-public class GeometryInternalExceptionTest {
+public class GeometryInternalErrorTest {
 
     @Test
     public void testMessage() {
         // act
-        final GeometryInternalException err = new GeometryInternalException();
+        final GeometryInternalError err = new GeometryInternalError();
 
         // assert
         final String msg = "An internal geometry error occurred. This most often indicates an " +
diff --git a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/WelzlEncloser.java b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/WelzlEncloser.java
index d94a0aa..9f9f772 100755
--- a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/WelzlEncloser.java
+++ b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/WelzlEncloser.java
@@ -20,7 +20,7 @@
 import java.util.List;
 
 import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.internal.GeometryInternalException;
+import org.apache.commons.geometry.core.internal.GeometryInternalError;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 
 /** Class implementing Emo Welzl's algorithm to find the smallest enclosing ball in linear time.
@@ -98,7 +98,7 @@
             ball = moveToFrontBall(extreme, extreme.size(), support);
             if (precision.lt(ball.getRadius(), savedBall.getRadius())) {
                 // this should never happen
-                throw new GeometryInternalException();
+                throw new GeometryInternalError();
             }
 
             // it was an interesting point, move it to the front
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotation.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotation.java
index 65f34c1..62e950a 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotation.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotation.java
@@ -19,7 +19,7 @@
 import java.util.Objects;
 import java.util.function.DoubleFunction;
 
-import org.apache.commons.geometry.core.internal.GeometryInternalException;
+import org.apache.commons.geometry.core.internal.GeometryInternalError;
 import org.apache.commons.geometry.euclidean.internal.Vectors;
 import org.apache.commons.geometry.euclidean.threed.AffineTransformMatrix3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
@@ -382,7 +382,7 @@
         }
 
         // all possibilities should have been covered above
-        throw new GeometryInternalException();
+        throw new GeometryInternalError();
     }
 
     /** Get a sequence of angles around the given Tait-Bryan axes that produce a rotation equivalent
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/ConvexArea2S.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/ConvexArea2S.java
index a144a66..fc9de10 100644
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/ConvexArea2S.java
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/ConvexArea2S.java
@@ -20,6 +20,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Iterator;
 import java.util.List;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -47,6 +48,12 @@
     /** Constant containing the area of half of the spherical space. */
     private static final double HALF_SIZE = PlaneAngleRadians.TWO_PI;
 
+    /** Empirically determined threshold for computing the weighted centroid vector using the
+     * triangle fan approach. Areas with boundary sizes under this value use the triangle fan
+     * method to increase centroid accuracy.
+     */
+    private static final double TRIANGLE_FAN_CENTROID_COMPUTE_THRESHOLD = 1e-2;
+
     /** Construct an instance from its boundaries. Callers are responsible for ensuring
      * that the given path represents the boundary of a convex area. No validation is
      * performed.
@@ -132,34 +139,35 @@
         return weighted == null ? null : Point2S.from(weighted);
     }
 
-    /** Returns the weighted vector for the centroid. This vector is computed by scaling the
-     * pole vector of the great circle of each boundary arc by the size of the arc and summing
-     * the results. By combining the weighted centroid vectors of multiple areas, a single
-     * centroid can be computed for the whole group.
+    /** Return the weighted centroid vector of the area. The returned vector points in the direction of the
+     * centroid point on the surface of the unit sphere with the length of the vector proportional to the
+     * effective mass of the area at the centroid. By adding the weighted centroid vectors of multiple
+     * convex areas, a single centroid can be computed for the combined area.
      * @return weighted centroid vector.
      * @see <a href="https://archive.org/details/centroidinertiat00broc">
      *  <em>The Centroid and Inertia Tensor for a Spherical Triangle</em> - John E. Brock</a>
      */
     Vector3D getWeightedCentroidVector() {
         final List<GreatArc> arcs = getBoundaries();
-        switch (arcs.size()) {
+        final int numBoundaries = arcs.size();
+
+        switch (numBoundaries) {
         case 0:
             // full space; no centroid
             return null;
         case 1:
-            // hemisphere; centroid is the pole of the hemisphere
-            final GreatArc singleArc = arcs.get(0);
-            return singleArc.getCircle().getPole().withNorm(singleArc.getSize());
+            // hemisphere
+            return computeHemisphereWeightedCentroidVector(arcs.get(0));
+        case 2:
+            // lune
+            return computeLuneWeightedCentroidVector(arcs.get(0), arcs.get(1));
         default:
-            // 2 or more sides; use an extension of the approach outlined here:
-            // https://archive.org/details/centroidinertiat00broc
-            // In short, the centroid is the sum of the pole vectors of each side
-            // multiplied by their arc lengths.
-            Vector3D centroid = Vector3D.ZERO;
-            for (final GreatArc arc : getBoundaries()) {
-                centroid = centroid.add(arc.getCircle().getPole().withNorm(arc.getSize()));
+            // triangle or other convex polygon
+            if (getBoundarySize() < TRIANGLE_FAN_CENTROID_COMPUTE_THRESHOLD) {
+                return computeTriangleFanWeightedCentroidVector(arcs);
             }
-            return centroid;
+
+            return computeArcPoleWeightedCentroidVector(arcs);
         }
     }
 
@@ -313,4 +321,131 @@
                 full() :
                 new ConvexArea2S(arcs);
     }
+
+    /** Compute the weighted centroid vector for the hemisphere formed by the given arc.
+     * @param arc arc defining the hemisphere
+     * @return the weighted centroid vector for the hemisphere
+     * @see #getWeightedCentroidVector()
+     */
+    private static Vector3D computeHemisphereWeightedCentroidVector(final GreatArc arc) {
+        return arc.getCircle().getPole().withNorm(HALF_SIZE);
+    }
+
+    /** Compute the weighted centroid vector for the lune formed by the given arcs.
+     * @param a first arc for the lune
+     * @param b second arc for the lune
+     * @return the weighted centroid vector for the lune
+     * @see #getWeightedCentroidVector()
+     */
+    private static Vector3D computeLuneWeightedCentroidVector(final GreatArc a, final GreatArc b) {
+        final Point2S aMid = a.getCentroid();
+        final Point2S bMid = b.getCentroid();
+
+        // compute the centroid vector as the exact center of the lune to avoid inaccurate
+        // results with very small regions
+        final Vector3D.Unit centroid = aMid.slerp(bMid, 0.5).getVector();
+
+        // compute the weight using the reverse of the algorithm from computeArcPoleWeightedCentroidVector()
+        final double weight =
+            (a.getSize() * centroid.dot(a.getCircle().getPole())) +
+            (b.getSize() * centroid.dot(b.getCircle().getPole()));
+
+        return centroid.withNorm(weight);
+    }
+
+    /** Compute the weighted centroid vector for the triangle or polygon formed by the given arcs
+     * by adding together the arc pole vectors multiplied by their respective arc lengths. This
+     * algorithm is described in the paper <a href="https://archive.org/details/centroidinertiat00broc">
+     * <em>The Centroid and Inertia Tensor for a Spherical Triangle</em></a> by John E Brock.
+     *
+     * <p>Note: This algorithm works well in general but is susceptible to floating point errors
+     * on very small areas. In these cases, the computed centroid may not be in the expected location
+     * and may even be outside of the area. The {@link #computeTriangleFanWeightedCentroidVector(List)}
+     * method can produce more accurate results in these cases.</p>
+     * @param arcs boundary arcs for the area
+     * @return the weighted centroid vector for the area
+     * @see #computeTriangleFanWeightedCentroidVector(List)
+     */
+    private static Vector3D computeArcPoleWeightedCentroidVector(final List<GreatArc> arcs) {
+        Vector3D centroid = Vector3D.ZERO;
+
+        Vector3D arcContribution;
+        for (final GreatArc arc : arcs) {
+            arcContribution = arc.getCircle().getPole().withNorm(arc.getSize());
+
+            centroid = centroid.add(arcContribution);
+        }
+
+        return centroid;
+    }
+
+    /** Compute the weighted centroid vector for the triangle or polygon formed by the given arcs
+     * using a triangle fan approach. This method is specifically designed for use with areas of very small size,
+     * where use of the standard algorithm from {@link ##computeArcPoleWeightedCentroidVector(List))} can produce
+     * inaccurate results. The algorithm proceeds as follows:
+     * <ol>
+     *  <li>The polygon is divided into spherical triangles using a triangle fan.</li>
+     *  <li>For each triangle, the vectors of the 3 spherical points are added together to approximate the direction
+     *      of the spherical centroid. This ensures that the computed centroid lies within the area.</li>
+     *  <li>The length of the weighted centroid vector is determined by computing the sum of the contributions that
+     *      each arc in the triangle would make to the centroid using the algorithm from
+     *      {@link ##computeArcPoleWeightedCentroidVector(List)}. This essentially performs part of that algorithm in
+     *      reverse: given a centroid direction, compute the contribution that each arc makes.</li>
+     *  <li>The sum of the weighted centroid vectors for each triangle is computed and returned.</li>
+     * </ol>
+     * @param arcs boundary arcs for the area; must contain at least 3 arcs
+     * @return the weighted centroid vector for the area
+     * @see ##computeArcPoleWeightedCentroidVector(List)
+     */
+    private static Vector3D computeTriangleFanWeightedCentroidVector(final List<GreatArc> arcs) {
+        final Iterator<GreatArc> arcIt = arcs.iterator();
+
+        final Point2S p0 = arcIt.next().getStartPoint();
+        final Vector3D.Unit v0 = p0.getVector();
+
+        Vector3D areaCentroid = Vector3D.ZERO;
+
+        GreatArc arc;
+        Point2S p1;
+        Point2S p2;
+        Vector3D.Unit v1;
+        Vector3D.Unit v2;
+        Vector3D.Unit triangleCentroid;
+        double triangleCentroidLen;
+        while (arcIt.hasNext()) {
+            arc = arcIt.next();
+
+            if (!arc.contains(p0)) {
+                p1 = arc.getStartPoint();
+                p2 = arc.getEndPoint();
+
+                v1 = p1.getVector();
+                v2 = p2.getVector();
+
+                triangleCentroid = v0.add(v1).add(v2).normalize();
+                triangleCentroidLen =
+                        computeArcCentroidContribution(v0, v1, triangleCentroid) +
+                        computeArcCentroidContribution(v1, v2, triangleCentroid) +
+                        computeArcCentroidContribution(v2, v0, triangleCentroid);
+
+                areaCentroid = areaCentroid.add(triangleCentroid.withNorm(triangleCentroidLen));
+            }
+        }
+
+        return areaCentroid;
+    }
+
+    /** Compute the contribution made by a single arc to a weighted centroid vector.
+     * @param a first point in the arc
+     * @param b second point in the arc
+     * @param triangleCentroid the centroid vector for the area
+     * @return the contribution made by the arc {@code ab} to the length of the weighted centroid vector
+     */
+    private static double computeArcCentroidContribution(final Vector3D.Unit a, final Vector3D.Unit b,
+            final Vector3D.Unit triangleCentroid) {
+        final double arcLength = a.angle(b);
+        final Vector3D.Unit planeNormal = a.cross(b).normalize();
+
+        return arcLength * triangleCentroid.dot(planeNormal);
+    }
 }
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircle.java
index c1e830c..4847449 100644
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircle.java
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircle.java
@@ -32,7 +32,8 @@
  * intersection of a sphere with a plane that passes through its center. It is
  * the largest diameter circle that can be drawn on the sphere and partitions the
  * sphere into two hemispheres. The vectors {@code u} and {@code v} lie in the great
- * circle plane, while the vector {@code w} (the pole) is perpendicular to it.
+ * circle plane, while the vector {@code w} (the pole) is perpendicular to it. The
+ * pole vector points toward the <em>minus</em> side of the hyperplane.
  *
  * <p>Instances of this class are guaranteed to be immutable.</p>
  * @see GreatCircles
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2S.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2S.java
index 5a9d20e..2b8cf2e 100644
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2S.java
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2S.java
@@ -171,16 +171,32 @@
         final DoublePrecisionContext precision = ((GreatArc) getRoot().getCut()).getPrecision();
 
         double sizeSum = 0;
-        Vector3D centroidVector = Vector3D.ZERO;
+        Vector3D centroidVectorSum = Vector3D.ZERO;
+
+        Vector3D centroidVector;
+        double maxCentroidVectorWeightSq = 0.0;
 
         for (final ConvexArea2S area : areas) {
             sizeSum += area.getSize();
-            centroidVector = centroidVector.add(area.getWeightedCentroidVector());
+
+            centroidVector = area.getWeightedCentroidVector();
+            maxCentroidVectorWeightSq = Math.max(maxCentroidVectorWeightSq, centroidVector.normSq());
+
+            centroidVectorSum = centroidVectorSum.add(centroidVector);
         }
 
-        final Point2S centroid = centroidVector.eq(Vector3D.ZERO, precision) ?
-                null :
-                Point2S.from(centroidVector);
+        // Convert the weighted centroid vector to a point on the sphere surface. If the centroid vector
+        // length is less than the max length of the combined convex areas and the vector itself is
+        // equivalent to zero, then we know that there are opposing and approximately equal areas in the
+        // region, resulting in an indeterminate centroid. This would occur, for example, if there were
+        // equal areas around each pole.
+        final Point2S centroid;
+        if (centroidVectorSum.normSq() < maxCentroidVectorWeightSq &&
+                centroidVectorSum.eq(Vector3D.ZERO, precision)) {
+            centroid = null;
+        } else {
+            centroid = Point2S.from(centroidVectorSum);
+        }
 
         return new RegionSizeProperties<>(sizeSum, centroid);
     }
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/ConvexArea2STest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/ConvexArea2STest.java
index 6683be5..ddcb505 100644
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/ConvexArea2STest.java
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/ConvexArea2STest.java
@@ -538,6 +538,76 @@
     }
 
     @Test
+    public void testGetCentroid_diminishingLunes() {
+        // arrange
+        final double eps = 1e-14;
+        final DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
+
+        final double centerAz = 1;
+        final double centerPolar = 0.5 * Math.PI;
+        final Point2S center = Point2S.of(centerAz, centerPolar);
+        final Point2S pole = Point2S.PLUS_K;
+
+        final double startOffset = PlaneAngleRadians.PI_OVER_TWO;
+        final double minOffset = 1e-14;
+
+        ConvexArea2S area;
+        Point2S p1;
+        Point2S p2;
+        Point2S centroid;
+        for (double offset = startOffset; offset > minOffset; offset *= 0.5) {
+            p1 = Point2S.of(centerAz - offset, centerPolar);
+            p2 = Point2S.of(centerAz + offset, centerPolar);
+
+            area = ConvexArea2S.fromBounds(
+                    GreatCircles.fromPoints(pole, p1, precision),
+                    GreatCircles.fromPoints(p2, pole, precision));
+
+            // act
+            centroid = area.getCentroid();
+
+            // assert
+            Assert.assertTrue(area.contains(centroid));
+            SphericalTestUtils.assertPointsEq(center, centroid, TEST_EPS);
+        }
+    }
+
+    @Test
+    public void testGetCentroid_diminishingSquares() {
+        // arrange
+        final double eps = 1e-14;
+        final DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
+
+        final double centerAz = 1;
+        final double centerPolar = 0.5 * Math.PI;
+        final Point2S center = Point2S.of(centerAz, centerPolar);
+
+        final double minOffset = 1e-14;
+
+        ConvexArea2S area;
+        Point2S p1;
+        Point2S p2;
+        Point2S p3;
+        Point2S p4;
+        Point2S centroid;
+        for (double offset = 0.5; offset > minOffset; offset *= 0.5) {
+            p1 = Point2S.of(centerAz, centerPolar - offset);
+            p2 = Point2S.of(centerAz - offset, centerPolar);
+            p3 = Point2S.of(centerAz, centerPolar + offset);
+            p4 = Point2S.of(centerAz + offset, centerPolar);
+
+            area = ConvexArea2S.fromVertexLoop(Arrays.asList(p1, p2, p3, p4), precision);
+
+            // act
+            centroid = area.getCentroid();
+
+            // assert
+            Assert.assertTrue(area.contains(centroid));
+            SphericalTestUtils.assertPointsEq(center, centroid, TEST_EPS);
+        }
+    }
+
+    @Test
     public void testBoundaryStream() {
         // arrange
         final GreatCircle circle = GreatCircles.fromPole(Vector3D.Unit.PLUS_X, TEST_PRECISION);
@@ -823,8 +893,10 @@
             final ConvexArea2S plus = split.getPlus();
             final double plusSize = plus.getSize();
 
-            final Point2S computedCentroid = Point2S.from(minus.getWeightedCentroidVector()
-                    .add(plus.getWeightedCentroidVector()));
+            final Vector3D minusWeightedCentroid = minus.getWeightedCentroidVector();
+            final Vector3D plusWeightedCentroid = plus.getWeightedCentroidVector();
+
+            final Point2S computedCentroid = Point2S.from(minusWeightedCentroid.add(plusWeightedCentroid));
 
             Assert.assertEquals(size, minusSize + plusSize, TEST_EPS);
             SphericalTestUtils.assertPointsEq(centroid, computedCentroid, TEST_EPS);
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2STest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2STest.java
index fb0784c..1b8032f 100644
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2STest.java
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2STest.java
@@ -21,6 +21,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import org.apache.commons.geometry.core.GeometryTestUtils;
 import org.apache.commons.geometry.core.RegionLocation;
@@ -361,7 +362,11 @@
         SphericalTestUtils.assertPointsEq(Point2S.MINUS_I,
                 tree.project(Point2S.of(PlaneAngleRadians.PI + 0.5, PlaneAngleRadians.PI_OVER_TWO)), TEST_EPS);
 
-        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, tree.project(tree.getCentroid()), TEST_EPS);
+        final Point2S centroid = tree.getCentroid();
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K,
+                tree.project(centroid.slerp(Point2S.PLUS_K, 1e-10)), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_J,
+                tree.project(centroid.slerp(Point2S.PLUS_J, 1e-10)), TEST_EPS);
     }
 
     @Test
@@ -560,6 +565,42 @@
     }
 
     @Test
+    public void testGeometricProperties_polygonWithHole_small() {
+        // arrange
+        final Point2S center = Point2S.of(0.5, 2);
+
+        final double outerRadius = 1e-5;
+        final double innerRadius = 1e-7;
+
+        final RegionBSPTree2S outer = buildDiamond(center, outerRadius);
+        final RegionBSPTree2S inner = buildDiamond(center, innerRadius);
+
+        // rotate the inner diamond a quarter turn to become a square
+        inner.transform(Transform2S.createRotation(center, 0.25 * Math.PI));
+
+        // act
+        final RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        tree.difference(outer, inner);
+
+        // assert
+
+        // use Euclidean approximations of the area and boundary size since those will be more accurate
+        // at these sizes
+        final double area = (2 * outerRadius * outerRadius) - (2 * innerRadius * innerRadius);
+        Assert.assertEquals(area, tree.getSize(), TEST_EPS);
+
+        final double outerSideLength = Math.hypot(outerRadius, outerRadius);
+        final double innerSideLength = Math.hypot(innerRadius, innerRadius);
+        final double boundarySize = 4 * (outerSideLength + innerSideLength);
+        Assert.assertEquals(boundarySize, tree.getBoundarySize(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEq(center, tree.getCentroid(), TEST_EPS);
+        checkCentroidConsistency(tree);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE, center);
+    }
+
+    @Test
     public void testGeometricProperties_polygonWithHole_complex() {
         // arrange
         final Point2S center = Point2S.of(0.5, 2);
@@ -602,6 +643,59 @@
     }
 
     @Test
+    public void testGeometricProperties_smallRightTriangle() {
+        // arrange
+        final double azOffset = 1e-5;
+        final double polarOffset = 1e-6;
+
+        final double minAz = 0;
+        final double maxAz = minAz + azOffset;
+        final double maxPolar = PlaneAngleRadians.PI_OVER_TWO;
+        final double minPolar = maxPolar - polarOffset;
+
+        final Point2S p0 = Point2S.of(minAz, maxPolar);
+        final Point2S p1 = Point2S.of(maxAz, maxPolar);
+        final Point2S p2 = Point2S.of(maxAz, minPolar);
+
+        // act
+        final RegionBSPTree2S tree = GreatArcPath.fromVertexLoop(Arrays.asList(p0, p1, p2), TEST_PRECISION)
+                .toTree();
+
+        // assert
+
+        // use Euclidean approximations of the area and boundary size since those will be more accurate
+        // at these sizes
+        final double expectedArea = 0.5 * azOffset * polarOffset;
+        Assert.assertEquals(expectedArea, tree.getSize(), TEST_EPS);
+
+        final double expectedBoundarySize = azOffset + polarOffset + Math.hypot(azOffset, polarOffset);
+        Assert.assertEquals(expectedBoundarySize, tree.getBoundarySize(), TEST_EPS);
+
+        Assert.assertTrue(tree.contains(tree.getCentroid()));
+        checkCentroidConsistency(tree);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.INSIDE,
+                tree.getCentroid(),
+                Point2S.of(minAz + (0.75 * azOffset), minPolar + (0.75 * polarOffset)));
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.BOUNDARY,
+                p0, p1, p2, p0.slerp(p1, 0.5), p1.slerp(p2, 0.5), p2.slerp(p0, 0.5));
+
+        final double midAz = minAz + (0.5 * azOffset);
+        final double pastMinAz = minAz - azOffset;
+        final double pastMaxAz = maxAz + azOffset;
+
+        final double midPolar = minPolar + (0.5 * polarOffset);
+        final double pastMinPolar = minPolar - polarOffset;
+        final double pastMaxPolar = maxPolar + polarOffset;
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE,
+                tree.getCentroid().antipodal(),
+                Point2S.of(pastMinAz, midPolar), Point2S.of(pastMaxAz, midPolar),
+                Point2S.of(midAz, pastMinPolar), Point2S.of(midAz, pastMaxPolar));
+    }
+
+    @Test
     public void testGeometricProperties_equalAndOppositeRegions() {
         // arrange
         final Point2S center = Point2S.PLUS_I;
@@ -761,7 +855,7 @@
     @Test
     public void testGeographicMap() {
         // arrange
-        final RegionBSPTree2S continental = latLongToTree(new double[][] {
+        final RegionBSPTree2S continental = latLongToTree(TEST_PRECISION, new double[][] {
                 {51.14850,  2.51357}, {50.94660,  1.63900}, {50.12717,  1.33876}, {49.34737, -0.98946},
                 {49.77634, -1.93349}, {48.64442, -1.61651}, {48.90169, -3.29581}, {48.68416, -4.59234},
                 {47.95495, -4.49155}, {47.57032, -2.96327}, {46.01491, -1.19379}, {44.02261, -1.38422},
@@ -771,7 +865,7 @@
                 {46.27298,  6.02260}, {46.72577,  6.03738}, {47.62058,  7.46675}, {49.01778,  8.09927},
                 {49.20195,  6.65822}, {49.44266,  5.89775}, {49.98537,  4.79922}
             });
-        final RegionBSPTree2S corsica = latLongToTree(new double[][] {
+        final RegionBSPTree2S corsica = latLongToTree(TEST_PRECISION, new double[][] {
                 {42.15249,  9.56001}, {43.00998,  9.39000}, {42.62812,  8.74600}, {42.25651,  8.54421},
                 {41.58361,  8.77572}, {41.38000,  9.22975}
             });
@@ -797,11 +891,12 @@
         final int numPts = 200;
 
         // counterclockwise
-        final RegionBSPTree2S ccw = circleToPolygon(center, radius, numPts, false);
-        SphericalTestUtils.assertPointsEq(center, ccw.getCentroid(), CENTROID_EPS);
+        final RegionBSPTree2S ccw = circleToPolygon(center, radius, numPts, false, TEST_PRECISION);
+        SphericalTestUtils.assertPointsEq(center, ccw.getCentroid(), TEST_EPS);
 
         // clockwise; centroid should just be antipodal for the circle center
-        final RegionBSPTree2S cw = circleToPolygon(center, radius, numPts, true);
+        final RegionBSPTree2S cw = circleToPolygon(center, radius, numPts, true, TEST_PRECISION);
+
         SphericalTestUtils.assertPointsEq(center.antipodal(), cw.getCentroid(), CENTROID_EPS);
     }
 
@@ -815,10 +910,10 @@
         final double ccwArea = 4.0 * PlaneAngleRadians.PI * Math.pow(Math.sin(radius / 2.0), 2.0);
         final double cwArea = 4.0 * PlaneAngleRadians.PI - ccwArea;
 
-        final RegionBSPTree2S ccw = circleToPolygon(center, radius, numPts, false);
+        final RegionBSPTree2S ccw = circleToPolygon(center, radius, numPts, false, TEST_PRECISION);
         Assert.assertEquals("Counterclockwise size", ccwArea, ccw.getSize(), TEST_EPS);
 
-        final RegionBSPTree2S cw = circleToPolygon(center, radius, numPts, true);
+        final RegionBSPTree2S cw = circleToPolygon(center, radius, numPts, true, TEST_PRECISION);
         Assert.assertEquals("Clockwise size", cwArea, cw.getSize(), TEST_EPS);
     }
 
@@ -831,13 +926,59 @@
         // boundary size is independent from winding
         final double boundary = PlaneAngleRadians.TWO_PI * Math.sin(radius);
 
-        final RegionBSPTree2S ccw = circleToPolygon(center, radius, numPts, false);
+        final RegionBSPTree2S ccw = circleToPolygon(center, radius, numPts, false, TEST_PRECISION);
         Assert.assertEquals("Counterclockwise boundary size", boundary, ccw.getBoundarySize(), 1.0e-7);
 
-        final RegionBSPTree2S cw = circleToPolygon(center, radius, numPts, true);
+        final RegionBSPTree2S cw = circleToPolygon(center, radius, numPts, true, TEST_PRECISION);
         Assert.assertEquals("Clockwise boundary size", boundary, cw.getBoundarySize(), 1.0e-7);
     }
 
+    @Test
+    public void testSmallCircleToPolygon() {
+        // arrange
+        final double radius = 5.0e-8;
+        final Point2S center = Point2S.of(0.5, 1.5);
+        final int numPts = 100;
+
+        // act
+        final RegionBSPTree2S circle = circleToPolygon(center, radius, numPts, false, TEST_PRECISION);
+
+        // assert
+        // https://en.wikipedia.org/wiki/Spherical_cap
+        final double area = 4.0 * PlaneAngleRadians.PI * Math.pow(Math.sin(radius / 2.0), 2.0);
+        final double boundary = PlaneAngleRadians.TWO_PI * Math.sin(radius);
+
+        SphericalTestUtils.assertPointsEq(center, circle.getCentroid(), TEST_EPS);
+        Assert.assertEquals(area, circle.getSize(), TEST_EPS);
+        Assert.assertEquals(boundary, circle.getBoundarySize(), TEST_EPS);
+    }
+
+    @Test
+    public void testSmallGeographicalRectangle() {
+        // arrange
+        final double[][] vertices = {
+            {42.656216727628696, -70.61919768884546},
+            {42.65612858998112, -70.61938607250165},
+            {42.65579098923594, -70.61909615581666},
+            {42.655879126692355, -70.61890777301083}
+        };
+
+        // act
+        final RegionBSPTree2S rectangle = latLongToTree(TEST_PRECISION, vertices);
+
+        // assert
+        // approximate the centroid as average of vertices
+        final double avgLat = Stream.of(vertices).mapToDouble(v -> v[0]).average().getAsDouble();
+        final double avgLon = Stream.of(vertices).mapToDouble(v -> v[1]).average().getAsDouble();
+        final Point2S expectedCentroid = latLongToPoint(avgLat, avgLon);
+
+        SphericalTestUtils.assertPointsEq(expectedCentroid, rectangle.getCentroid(), TEST_EPS);
+
+        // expected results computed using GeographicLib (https://geographiclib.sourceforge.io/)
+        Assert.assertEquals(1.997213869978027E-11, rectangle.getSize(), TEST_EPS);
+        Assert.assertEquals(1.9669710464585642E-5, rectangle.getBoundarySize(), TEST_EPS);
+    }
+
     /**
      * Insert hyperplane convex subsets defining the positive quadrant area.
      * @param tree
@@ -875,8 +1016,8 @@
         }
     }
 
-    private static RegionBSPTree2S latLongToTree(final double[][] points) {
-        final GreatArcPath.Builder pathBuilder = GreatArcPath.builder(TEST_PRECISION);
+    private static RegionBSPTree2S latLongToTree(final DoublePrecisionContext precision, final double[][] points) {
+        final GreatArcPath.Builder pathBuilder = GreatArcPath.builder(precision);
 
         for (int i = 0; i < points.length; ++i) {
             pathBuilder.append(latLongToPoint(points[i][0], points[i][1]));
@@ -972,10 +1113,11 @@
         final double angleB = Math.asin(Math.sin(b) / sinC);
 
         // use Girard's theorem
-        return angleA + angleB  - (0.5 * PlaneAngleRadians.PI);
+        return angleA + angleB - PlaneAngleRadians.PI_OVER_TWO;
     }
 
-    private static RegionBSPTree2S circleToPolygon(final Point2S center, final double radius, final int numPts, final boolean clockwise) {
+    private static RegionBSPTree2S circleToPolygon(final Point2S center, final double radius, final int numPts,
+                                                   final boolean clockwise, final DoublePrecisionContext precision) {
         final List<Point2S> pts = new ArrayList<>(numPts);
 
         // get an arbitrary point on the circle boundary
@@ -990,6 +1132,6 @@
             pts.add(rotate.apply(pts.get(i - 1)));
         }
 
-        return GreatArcPath.fromVertexLoop(pts, TEST_PRECISION).toTree();
+        return GreatArcPath.fromVertexLoop(pts, precision).toTree();
     }
 }